21 Commits

Author SHA1 Message Date
gongyun 6dc0c8bd48 Merge pull request '构建修复' (#10) from dev into main
Android APK Release / Build Android APK (push) Successful in 1h16m25s
Reviewed-on: #10
2026-06-04 21:40:33 +08:00
gongyun c143ae4c1c Merge branch 'main' into dev 2026-06-04 21:40:25 +08:00
gongyun 45e487e7a0 Merge branch 'dev' of ssh://git.saont.net:222/gongyun/app into dev 2026-06-04 21:38:09 +08:00
gongyun 7afa4bf156 构建修复 2026-06-04 21:33:07 +08:00
gongyun 18def0753f Merge pull request '上游源码合并与功能更新' (#9) from dev into main
Android APK Release / Build Android APK (push) Failing after 25m9s
Reviewed-on: #9
  - 上游源码合并增补相关功能与补丁
  - v1.3.6版本开始付费用户可以自行选择中国大陆地区优化专线节点以及手动申请独享专线节点
    - 进入优化专线节点方式为各客户端中设置页面下"网络测试"选项进入即可看到“自定义线路”选项
2026-06-04 18:55:25 +08:00
gongyun 526990a01a Merge branch 'main' into dev 2026-06-04 18:52:43 +08:00
gongyun db10873aaf 构建环境镜像添加 2026-06-04 18:17:10 +08:00
gongyun e6c60a9d6d 自定义线路与线路优化 2026-06-04 17:52:16 +08:00
gongyun 33509de4e3 正式上线"自定义线路"功能(仅限付费用户组) 2026-06-04 11:10:44 +08:00
gongyun 42db3f9683 版号递增 2026-06-04 07:36:52 +08:00
gongyun eef561796a Merge branch 'dev' of ssh://git.saont.net:222/gongyun/app into dev 2026-06-04 07:17:09 +08:00
gongyun b02daf1448 主要合入内容:
- 文件管理器分页加载、排序偏好、桌面表头排序、移动端排序菜单、Ctrl/Cmd+F搜索、Esc 关闭搜索、鼠标右滑返回上级。
- 移动端同步状态页、同步统计卡片、同步任务/累计统计事件、同步页布局更新。
- Android 相册同步默认路径改为 DCIM/Camera,下载路径改用 external_path。
- Rust sync-core 更新:Android 日志依赖、AlbumUpload/AlbumDownload、MirrorWcf 统计修复、Linux FUSE镜像挂载与读写同步支持。

- 依赖更新:external_path、fl_chart、pdfrx 升级,并修了 pdfrx 新版本的deprecated API。
- 新增文件包括:sort_options.dart、sync_page_android.dart、sync_stats_card.dart、platform/fuse.rs、sync_engine/fuse.rs。
2026-06-04 07:11:43 +08:00
gongyun 3b94f40def Merge pull request '新增网络测试功能,预留付费用户线路自定义与线路自选入口' (#8) from dev into main
Android APK Release / Build Android APK (push) Successful in 1h6m5s
Reviewed-on: #8
2026-05-31 10:23:10 +08:00
gongyun edcde96051 Merge branch 'main' into dev 2026-05-31 10:21:47 +08:00
gongyun 5ee6ba1e28 完善PR审查后出现的问题 2026-05-31 10:13:36 +08:00
gongyun 61ad85f6fc Optimize automatic updates 2026-05-31 09:32:32 +08:00
gongyun 8dfc22691a 新增网络测试功能,预留付费用户线路自定义与线路自选入口 2026-05-31 08:19:13 +08:00
gongyun c583c51d80 Merge pull request 'Upstream source code synchronization' (#6) from dev into main
Android APK Release / Build Android APK (push) Successful in 1h2m9s
Reviewed-on: #6
2026-05-28 23:13:18 +08:00
gongyun 4d6ca139e5 v1.3.4 2026-05-28 22:59:25 +08:00
gongyun 81864c99c2 Upstream source code synchronization 2026-05-28 22:08:12 +08:00
gongyun fb5fd6c9bc 统一tag与release版本号格式 2026-05-28 05:24:02 +08:00
84 changed files with 11170 additions and 1657 deletions
+18 -8
View File
@@ -12,6 +12,9 @@ permissions:
contents: write contents: write
releases: write releases: write
env:
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
jobs: jobs:
android: android:
name: Build Android APK name: Build Android APK
@@ -113,7 +116,7 @@ jobs:
flutter doctor -v flutter doctor -v
- name: Install dependencies - name: Install dependencies
run: flutter pub get run: flutter pub get --enforce-lockfile
- name: Restore Android signing files - name: Restore Android signing files
shell: bash shell: bash
@@ -140,10 +143,12 @@ jobs:
- name: Build Android release APK - name: Build Android release APK
shell: bash shell: bash
env:
IP_NODE_API_BASE_URL: ${{ secrets.IP_NODE_API_BASE_URL }}
run: | run: |
set -euo pipefail set -euo pipefail
rm -rf build dist .dart_tool android/.gradle rm -rf build dist android/.gradle
pkg_dir="$PWD/dist/android" pkg_dir="$PWD/dist/android"
if [ -d "$pkg_dir" ]; then if [ -d "$pkg_dir" ]; then
@@ -151,8 +156,13 @@ jobs:
rm -rf "$pkg_dir" rm -rf "$pkg_dir"
fi fi
build_args=(--release --no-pub)
if [ -n "${IP_NODE_API_BASE_URL:-}" ]; then
build_args+=("--dart-define=IP_NODE_API_BASE_URL=${IP_NODE_API_BASE_URL}")
fi
echo "Building Flutter Android release APK..." echo "Building Flutter Android release APK..."
flutter build apk -v --release flutter build apk "${build_args[@]}"
mkdir -p "$pkg_dir" mkdir -p "$pkg_dir"
apk_path="$(find "$PWD/build/app/outputs" -type f -name '*release*.apk' | head -n 1)" apk_path="$(find "$PWD/build/app/outputs" -type f -name '*release*.apk' | head -n 1)"
@@ -191,7 +201,7 @@ jobs:
exit 1 exit 1
fi fi
if [[ "$GITHUB_REF" == refs/tags/android-app-v* ]]; then if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
tag_name="${GITHUB_REF#refs/tags/}" tag_name="${GITHUB_REF#refs/tags/}"
else else
app_version="$(awk -F '[:+]' '/^version:/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' pubspec.yaml)" app_version="$(awk -F '[:+]' '/^version:/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' pubspec.yaml)"
@@ -199,7 +209,7 @@ jobs:
echo "Unable to resolve app version from pubspec.yaml." echo "Unable to resolve app version from pubspec.yaml."
exit 1 exit 1
fi fi
tag_name="android-app-v${app_version}" tag_name="v${app_version}"
fi fi
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT" echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
@@ -249,7 +259,7 @@ jobs:
releases_url="$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases" releases_url="$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases"
release_by_tag_url="$releases_url/tags/$TAG_NAME" release_by_tag_url="$releases_url/tags/$TAG_NAME"
export RELEASE_TITLE="Android App APK ${TAG_NAME}" export RELEASE_TITLE="${TAG_NAME}"
export RELEASE_BODY="Android app installation package (APK) for ${TAG_NAME}." export RELEASE_BODY="Android app installation package (APK) for ${TAG_NAME}."
status="$(curl -sS -o release.json -w "%{http_code}" \ status="$(curl -sS -o release.json -w "%{http_code}" \
@@ -265,7 +275,7 @@ jobs:
print(json.dumps({ print(json.dumps({
"tag_name": tag, "tag_name": tag,
"target_commitish": os.environ.get("TARGET_COMMITISH", ""), "target_commitish": os.environ.get("TARGET_COMMITISH", ""),
"name": f"Android App APK {tag}", "name": os.environ["RELEASE_TITLE"],
"body": f"Android app installation package (APK) for {tag}.", "body": f"Android app installation package (APK) for {tag}.",
"draft": False, "draft": False,
"prerelease": False, "prerelease": False,
@@ -353,7 +363,7 @@ jobs:
fi fi
done < asset-ids-to-delete.txt done < asset-ids-to-delete.txt
release_asset_name="gongyun-${TAG_NAME}.apk" release_asset_name="app-release-${TAG_NAME}.apk"
curl -sS -f \ curl -sS -f \
-X POST \ -X POST \
-H "Authorization: token $GITEA_TOKEN" \ -H "Authorization: token $GITEA_TOKEN" \
+1
View File
@@ -66,6 +66,7 @@ lib/firebase_options.dart
native/target native/target
native/logs native/logs
sync_refactory.md sync_refactory.md
linux-fuse.md
tools/* tools/*
*.diff *.diff
android/app/src/main/jniLibs/* android/app/src/main/jniLibs/*
+11 -3
View File
@@ -160,6 +160,14 @@ Linux: Debian 12
flutter pub get flutter pub get
``` ```
如果当前网络无法解析 `pub.dev`,先设置 Pub/Flutter 镜像:
```powershell
$env:PUB_HOSTED_URL="https://pub.flutter-io.cn"
$env:FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn"
flutter pub get
```
### 运行项目 ### 运行项目
```bash ```bash
@@ -170,11 +178,11 @@ flutter run # pdf 和 音视频会再构建过程中下载github上的依赖,
```bash ```bash
# Android # Android
flutter build apk --release flutter build apk --release --no-pub
# Linux # Linux
flutter build -d linux --release flutter build -d linux --release --no-pub
# windows # windows
flutter build -d windows --release flutter build -d windows --release --no-pub
``` ```
--- ---
+11 -1
View File
@@ -81,7 +81,17 @@ android {
versionName = flutter.versionName versionName = flutter.versionName
ndk { 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
} }
} }
+70
View File
@@ -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';
}
}
+9
View File
@@ -14,6 +14,7 @@ class StorageKeys {
static const String downloadTasks = 'download_tasks'; static const String downloadTasks = 'download_tasks';
static const String downloadWifiOnly = 'download_wifi_only'; static const String downloadWifiOnly = 'download_wifi_only';
static const String downloadRetries = 'download_retries'; static const String downloadRetries = 'download_retries';
static const String selectedCustomLineNode = 'selected_custom_line_node';
// 任务记录 // 任务记录
static const String taskRetentionDays = 'task_retention_days'; static const String taskRetentionDays = 'task_retention_days';
@@ -31,12 +32,20 @@ class StorageKeys {
// 同步相关 // 同步相关
static const String syncConfig = 'sync_config'; static const String syncConfig = 'sync_config';
static const String syncState = 'sync_state'; static const String syncState = 'sync_state';
static const String syncCumStats = 'sync_cum_stats';
static const String clientId = 'client_id'; static const String clientId = 'client_id';
// 文件排序
static const String fileSortOption = 'file_sort_option';
// 日志级别 // 日志级别
static const String logLevel = 'app_log_level'; static const String logLevel = 'app_log_level';
// 公告 // 公告
static const String siteAnnouncementDismissedFingerprint = static const String siteAnnouncementDismissedFingerprint =
'site_announcement_dismissed_fingerprint'; 'site_announcement_dismissed_fingerprint';
// 版本更新
static const String updatePromptSkipUntil = 'update_prompt_skip_until';
static const String updatePromptSkipVersion = 'update_prompt_skip_version';
} }
+30 -3
View File
@@ -1,22 +1,32 @@
import 'dart:io'; import 'dart:io';
import 'package:external_path/external_path.dart';
class SyncDefaults { class SyncDefaults {
SyncDefaults._(); SyncDefaults._();
/// 默认同步目录 /// 默认同步目录(同步版本,Android 返回空串,需用 getDefaultLocalRoot 异步获取)
static String defaultLocalRoot() { static String defaultLocalRoot() {
if (Platform.isWindows) { if (Platform.isWindows) {
return '${Platform.environment['USERPROFILE']}\\Documents\\Cloudreve4'; return '${Platform.environment['USERPROFILE']}\\Documents\\Cloudreve4';
} else if (Platform.isLinux) { } else if (Platform.isLinux) {
final xdgDownload = final xdgDownload =
Platform.environment['XDG_DOWNLOAD_DIR'] ?? ("${Platform.environment['HOME'] ?? ''}/Downloads"); Platform.environment['XDG_DOWNLOAD_DIR'] ??
("${Platform.environment['HOME'] ?? ''}/Downloads");
return '$xdgDownload/Cloudreve4'; return '$xdgDownload/Cloudreve4';
} else if (Platform.isAndroid) { } else if (Platform.isAndroid) {
return ''; // Android 使用系统相册目录 return '';
} }
return ''; return '';
} }
/// 默认同步目录(异步版本,Android 通过 ExternalPath 获取公共目录)
static Future<String> getDefaultLocalRoot() async {
if (Platform.isAndroid) {
return getDefaultAndroidLocalRoot();
}
return defaultLocalRoot();
}
static const String defaultRemoteRoot = 'cloudreve://my'; static const String defaultRemoteRoot = 'cloudreve://my';
static const String defaultSyncMode = 'full'; static const String defaultSyncMode = 'full';
static const String defaultConflictStrategy = 'keep_both'; static const String defaultConflictStrategy = 'keep_both';
@@ -24,4 +34,21 @@ class SyncDefaults {
static const int defaultBandwidthLimitKbps = 0; static const int defaultBandwidthLimitKbps = 0;
static const int defaultMaxWorkers = 0; // 0 = CPU 核心数 static const int defaultMaxWorkers = 0; // 0 = CPU 核心数
static const String defaultLogLevel = 'info'; static const String defaultLogLevel = 'info';
// ===== Android 相册同步专用 =====
static String get defaultAndroidLocalRoot {
// runtime 获取 DCIM 公共目录,再拼接 /Camera
// ExternalPath 无法 const,使用 getter 延迟求值
throw UnsupportedError('Use getDefaultAndroidLocalRoot() instead');
}
static Future<String> 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';
} }
+80
View File
@@ -50,6 +50,86 @@ class SyncInitialSyncComplete extends SyncEventModel {
SyncInitialSyncComplete(this.summary); 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 { class SyncSummaryModel {
final int uploaded; final int uploaded;
final int downloaded; final int downloaded;
+2
View File
@@ -29,6 +29,7 @@ import 'services/storage_service.dart';
import 'services/server_service.dart'; import 'services/server_service.dart';
import 'services/cache_manager_service.dart'; import 'services/cache_manager_service.dart';
import 'services/avatar_cache_service.dart'; import 'services/avatar_cache_service.dart';
import 'services/host_mapping_proxy_service.dart';
import 'core/utils/video_fullscreen.dart'; import 'core/utils/video_fullscreen.dart';
import 'services/desktop_service.dart'; import 'services/desktop_service.dart';
import 'router/app_router.dart'; import 'router/app_router.dart';
@@ -51,6 +52,7 @@ Level _parseLogLevel(String level) {
void main() async { void main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
HttpOverrides.global = HostMappingHttpOverrides();
await AppLogger.init(); await AppLogger.init();
final savedLevel = await StorageService.instance.getString( final savedLevel = await StorageService.instance.getString(
@@ -4,10 +4,13 @@ import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../../core/utils/date_utils.dart' as date_utils; 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 '../../../core/utils/file_type_utils.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../router/app_router.dart'; import '../../../router/app_router.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
import '../../../services/storage_service.dart';
import '../../providers/file_manager_provider.dart'; import '../../providers/file_manager_provider.dart';
import '../../widgets/file_info_dialog.dart'; import '../../widgets/file_info_dialog.dart';
import '../../widgets/file_operation_dialogs.dart'; import '../../widgets/file_operation_dialogs.dart';
@@ -52,10 +55,7 @@ class CategoryFilesPageArgs {
class CategoryFilesPage extends StatefulWidget { class CategoryFilesPage extends StatefulWidget {
final CategoryFilesPageArgs args; final CategoryFilesPageArgs args;
const CategoryFilesPage({ const CategoryFilesPage({super.key, required this.args});
super.key,
required this.args,
});
@override @override
State<CategoryFilesPage> createState() => _CategoryFilesPageState(); State<CategoryFilesPage> createState() => _CategoryFilesPageState();
@@ -73,6 +73,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
bool _isLoading = true; bool _isLoading = true;
bool _isLoadingMore = false; bool _isLoadingMore = false;
String? _errorMessage; String? _errorMessage;
SortOption _sortOption = SortOption.default_;
bool get _hasSelection => _selectedFilePaths.isNotEmpty; bool get _hasSelection => _selectedFilePaths.isNotEmpty;
@@ -83,6 +84,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_restoreSortOption();
_loadFiles(refresh: true); _loadFiles(refresh: true);
_scrollController.addListener(_onScroll); _scrollController.addListener(_onScroll);
} }
@@ -133,11 +135,14 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
final response = await _fileService.listFilesByCategory( final response = await _fileService.listFilesByCategory(
category: widget.args.category, category: widget.args.category,
pageSize: 100, pageSize: 100,
orderBy: _sortOption.field.apiKey,
orderDirection: _sortOption.direction.apiKey,
nextPageToken: refresh ? null : _nextPageToken, nextPageToken: refresh ? null : _nextPageToken,
); );
final filesData = response['files'] as List<dynamic>? ?? const []; final filesData = response['files'] as List<dynamic>? ?? const [];
final pagination = response['pagination'] as Map<String, dynamic>? ?? const {}; final pagination =
response['pagination'] as Map<String, dynamic>? ?? const {};
final newFiles = filesData final newFiles = filesData
.map((item) => FileModel.fromJson(item as Map<String, dynamic>)) .map((item) => FileModel.fromJson(item as Map<String, dynamic>))
.where((file) => file.isFile) .where((file) => file.isFile)
@@ -152,7 +157,9 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
..addAll(newFiles); ..addAll(newFiles);
} else { } else {
final existingIds = _files.map((e) => e.id).toSet(); 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?; _nextPageToken = pagination['next_token'] as String?;
@@ -170,8 +177,65 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
} }
} }
Widget _buildSortMenu() {
final allOptions = [
for (final field in SortField.values)
for (final dir in SortDirection.values) SortOption(field, dir),
];
return PopupMenuButton<SortOption>(
icon: const Icon(LucideIcons.arrowUpDown),
tooltip: '排序',
position: PopupMenuPosition.under,
onSelected: _setSortOption,
itemBuilder: (context) => allOptions.map((option) {
final isSelected = _sortOption == option;
return PopupMenuItem<SortOption>(
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<void> _refresh() => _loadFiles(refresh: true); Future<void> _refresh() => _loadFiles(refresh: true);
Future<void> _restoreSortOption() async {
final key = await StorageService.instance.getString(
StorageKeys.fileSortOption,
);
final option = SortOption.fromKey(key);
if (option != _sortOption) {
setState(() => _sortOption = option);
}
}
Future<void> _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) { void _toggleSelection(FileModel file) {
HapticFeedback.selectionClick(); HapticFeedback.selectionClick();
setState(() { setState(() {
@@ -207,10 +271,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
}, },
child: Scaffold( child: Scaffold(
appBar: _buildAppBar(context), appBar: _buildAppBar(context),
body: RefreshIndicator( body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
onRefresh: _refresh,
child: _buildBody(context),
),
bottomNavigationBar: _buildSelectionBottomBar(context), bottomNavigationBar: _buildSelectionBottomBar(context),
), ),
); );
@@ -230,10 +291,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
centerTitle: true, centerTitle: true,
title: Text('已选中 ${_selectedFilePaths.length} 个文件'), title: Text('已选中 ${_selectedFilePaths.length} 个文件'),
actions: [ actions: [
TextButton( TextButton(onPressed: _selectAllVisible, child: const Text('全选')),
onPressed: _selectAllVisible,
child: const Text('全选'),
),
], ],
); );
} }
@@ -241,6 +299,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
return AppBar( return AppBar(
title: Text(args.title), title: Text(args.title),
actions: [ actions: [
_buildSortMenu(),
IconButton( IconButton(
icon: const Icon(LucideIcons.refreshCw), icon: const Icon(LucideIcons.refreshCw),
tooltip: '刷新', tooltip: '刷新',
@@ -265,10 +324,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
layoutBuilder: (currentChild, previousChildren) { layoutBuilder: (currentChild, previousChildren) {
return Stack( return Stack(
alignment: Alignment.bottomCenter, alignment: Alignment.bottomCenter,
children: <Widget>[ children: <Widget>[...previousChildren, ?currentChild],
...previousChildren,
?currentChild,
],
); );
}, },
transitionBuilder: (child, animation) { transitionBuilder: (child, animation) {
@@ -359,11 +415,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
children: [ children: [
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25), SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
Icon( Icon(widget.args.icon, size: 52, color: widget.args.color),
widget.args.icon,
size: 52,
color: widget.args.color,
),
const SizedBox(height: 12), const SizedBox(height: 12),
Center( Center(
child: Text( child: Text(
@@ -391,7 +443,8 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
for (final file in _files) { for (final file in _files) {
final targetIndex = _indexOfMin(heights); final targetIndex = _indexOfMin(heights);
columns[targetIndex].add(file); columns[targetIndex].add(file);
heights[targetIndex] += _estimatedTileHeight(file, columnWidth) + spacing; heights[targetIndex] +=
_estimatedTileHeight(file, columnWidth) + spacing;
} }
return ListView( return ListView(
@@ -417,12 +470,16 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
Padding( Padding(
padding: EdgeInsets.only(bottom: spacing), padding: EdgeInsets.only(bottom: spacing),
child: _CategoryFileTile( 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, file: file,
contextHint: _contextHint, contextHint: _contextHint,
category: widget.args.category, category: widget.args.category,
accentColor: widget.args.color, accentColor: widget.args.color,
isSelected: _selectedFilePaths.contains(file.path), isSelected: _selectedFilePaths.contains(
file.path,
),
selectionMode: _hasSelection, selectionMode: _hasSelection,
onTap: () { onTap: () {
if (_hasSelection) { if (_hasSelection) {
@@ -536,11 +593,17 @@ class _CategoryFilesPageState extends State<CategoryFilesPage>
} else if (FileTypeUtils.isAudio(file.name)) { } else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file); Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) { } 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)) { } else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file); Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else { } else {
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}'); ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
} }
} }
@@ -760,9 +823,7 @@ class _CategoryFileTile extends StatelessWidget {
), ),
), ),
if (category == 'video') if (category == 'video')
const Center( const Center(child: _PlayOverlay()),
child: _PlayOverlay(),
),
], ],
), ),
), ),
@@ -807,8 +868,9 @@ class _CategoryFileTile extends StatelessWidget {
boxShadow: isSelected boxShadow: isSelected
? [ ? [
BoxShadow( BoxShadow(
color: theme.colorScheme.primary color: theme.colorScheme.primary.withValues(
.withValues(alpha: 0.12), alpha: 0.12,
),
blurRadius: 10, blurRadius: 10,
spreadRadius: 0.5, spreadRadius: 0.5,
), ),
@@ -877,10 +939,7 @@ class _SelectionCircle extends StatelessWidget {
final bool selected; final bool selected;
final VoidCallback? onTap; final VoidCallback? onTap;
const _SelectionCircle({ const _SelectionCircle({required this.selected, this.onTap});
required this.selected,
this.onTap,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -915,11 +974,7 @@ class _SelectionCircle extends StatelessWidget {
], ],
), ),
child: selected child: selected
? const Icon( ? const Icon(LucideIcons.check, color: Colors.white, size: 16)
LucideIcons.check,
color: Colors.white,
size: 16,
)
: null, : null,
), ),
), ),
@@ -948,10 +1003,7 @@ class _TypeBadge extends StatelessWidget {
borderRadius: BorderRadius.circular(9), borderRadius: BorderRadius.circular(9),
), ),
child: Padding( child: Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4),
horizontal: compact ? 6 : 7,
vertical: 4,
),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -986,11 +1038,7 @@ class _PlayOverlay extends StatelessWidget {
), ),
child: const Padding( child: const Padding(
padding: EdgeInsets.all(10), padding: EdgeInsets.all(10),
child: Icon( child: Icon(LucideIcons.play, color: Colors.white, size: 22),
LucideIcons.play,
color: Colors.white,
size: 22,
),
), ),
); );
} }
+409 -106
View File
@@ -4,10 +4,12 @@ import 'dart:ui';
import 'package:cross_file/cross_file.dart'; import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.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/data/models/file_model.dart';
import 'package:cloudreve4_flutter/services/file_service.dart'; import 'package:cloudreve4_flutter/services/file_service.dart';
import 'package:cloudreve4_flutter/services/upload_service.dart'; import 'package:cloudreve4_flutter/services/upload_service.dart';
import '../../../core/utils/file_utils.dart'; import '../../../core/utils/file_utils.dart';
import '../../../core/constants/sort_options.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -41,6 +43,8 @@ class _FilesPageState extends State<FilesPage> {
bool _isFirstLoad = true; bool _isFirstLoad = true;
FileModel? _infoFile; FileModel? _infoFile;
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>(); final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
final ScrollController _scrollController = ScrollController();
final ScrollController _breadcrumbController = ScrollController();
// FAB 状态 // FAB 状态
bool _isFabVisible = true; bool _isFabVisible = true;
@@ -50,24 +54,37 @@ class _FilesPageState extends State<FilesPage> {
// 桌面端拖拽状态 // 桌面端拖拽状态
bool _isDraggingOver = false; bool _isDraggingOver = false;
// 滑动手势追踪
Offset? _swipeStartPos;
DateTime? _swipeStartTime;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_scrollController.addListener(_onScrollForPagination);
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
Future.delayed(const Duration(milliseconds: 100), () { Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) { if (mounted) {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
if (screenWidth >= 1000) { if (screenWidth >= 1000) {
fileManager.setViewType(FileViewType.grid); fileManager.setViewType(FileViewType.grid);
} else { } else {
fileManager.setViewType(FileViewType.list); fileManager.setViewType(FileViewType.list);
} }
fileManager.restoreSortOption();
if (_isFirstLoad) { if (_isFirstLoad) {
fileManager.loadFiles(); fileManager.loadFiles();
_isFirstLoad = false; _isFirstLoad = false;
} }
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false); final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
downloadManager.initialize(); downloadManager.initialize();
} }
}); });
@@ -75,8 +92,13 @@ class _FilesPageState extends State<FilesPage> {
// 上传完成 → 自动刷新当前目录文件列表 // 上传完成 → 自动刷新当前目录文件列表
UploadService.instance.onUploadCompleted = (targetPath, fileName) { UploadService.instance.onUploadCompleted = (targetPath, fileName) {
if (!mounted) return; if (!mounted) return;
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); final fileManager = Provider.of<FileManagerProvider>(
final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath); context,
listen: false,
);
final normalizedCurrent = FileUtils.toCloudreveUri(
fileManager.currentPath,
);
if (targetPath == normalizedCurrent) { if (targetPath == normalizedCurrent) {
final fileUri = targetPath.endsWith('/') final fileUri = targetPath.endsWith('/')
? '$targetPath$fileName' ? '$targetPath$fileName'
@@ -88,11 +110,74 @@ class _FilesPageState extends State<FilesPage> {
@override @override
void dispose() { void dispose() {
_scrollController.removeListener(_onScrollForPagination);
_scrollController.dispose();
_breadcrumbController.dispose();
_fabShowTimer?.cancel(); _fabShowTimer?.cancel();
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
UploadService.instance.onUploadCompleted = null; UploadService.instance.onUploadCompleted = null;
super.dispose(); super.dispose();
} }
void _onScrollForPagination() {
if (!_scrollController.hasClients) return;
final fileManager = Provider.of<FileManagerProvider>(
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<NavigationProvider>(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<FileManagerProvider>(
context,
listen: false,
);
if (fileManager.currentPath != '/') {
fileManager.goBack();
}
}
}
void _showFileInfo(FileModel file) { void _showFileInfo(FileModel file) {
setState(() => _infoFile = file); setState(() => _infoFile = file);
// 等待下一帧 rebuild 完成,endDrawer 从 null 变为非 null 后再打开 // 等待下一帧 rebuild 完成,endDrawer 从 null 变为非 null 后再打开
@@ -101,10 +186,7 @@ class _FilesPageState extends State<FilesPage> {
}); });
} }
void _showSelectionMore( void _showSelectionMore(FileModel file, FileManagerProvider fileManager) {
FileModel file,
FileManagerProvider fileManager,
) {
showModalBottomSheet<void>( showModalBottomSheet<void>(
context: context, context: context,
builder: (sheetContext) => SafeArea( builder: (sheetContext) => SafeArea(
@@ -116,7 +198,11 @@ class _FilesPageState extends State<FilesPage> {
title: const Text('重命名'), title: const Text('重命名'),
onTap: () { onTap: () {
Navigator.of(sheetContext).pop(); Navigator.of(sheetContext).pop();
FileOperationDialogs.showRenameDialog(context, fileManager, file); FileOperationDialogs.showRenameDialog(
context,
fileManager,
file,
);
}, },
), ),
ListTile( ListTile(
@@ -179,13 +265,17 @@ class _FilesPageState extends State<FilesPage> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDesktop = MediaQuery.of(context).size.width >= 1000; final isDesktop = MediaQuery.of(context).size.width >= 1000;
return Scaffold( return Listener(
onPointerDown: _onPointerDown,
onPointerUp: _onPointerUp,
child: Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
appBar: _buildAppBar(context), appBar: _buildAppBar(context),
body: _buildBody(context), body: _buildBody(context),
bottomNavigationBar: _buildBottomBar(context), bottomNavigationBar: _buildBottomBar(context),
endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null, endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null,
floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context), floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context),
),
); );
} }
@@ -200,7 +290,13 @@ class _FilesPageState extends State<FilesPage> {
if (fileManager.currentPath == '/') { if (fileManager.currentPath == '/') {
return const Text('文件'); 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); return _buildMobileBreadcrumb(context, fileManager);
}, },
@@ -224,60 +320,30 @@ class _FilesPageState extends State<FilesPage> {
return decoded; return decoded;
} }
Widget _buildDesktopBreadcrumb(BuildContext context, FileManagerProvider fileManager) { Widget _buildMobileBreadcrumb(
BuildContext context,
FileManagerProvider fileManager,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
final pathParts = fileManager.currentPath.split('/'); final pathParts = fileManager.currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty); pathParts.removeWhere((part) => part.isEmpty);
return Row( // 路径变化后自动滚动到末尾
mainAxisSize: MainAxisSize.min, WidgetsBinding.instance.addPostFrameCallback((_) {
children: [ if (_breadcrumbController.hasClients) {
InkWell( _breadcrumbController.animateTo(
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null, _breadcrumbController.position.maxScrollExtent,
borderRadius: BorderRadius.circular(6), duration: const Duration(milliseconds: 200),
child: Padding( curve: Curves.easeOut,
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);
return SizedBox( return SizedBox(
height: 40, height: 40,
child: ListView( child: ListView(
key: const PageStorageKey('mobile_breadcrumb'), controller: _breadcrumbController,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
children: [ children: [
_buildBreadcrumbChip( _buildBreadcrumbChip(
@@ -285,12 +351,18 @@ class _FilesPageState extends State<FilesPage> {
label: '文件', label: '文件',
icon: LucideIcons.home, icon: LucideIcons.home,
color: colorScheme.primary, color: colorScheme.primary,
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null, onTap: () => fileManager.currentPath != '/'
? fileManager.enterFolder('/')
: null,
), ),
for (int i = 0; i < pathParts.length; i++) ...[ for (int i = 0; i < pathParts.length; i++) ...[
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), 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( _buildBreadcrumbChip(
context, context,
@@ -300,7 +372,9 @@ class _FilesPageState extends State<FilesPage> {
isLast: i == pathParts.length - 1, isLast: i == pathParts.length - 1,
onTap: () { onTap: () {
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}'; 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<FilesPage> {
height: 28, height: 28,
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
decoration: BoxDecoration( 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), borderRadius: BorderRadius.circular(14),
), ),
child: Row( child: Row(
@@ -358,12 +434,19 @@ class _FilesPageState extends State<FilesPage> {
Consumer<FileManagerProvider>( Consumer<FileManagerProvider>(
builder: (context, fileManager, child) { builder: (context, fileManager, child) {
return IconButton( return IconButton(
icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh), icon: Icon(
fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh,
),
onPressed: () => fileManager.refreshFiles(), onPressed: () => fileManager.refreshFiles(),
tooltip: '刷新', tooltip: '刷新',
); );
}, },
), ),
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return _buildSortMenu(fileManager);
},
),
Consumer<FileManagerProvider>( Consumer<FileManagerProvider>(
builder: (context, fileManager, child) { builder: (context, fileManager, child) {
final icon = fileManager.viewType == FileViewType.list final icon = fileManager.viewType == FileViewType.list
@@ -378,14 +461,19 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list, : FileViewType.list,
); );
}, },
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图', tooltip: fileManager.viewType == FileViewType.list
? '网格视图'
: '列表视图',
); );
}, },
), ),
IconButton( IconButton(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
onPressed: () { onPressed: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
FileOperationDialogs.showCreateDialog(context, fileManager); FileOperationDialogs.showCreateDialog(context, fileManager);
}, },
tooltip: '新建', tooltip: '新建',
@@ -397,7 +485,8 @@ class _FilesPageState extends State<FilesPage> {
), ),
IconButton( IconButton(
icon: const Icon(Icons.cloud_download), icon: const Icon(Icons.cloud_download),
onPressed: () => Provider.of<NavigationProvider>(context, listen: false).setIndex(2), onPressed: () =>
Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
tooltip: '下载', tooltip: '下载',
), ),
]; ];
@@ -405,6 +494,11 @@ class _FilesPageState extends State<FilesPage> {
List<Widget> _buildMobileActions() { List<Widget> _buildMobileActions() {
return [ return [
Consumer<FileManagerProvider>(
builder: (context, fileManager, child) {
return _buildSortMenu(fileManager);
},
),
Consumer<FileManagerProvider>( Consumer<FileManagerProvider>(
builder: (context, fileManager, child) { builder: (context, fileManager, child) {
final icon = fileManager.viewType == FileViewType.list final icon = fileManager.viewType == FileViewType.list
@@ -419,13 +513,52 @@ class _FilesPageState extends State<FilesPage> {
: FileViewType.list, : 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<SortOption>(
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<SortOption>(
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 ---- // ---- SpeedDial FAB ----
Widget _buildSpeedDialFAB(BuildContext context) { Widget _buildSpeedDialFAB(BuildContext context) {
@@ -471,8 +604,16 @@ class _FilesPageState extends State<FilesPage> {
isDark: isDark, isDark: isDark,
colorScheme: colorScheme, colorScheme: colorScheme,
onTap: () { onTap: () {
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); final fileManager = Provider.of<FileManagerProvider>(
_onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager)); context,
listen: false,
);
_onFabSubAction(
() => FileOperationDialogs.showCreateDialog(
context,
fileManager,
),
);
}, },
), ),
_buildFabSubItem( _buildFabSubItem(
@@ -482,7 +623,10 @@ class _FilesPageState extends State<FilesPage> {
label: '离线下载', label: '离线下载',
isDark: isDark, isDark: isDark,
colorScheme: colorScheme, colorScheme: colorScheme,
onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)), onTap: () => _onFabSubAction(
() =>
Navigator.of(context).pushNamed(RouteNames.remoteDownload),
),
), ),
Consumer<FileManagerProvider>( Consumer<FileManagerProvider>(
builder: (context, fileManager, _) { builder: (context, fileManager, _) {
@@ -568,7 +712,10 @@ class _FilesPageState extends State<FilesPage> {
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7), padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 7,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isDark color: isDark
? Colors.white.withValues(alpha: 0.12) ? Colors.white.withValues(alpha: 0.12)
@@ -649,7 +796,8 @@ class _FilesPageState extends State<FilesPage> {
final isDesktop = MediaQuery.of(context).size.width >= 1000; final isDesktop = MediaQuery.of(context).size.width >= 1000;
final child = _buildFileList(context); final child = _buildFileList(context);
if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) { if (!isDesktop ||
!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
return child; return child;
} }
@@ -673,13 +821,19 @@ class _FilesPageState extends State<FilesPage> {
strokeAlign: BorderSide.strokeAlignOutside, strokeAlign: BorderSide.strokeAlignOutside,
), ),
borderRadius: BorderRadius.circular(8), 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: Center(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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), const SizedBox(height: 12),
Text( Text(
'释放文件以上传到当前目录', '释放文件以上传到当前目录',
@@ -709,8 +863,14 @@ class _FilesPageState extends State<FilesPage> {
} }
if (files.isEmpty) return; if (files.isEmpty) return;
final uploadManager = Provider.of<UploadManagerProvider>(context, listen: false); final uploadManager = Provider.of<UploadManagerProvider>(
final fileManager = Provider.of<FileManagerProvider>(context, listen: false); context,
listen: false,
);
final fileManager = Provider.of<FileManagerProvider>(
context,
listen: false,
);
uploadManager.markShouldShowDialog(); uploadManager.markShouldShowDialog();
uploadManager.startUpload(files, fileManager.currentPath); uploadManager.startUpload(files, fileManager.currentPath);
ToastHelper.info('已添加 ${files.length} 个文件到上传队列'); ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
@@ -740,12 +900,19 @@ class _FilesPageState extends State<FilesPage> {
); );
} }
Widget _buildErrorView(BuildContext context, FileManagerProvider fileManager) { Widget _buildErrorView(
BuildContext context,
FileManagerProvider fileManager,
) {
return Center( return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ 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), const SizedBox(height: 16),
Text( Text(
fileManager.errorMessage!, fileManager.errorMessage!,
@@ -783,23 +950,38 @@ class _FilesPageState extends State<FilesPage> {
Widget _buildListView(BuildContext context, FileManagerProvider fileManager) { Widget _buildListView(BuildContext context, FileManagerProvider fileManager) {
final isDesktop = MediaQuery.of(context).size.width >= 1000; final isDesktop = MediaQuery.of(context).size.width >= 1000;
final showCheckbox = fileManager.hasSelection; final showCheckbox = fileManager.hasSelection;
final itemCount =
fileManager.files.length +
(fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0);
return Column( return Column(
children: [ children: [
if (isDesktop) FileListHeader(showCheckbox: showCheckbox), if (isDesktop)
FileListHeader(
showCheckbox: showCheckbox,
currentSort: fileManager.sortOption,
onSort: (option) => fileManager.setSortOption(option),
),
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => _onRefresh(fileManager), onRefresh: () => _onRefresh(fileManager),
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification, onNotification: _onScrollNotification,
child: ListView.builder( child: ListView.builder(
controller: _scrollController,
key: PageStorageKey('files_list_${fileManager.currentPath}'), key: PageStorageKey('files_list_${fileManager.currentPath}'),
cacheExtent: 900, cacheExtent: 900,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, keyboardDismissBehavior:
itemCount: fileManager.files.length, ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= fileManager.files.length) {
return _buildLoadMoreIndicator(context, fileManager);
}
final file = fileManager.files[index]; final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(file.path); final isSelected = fileManager.selectedFiles.contains(
file.path,
);
return FileListItem( return FileListItem(
key: ValueKey('file_${file.id}'), key: ValueKey('file_${file.id}'),
@@ -821,14 +1003,40 @@ class _FilesPageState extends State<FilesPage> {
} }
}, },
onSelect: () => fileManager.toggleSelection(file.path), onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null, onDownload: !file.isFolder
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null, ? () => _downloadFile(context, fileManager, file)
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null, : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file), onOpenInBrowser: !file.isFolder
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false), ? () => _openInBrowser(context, file)
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true), : null,
onShare: () => FileOperationDialogs.showShareDialog(context, file), onOpenInCloudreveApp: !file.isFolder
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file), ? () => _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), onInfo: () => _showFileInfo(file),
); );
}, },
@@ -857,15 +1065,20 @@ class _FilesPageState extends State<FilesPage> {
crossAxisCount = 5; crossAxisCount = 5;
} }
final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount; final itemWidth =
(availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
final childAspectRatio = itemWidth / 160; final childAspectRatio = itemWidth / 160;
final showCheckbox = fileManager.hasSelection; final showCheckbox = fileManager.hasSelection;
final itemCount =
fileManager.files.length +
(fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0);
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => _onRefresh(fileManager), onRefresh: () => _onRefresh(fileManager),
child: NotificationListener<ScrollNotification>( child: NotificationListener<ScrollNotification>(
onNotification: _onScrollNotification, onNotification: _onScrollNotification,
child: GridView.builder( child: GridView.builder(
controller: _scrollController,
key: PageStorageKey('files_grid_${fileManager.currentPath}'), key: PageStorageKey('files_grid_${fileManager.currentPath}'),
cacheExtent: 1100, cacheExtent: 1100,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
@@ -876,8 +1089,11 @@ class _FilesPageState extends State<FilesPage> {
crossAxisSpacing: spacing / 2, crossAxisSpacing: spacing / 2,
childAspectRatio: childAspectRatio, childAspectRatio: childAspectRatio,
), ),
itemCount: fileManager.files.length, itemCount: itemCount,
itemBuilder: (context, index) { itemBuilder: (context, index) {
if (index >= fileManager.files.length) {
return _buildGridLoadMoreIndicator(context, fileManager);
}
final file = fileManager.files[index]; final file = fileManager.files[index];
final isSelected = fileManager.selectedFiles.contains(file.path); final isSelected = fileManager.selectedFiles.contains(file.path);
@@ -900,14 +1116,39 @@ class _FilesPageState extends State<FilesPage> {
} }
}, },
onSelect: () => fileManager.toggleSelection(file.path), onSelect: () => fileManager.toggleSelection(file.path),
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null, onDownload: !file.isFolder
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null, ? () => _downloadFile(context, fileManager, file)
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null, : null,
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file), onOpenInBrowser: !file.isFolder
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false), ? () => _openInBrowser(context, file)
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true), : null,
onShare: () => FileOperationDialogs.showShareDialog(context, file), onOpenInCloudreveApp: !file.isFolder
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file), ? () => _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), onInfo: () => _showFileInfo(file),
); );
}, },
@@ -916,6 +1157,62 @@ class _FilesPageState extends State<FilesPage> {
); );
} }
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) { Widget _buildBottomBar(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1000; final isDesktop = screenWidth >= 1000;
@@ -976,11 +1273,17 @@ class _FilesPageState extends State<FilesPage> {
} else if (FileTypeUtils.isAudio(file.name)) { } else if (FileTypeUtils.isAudio(file.name)) {
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file); Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
} else if (FileTypeUtils.isMarkdown(file.name)) { } 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)) { } else if (FileTypeUtils.isTextCode(file.name)) {
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file); Navigator.of(
context,
).pushNamed(RouteNames.documentPreview, arguments: file);
} else { } else {
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}'); ToastHelper.info(
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
);
} }
} }
@@ -989,7 +1292,10 @@ class _FilesPageState extends State<FilesPage> {
FileManagerProvider fileManager, FileManagerProvider fileManager,
FileModel file, FileModel file,
) async { ) async {
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false); final downloadManager = Provider.of<DownloadManagerProvider>(
context,
listen: false,
);
final task = await downloadManager.addDownloadTask( final task = await downloadManager.addDownloadTask(
fileName: file.name, fileName: file.name,
fileUri: file.relativePath, fileUri: file.relativePath,
@@ -1037,11 +1343,8 @@ class _FilesPageState extends State<FilesPage> {
} }
void _openInCloudreveApp(BuildContext context, FileModel file) { void _openInCloudreveApp(BuildContext context, FileModel file) {
Navigator.of(context).pushNamed( Navigator.of(
RouteNames.cloudreveFileApp, context,
arguments: { ).pushNamed(RouteNames.cloudreveFileApp, arguments: {'file': file});
'file': file,
},
);
} }
} }
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
/// 音频预览页面 /// 音频预览页面
@@ -43,7 +44,11 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
setState(() { setState(() {
_isLoading = false; _isLoading = false;
}); });
player.open(Media(url), play: true); await CustomLineService.instance.configureMediaPlayerProxy(
player,
url,
);
await player.open(Media(url), play: true);
} }
} else { } else {
if (mounted) { if (mounted) {
@@ -104,11 +109,7 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon( const Icon(Icons.error_outline, size: 64, color: Colors.white54),
Icons.error_outline,
size: 64,
color: Colors.white54,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_errorMessage!, _errorMessage!,
@@ -140,14 +141,17 @@ class AudioPlayerWidget extends StatefulWidget {
final Player player; final Player player;
final String fileName; final String fileName;
const AudioPlayerWidget({super.key, required this.player, required this.fileName}); const AudioPlayerWidget({
super.key,
required this.player,
required this.fileName,
});
@override @override
State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState(); State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
} }
class _AudioPlayerWidgetState extends State<AudioPlayerWidget> { class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
@@ -155,10 +159,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [const Color(0xFF1A1A2E), const Color(0xFF16213E)],
const Color(0xFF1A1A2E),
const Color(0xFF16213E),
],
), ),
), ),
child: Padding( child: Padding(
@@ -246,12 +247,18 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
return SliderTheme( return SliderTheme(
data: SliderThemeData( data: SliderThemeData(
trackHeight: 4, trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8), thumbShape: const RoundSliderThumbShape(
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16), enabledThumbRadius: 8,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 16,
),
activeTrackColor: const Color(0xFFE94560), activeTrackColor: const Color(0xFFE94560),
inactiveTrackColor: Colors.white.withValues(alpha: 0.1), inactiveTrackColor: Colors.white.withValues(alpha: 0.1),
thumbColor: const Color(0xFFE94560), thumbColor: const Color(0xFFE94560),
overlayColor: const Color(0xFFE94560).withValues(alpha: 0.3), overlayColor: const Color(
0xFFE94560,
).withValues(alpha: 0.3),
), ),
child: Slider( child: Slider(
value: position.inMilliseconds.toDouble(), value: position.inMilliseconds.toDouble(),
@@ -259,9 +266,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
? duration.inMilliseconds.toDouble() ? duration.inMilliseconds.toDouble()
: 1.0, : 1.0,
onChanged: (value) { onChanged: (value) {
widget.player.seek( widget.player.seek(Duration(milliseconds: value.toInt()));
Duration(milliseconds: value.toInt()),
);
}, },
), ),
); );
@@ -325,8 +325,12 @@ class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
initialSettings: desktop.InAppWebViewSettings( initialSettings: desktop.InAppWebViewSettings(
javaScriptEnabled: true, javaScriptEnabled: true,
domStorageEnabled: true, domStorageEnabled: true,
isInspectable: true,
cacheMode: desktop.CacheMode.LOAD_DEFAULT,
supportMultipleWindows: true,
transparentBackground: true, transparentBackground: true,
supportZoom: false, supportZoom: true,
useHybridComposition: true,
), ),
onWebViewCreated: (controller) async { onWebViewCreated: (controller) async {
_desktopController = controller; _desktopController = controller;
@@ -8,6 +8,7 @@ import 'package:highlight/highlight.dart';
import 'package:highlight/languages/all.dart'; import 'package:highlight/languages/all.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
import '../../../core/utils/file_type_utils.dart'; import '../../../core/utils/file_type_utils.dart';
@@ -62,6 +63,7 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
if (urls.isEmpty) throw Exception('获取URL为空'); if (urls.isEmpty) throw Exception('获取URL为空');
final urlData = urls[0] as Map<String, dynamic>; final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String; final url = urlData['url'] as String;
await CustomLineService.instance.activateSelectedNodeForUrl(url);
final responseContent = await http.get(Uri.parse(url)); final responseContent = await http.get(Uri.parse(url));
if (responseContent.statusCode != 200) { if (responseContent.statusCode != 200) {
@@ -93,7 +95,8 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
_codeController = CodeController(text: _content, language: _languageMode); _codeController = CodeController(text: _content, language: _languageMode);
} }
int _countLines(String text) => text.isEmpty ? 0 : '\n'.allMatches(text).length + 1; int _countLines(String text) =>
text.isEmpty ? 0 : '\n'.allMatches(text).length + 1;
Mode _detectLanguageMode(String fileName) { Mode _detectLanguageMode(String fileName) {
final ext = FileTypeUtils.getExtension(fileName); final ext = FileTypeUtils.getExtension(fileName);
@@ -131,16 +134,21 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
appBar: AppBar( appBar: AppBar(
backgroundColor: _backgroundColor, backgroundColor: _backgroundColor,
elevation: 0, elevation: 0,
iconTheme: const IconThemeData( iconTheme: const IconThemeData(color: Colors.white),
color: Colors.white,
),
title: Column( title: Column(
// crossAxisAlignment: CrossAxisAlignment.start, // crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(widget.file.name, style: const TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center,), Text(
widget.file.name,
style: const TextStyle(color: Colors.white, fontSize: 15),
textAlign: TextAlign.center,
),
if (!_isLoading) if (!_isLoading)
Text('$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}', Text(
style: TextStyle(color: Colors.grey.shade400, fontSize: 12), textAlign: TextAlign.center,), '$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
textAlign: TextAlign.center,
),
], ],
), ),
actions: [ actions: [
@@ -199,11 +207,15 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯 textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯
margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行 margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行
textStyle: TextStyle( textStyle: TextStyle(
color: _showLineNumbers ? Colors.grey.shade600 : Colors.transparent, color: _showLineNumbers
? Colors.grey.shade600
: Colors.transparent,
fontSize: _fontSize * 0.8, fontSize: _fontSize * 0.8,
height: 1.5, // 必须和正文高度完全一致 height: 1.5, // 必须和正文高度完全一致
// 核心修复:通过强制单词不换行来防止数字断裂 // 核心修复:通过强制单词不换行来防止数字断裂
fontFeatures: const [FontFeature.tabularFigures()], // 使用等宽数字 fontFeatures: const [
FontFeature.tabularFigures(),
], // 使用等宽数字
), ),
), ),
), ),
@@ -225,7 +237,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
heroTag: 'font_up', heroTag: 'font_up',
mini: true, mini: true,
backgroundColor: Colors.grey.shade800, backgroundColor: Colors.grey.shade800,
onPressed: () => setState(() { if(_fontSize < 30) _fontSize++; }), onPressed: () => setState(() {
if (_fontSize < 30) _fontSize++;
}),
child: const Icon(Icons.add, color: Colors.white, size: 20), child: const Icon(Icons.add, color: Colors.white, size: 20),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -234,7 +248,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
heroTag: 'font_down', heroTag: 'font_down',
mini: true, mini: true,
backgroundColor: Colors.grey.shade800, backgroundColor: Colors.grey.shade800,
onPressed: () => setState(() { if(_fontSize > 8) _fontSize--; }), onPressed: () => setState(() {
if (_fontSize > 8) _fontSize--;
}),
child: const Icon(Icons.remove, color: Colors.white, size: 20), child: const Icon(Icons.remove, color: Colors.white, size: 20),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -242,7 +258,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
FloatingActionButton( FloatingActionButton(
heroTag: 'line_toggle', heroTag: 'line_toggle',
mini: true, mini: true,
backgroundColor: _showLineNumbers ? Colors.blue : Colors.grey.shade800, backgroundColor: _showLineNumbers
? Colors.blue
: Colors.grey.shade800,
onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers), onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers),
child: Icon( child: Icon(
_showLineNumbers ? Icons.format_list_numbered : Icons.short_text, _showLineNumbers ? Icons.format_list_numbered : Icons.short_text,
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:photo_view/photo_view.dart'; import 'package:photo_view/photo_view.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
import '../../../services/cache_manager_service.dart'; import '../../../services/cache_manager_service.dart';
import '../../widgets/toast_helper.dart'; import '../../widgets/toast_helper.dart';
@@ -44,6 +45,7 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
if (urls.isNotEmpty) { if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>; final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String; final url = urlData['url'] as String;
await CustomLineService.instance.activateSelectedNodeForUrl(url);
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -11,6 +11,7 @@ import 'package:markdown_widget/widget/blocks/leaf/link.dart';
import 'package:markdown_widget/widget/markdown.dart'; import 'package:markdown_widget/widget/markdown.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
class MarkdownPreviewPage extends StatefulWidget { class MarkdownPreviewPage extends StatefulWidget {
@@ -62,6 +63,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
final urlData = urls[0] as Map<String, dynamic>; final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String; final url = urlData['url'] as String;
await CustomLineService.instance.activateSelectedNodeForUrl(url);
final responseContent = await http.get(Uri.parse(url)); final responseContent = await http.get(Uri.parse(url));
if (responseContent.statusCode != 200) { if (responseContent.statusCode != 200) {
@@ -104,7 +106,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
backgroundColor: bgColor, backgroundColor: bgColor,
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
icon: Icon(Icons.arrow_back, color: isDark ? Colors.white : Colors.black87), icon: Icon(
Icons.arrow_back,
color: isDark ? Colors.white : Colors.black87,
),
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
), ),
title: Column( title: Column(
@@ -120,13 +125,19 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
if (!_isLoading && _error == null) if (!_isLoading && _error == null)
Text('Markdown 预览', style: TextStyle(color: Colors.grey, fontSize: 12)), Text(
'Markdown 预览',
style: TextStyle(color: Colors.grey, fontSize: 12),
),
], ],
), ),
actions: [ actions: [
if (!_isLoading && _error == null) if (!_isLoading && _error == null)
IconButton( IconButton(
icon: Icon(Icons.copy, color: isDark ? Colors.white : Colors.black87), icon: Icon(
Icons.copy,
color: isDark ? Colors.white : Colors.black87,
),
onPressed: () => Clipboard.setData(ClipboardData(text: _content)), onPressed: () => Clipboard.setData(ClipboardData(text: _content)),
), ),
], ],
@@ -159,10 +170,14 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border( border: Border(
right: BorderSide( right: BorderSide(
color: _isDarkMode ? Colors.white10 : Colors.grey.withValues(alpha: 0.2) color: _isDarkMode
? Colors.white10
: Colors.grey.withValues(alpha: 0.2),
), ),
), ),
color: _isDarkMode ? const Color(0xFF252525) : Colors.grey.shade50, color: _isDarkMode
? const Color(0xFF252525)
: Colors.grey.shade50,
), ),
child: ClipRect( child: ClipRect(
child: Column( child: Column(
@@ -178,7 +193,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
), ),
), ),
), ),
Divider(height: 1, color: _isDarkMode ? Colors.white10 : Colors.grey.shade300), Divider(
height: 1,
color: _isDarkMode ? Colors.white10 : Colors.grey.shade300,
),
Expanded( Expanded(
child: GestureDetector( child: GestureDetector(
onTap: () { onTap: () {
@@ -198,9 +216,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
bodySmall: TextStyle(color: subTextColor), bodySmall: TextStyle(color: subTextColor),
), ),
), ),
child: TocWidget( child: TocWidget(controller: _tocController),
controller: _tocController,
),
), ),
), ),
), ),
@@ -228,8 +244,11 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
} }
Widget _buildMarkdownWidget(String content) { Widget _buildMarkdownWidget(String content) {
final config = _isDarkMode ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig; final config = _isDarkMode
CodeWrapperWidget codeWrapper(child, text, language) => CodeWrapperWidget(child, text, language); ? MarkdownConfig.darkConfig
: MarkdownConfig.defaultConfig;
CodeWrapperWidget codeWrapper(child, text, language) =>
CodeWrapperWidget(child, text, language);
return MarkdownWidget( return MarkdownWidget(
data: content, data: content,
@@ -237,7 +256,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
config: config.copy( config: config.copy(
configs: [ configs: [
_isDarkMode _isDarkMode
? PreConfig.darkConfig.copy(theme: a11yLightTheme, wrapper: codeWrapper) ? PreConfig.darkConfig.copy(
theme: a11yLightTheme,
wrapper: codeWrapper,
)
: PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper), : PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper),
LinkConfig( LinkConfig(
style: TextStyle( style: TextStyle(
@@ -266,12 +288,14 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
); );
} }
Widget _buildFAB(bool isDark) { Widget _buildFAB(bool isDark) {
if (_isLoading || _error != null) return const SizedBox.shrink(); if (_isLoading || _error != null) return const SizedBox.shrink();
// 定义统一的按钮背景颜色,增加视觉一致性 // 定义统一的按钮背景颜色,增加视觉一致性
final Color activeColor = isDark ? Colors.blue.shade700 : Colors.blue; final Color activeColor = isDark ? Colors.blue.shade700 : Colors.blue;
final Color inactiveColor = isDark ? Colors.grey.shade800 : Colors.grey.shade200; final Color inactiveColor = isDark
? Colors.grey.shade800
: Colors.grey.shade200;
final Color iconColor = isDark ? Colors.white : Colors.black87; final Color iconColor = isDark ? Colors.white : Colors.black87;
return Column( return Column(
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:pdfrx/pdfrx.dart'; import 'package:pdfrx/pdfrx.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
/// PDF预览页面 /// PDF预览页面
@@ -37,6 +38,7 @@ class _PdfPreviewPageState extends State<PdfPreviewPage> {
if (urls.isNotEmpty) { if (urls.isNotEmpty) {
final urlData = urls[0] as Map<String, dynamic>; final urlData = urls[0] as Map<String, dynamic>;
final url = urlData['url'] as String; final url = urlData['url'] as String;
await CustomLineService.instance.activateSelectedNodeForUrl(url);
if (mounted) { if (mounted) {
setState(() { setState(() {
@@ -114,9 +116,12 @@ class _PdfPreviewPageState extends State<PdfPreviewPage> {
initialPageNumber: 1, initialPageNumber: 1,
params: const PdfViewerParams( params: const PdfViewerParams(
activeMatchTextColor: Colors.yellow, activeMatchTextColor: Colors.yellow,
annotationRenderingMode: PdfAnnotationRenderingMode.annotationAndForms, annotationRenderingMode:
PdfAnnotationRenderingMode.annotationAndForms,
sizeDelegateProvider: PdfViewerSizeDelegateProviderLegacy(
maxScale: 4.0, maxScale: 4.0,
minScale: 0.8, // Allow 300% zoom minScale: 0.8,
),
scaleEnabled: true, scaleEnabled: true,
textSelectionParams: PdfTextSelectionParams( textSelectionParams: PdfTextSelectionParams(
enabled: true, enabled: true,
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:media_kit/media_kit.dart'; import 'package:media_kit/media_kit.dart';
import 'package:media_kit_video/media_kit_video.dart'; import 'package:media_kit_video/media_kit_video.dart';
import '../../../data/models/file_model.dart'; import '../../../data/models/file_model.dart';
import '../../../services/custom_line_service.dart';
import '../../../services/file_service.dart'; import '../../../services/file_service.dart';
import 'widgets/video_controls_overlay.dart'; import 'widgets/video_controls_overlay.dart';
@@ -45,7 +46,11 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
if (mounted) { if (mounted) {
setState(() => _isLoading = false); setState(() => _isLoading = false);
player.open(Media(url), play: true); await CustomLineService.instance.configureMediaPlayerProxy(
player,
url,
);
await player.open(Media(url), play: true);
} }
} else { } else {
if (mounted) { if (mounted) {
@@ -82,7 +87,11 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const Icon(Icons.error_outline, size: 64, color: Colors.white), const Icon(
Icons.error_outline,
size: 64,
color: Colors.white,
),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_errorMessage!, _errorMessage!,
@@ -106,7 +115,8 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
: ExcludeSemantics( : ExcludeSemantics(
child: Video( child: Video(
controller: controller, controller: controller,
controls: (state) => VideoControlsOverlay(state: state, title: widget.file.name), controls: (state) =>
VideoControlsOverlay(state: state, title: widget.file.name),
), ),
), ),
); );
@@ -23,26 +23,48 @@ class QuickFunctionsSection extends StatelessWidget {
const QuickFunctionsSection({super.key}); const QuickFunctionsSection({super.key});
static final _functions = [ static final _functions = [
_QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share), _QuickFunction(
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav), icon: LucideIcons.share2,
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload), label: '我的分享',
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin), 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( _QuickFunction(
icon: LucideIcons.refreshCw, icon: LucideIcons.refreshCw,
label: '文件同步', label: '文件同步',
onTap: (ctx) { onTap: (ctx) {
final nav = ctx.read<NavigationProvider>(); final nav = ctx.read<NavigationProvider>();
// 桌面端有同步 Tabindex 4,直接切换;移动端跳转同步设置 // 桌面端或 Android 平板(宽屏)有同步 Tab,直接切换;手机端跳转同步详情
final isDesktop = defaultTargetPlatform != TargetPlatform.android && final isDesktop =
defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS; defaultTargetPlatform != TargetPlatform.iOS;
if (isDesktop) { final isWideScreen = MediaQuery.of(ctx).size.width >= 800;
nav.setIndex(4); if (isDesktop || isWideScreen) {
nav.setIndex(3);
} else { } 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; static const double _spacing = 12;
@@ -63,9 +85,12 @@ class QuickFunctionsSection extends StatelessWidget {
children: [ children: [
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary), Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
Text('快捷功能', Text(
style: theme.textTheme.titleMedium '快捷功能',
?.copyWith(fontWeight: FontWeight.w600)), style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
),
),
], ],
), ),
), ),
@@ -79,7 +104,8 @@ class QuickFunctionsSection extends StatelessWidget {
if (itemWidth < _minItemWidth) break; if (itemWidth < _minItemWidth) break;
perRow = next; perRow = next;
} }
final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow; final itemWidth =
(availableWidth - _spacing * (perRow - 1)) / perRow;
return Wrap( return Wrap(
spacing: _spacing, spacing: _spacing,
@@ -132,9 +158,7 @@ class _QuickFunctionCardState extends State<_QuickFunctionCard> {
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
return Card( return Card(
color: _hovered color: _hovered ? colorScheme.surfaceContainerHighest : null,
? colorScheme.surfaceContainerHighest
: null,
child: InkWell( child: InkWell(
onTap: widget.onTap, onTap: widget.onTap,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
File diff suppressed because it is too large Load Diff
@@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../../data/models/user_model.dart'; import '../../../data/models/user_model.dart';
import '../../../data/models/user_setting_model.dart'; import '../../../data/models/user_setting_model.dart';
import '../../../services/update_service.dart';
import '../../../services/user_setting_service.dart'; import '../../../services/user_setting_service.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/user_setting_provider.dart'; import '../../providers/user_setting_provider.dart';
@@ -16,6 +18,7 @@ import 'file_preferences_page.dart';
import 'app_settings_page.dart'; import 'app_settings_page.dart';
import 'credit_history_page.dart'; import 'credit_history_page.dart';
import 'quick_access_settings_page.dart'; import 'quick_access_settings_page.dart';
import 'network_test_page.dart';
import '../../../router/app_router.dart'; import '../../../router/app_router.dart';
/// 设置主页 /// 设置主页
@@ -99,7 +102,8 @@ class _SettingsPageState extends State<SettingsPage> {
icon: Icons.security_outlined, icon: Icons.security_outlined,
title: '安全设置', title: '安全设置',
subtitle: _securitySubtitle(settings), subtitle: _securitySubtitle(settings),
onTap: () => _navigateTo(context, const SecuritySettingsPage()), onTap: () =>
_navigateTo(context, const SecuritySettingsPage()),
), ),
], ],
), ),
@@ -110,19 +114,22 @@ class _SettingsPageState extends State<SettingsPage> {
icon: Icons.sync_outlined, icon: Icons.sync_outlined,
title: '文件同步', title: '文件同步',
subtitle: '本地与云端文件自动同步', subtitle: '本地与云端文件自动同步',
onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings), onTap: () =>
Navigator.of(context).pushNamed(RouteNames.syncSettings),
), ),
_SettingsTile( _SettingsTile(
icon: Icons.apps_outlined, icon: Icons.apps_outlined,
title: '快捷入口', title: '快捷入口',
subtitle: '自定义概览页快捷目录', subtitle: '自定义概览页快捷目录',
onTap: () => _navigateTo(context, const QuickAccessSettingsPage()), onTap: () =>
_navigateTo(context, const QuickAccessSettingsPage()),
), ),
_SettingsTile( _SettingsTile(
icon: Icons.folder_outlined, icon: Icons.folder_outlined,
title: '文件偏好', title: '文件偏好',
subtitle: '版本保留、视图同步、分享可见性', subtitle: '版本保留、视图同步、分享可见性',
onTap: () => _navigateTo(context, const FilePreferencesPage()), onTap: () =>
_navigateTo(context, const FilePreferencesPage()),
), ),
_SettingsTile( _SettingsTile(
icon: Icons.tune, icon: Icons.tune,
@@ -130,6 +137,12 @@ class _SettingsPageState extends State<SettingsPage> {
subtitle: '缓存、主题、语言', subtitle: '缓存、主题、语言',
onTap: () => _navigateTo(context, const AppSettingsPage()), onTap: () => _navigateTo(context, const AppSettingsPage()),
), ),
_SettingsTile(
icon: Icons.network_check,
title: '网络测试',
subtitle: '服务连通性测试与自定义线路(会员)',
onTap: () => _openNetworkTest(context),
),
], ],
), ),
// 财务区域(有数据时才显示) // 财务区域(有数据时才显示)
@@ -142,11 +155,7 @@ class _SettingsPageState extends State<SettingsPage> {
title: const Text('应用名称'), title: const Text('应用名称'),
subtitle: const Text('公云存储'), subtitle: const Text('公云存储'),
), ),
ListTile( _buildVersionTile(),
leading: const Icon(Icons.tag),
title: const Text('版本号'),
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
),
ListTile( ListTile(
leading: const Icon(Icons.copyright), leading: const Icon(Icons.copyright),
title: const Text('License'), title: const Text('License'),
@@ -158,9 +167,8 @@ class _SettingsPageState extends State<SettingsPage> {
subtitle: const Text('gongyun_app'), subtitle: const Text('gongyun_app'),
trailing: const Icon(Icons.open_in_new, size: 16), trailing: const Icon(Icons.open_in_new, size: 16),
onTap: () { onTap: () {
launchUrl( _openExternalUrl(
Uri.parse('https://git.saont.net/gongyun/app'), Uri.parse('https://git.saont.net/gongyun/app'),
mode: LaunchMode.externalApplication,
); );
}, },
), ),
@@ -170,9 +178,10 @@ class _SettingsPageState extends State<SettingsPage> {
subtitle: const Text('cloudreve4_flutter'), subtitle: const Text('cloudreve4_flutter'),
trailing: const Icon(Icons.open_in_new, size: 16), trailing: const Icon(Icons.open_in_new, size: 16),
onTap: () { onTap: () {
launchUrl( _openExternalUrl(
Uri.parse('https://github.com/LimoYuan/cloudreve4_flutter'), Uri.parse(
mode: LaunchMode.externalApplication, 'https://github.com/LimoYuan/cloudreve4_flutter',
),
); );
}, },
), ),
@@ -188,7 +197,10 @@ class _SettingsPageState extends State<SettingsPage> {
} }
/// 财务区域(存储包、积分、会员) /// 财务区域(存储包、积分、会员)
List<Widget> _buildProSections(BuildContext context, UserSettingModel settings) { List<Widget> _buildProSections(
BuildContext context,
UserSettingModel settings,
) {
final sections = <Widget>[]; final sections = <Widget>[];
final hasStoragePacks = settings.storagePacks.isNotEmpty; final hasStoragePacks = settings.storagePacks.isNotEmpty;
final hasCredit = settings.credit > 0; final hasCredit = settings.credit > 0;
@@ -197,7 +209,8 @@ class _SettingsPageState extends State<SettingsPage> {
if (hasStoragePacks || hasCredit || hasMembership) { if (hasStoragePacks || hasCredit || hasMembership) {
final children = <Widget>[]; final children = <Widget>[];
if (hasMembership) { if (hasMembership) {
children.add(ListTile( children.add(
ListTile(
leading: const Icon(Icons.workspace_premium_outlined), leading: const Icon(Icons.workspace_premium_outlined),
title: const Text('会员'), title: const Text('会员'),
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'), subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
@@ -205,32 +218,45 @@ class _SettingsPageState extends State<SettingsPage> {
onPressed: () => _cancelMembership(context), onPressed: () => _cancelMembership(context),
child: const Text('取消会员', style: TextStyle(color: Colors.red)), child: const Text('取消会员', style: TextStyle(color: Colors.red)),
), ),
)); ),
);
} }
if (hasStoragePacks) { if (hasStoragePacks) {
children.add(ListTile( children.add(
ListTile(
leading: const Icon(Icons.inventory_2_outlined), leading: const Icon(Icons.inventory_2_outlined),
title: Text('存储包 (${settings.storagePacks.length})'), title: Text('存储包 (${settings.storagePacks.length})'),
subtitle: Text(_storagePackSummary(settings.storagePacks)), subtitle: Text(_storagePackSummary(settings.storagePacks)),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showStoragePacks(context, settings.storagePacks), onTap: () => _showStoragePacks(context, settings.storagePacks),
)); ),
);
} }
if (hasCredit) { if (hasCredit) {
children.add(ListTile( children.add(
ListTile(
leading: const Icon(Icons.account_balance_wallet_outlined), leading: const Icon(Icons.account_balance_wallet_outlined),
title: const Text('积分'), title: const Text('积分'),
subtitle: Text('${settings.credit} 积分'), subtitle: Text('${settings.credit} 积分'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _navigateTo(context, CreditHistoryPage(currentCredit: settings.credit)), onTap: () => _navigateTo(
)); context,
CreditHistoryPage(currentCredit: settings.credit),
),
),
);
} }
sections.add(_buildSection(title: '财务', children: children)); sections.add(_buildSection(title: '财务', children: children));
} }
return sections; return sections;
} }
Widget _buildProfileCard(BuildContext context, UserModel? user, UserSettingModel? settings, UserCapacityModel? capacity) { Widget _buildProfileCard(
BuildContext context,
UserModel? user,
UserSettingModel? settings,
UserCapacityModel? capacity,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
@@ -253,17 +279,24 @@ class _SettingsPageState extends State<SettingsPage> {
children: [ children: [
Text( Text(
user?.nickname ?? '未登录', user?.nickname ?? '未登录',
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
user?.email ?? '', user?.email ?? '',
style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant), style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
), ),
if (user?.group != null) ...[ if (user?.group != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: colorScheme.primaryContainer, color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
@@ -316,9 +349,12 @@ class _SettingsPageState extends State<SettingsPage> {
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text('存储空间', style: theme.textTheme.bodySmall), Text('存储空间', style: theme.textTheme.bodySmall),
Text('$usedText / $totalText', style: theme.textTheme.bodySmall?.copyWith( Text(
'$usedText / $totalText',
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
)), ),
),
], ],
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -334,7 +370,10 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
Widget _buildSection({required String title, required List<Widget> children}) { Widget _buildSection({
required String title,
required List<Widget> children,
}) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.symmetric(vertical: 4),
child: Column( child: Column(
@@ -360,17 +399,43 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
Widget _buildVersionTile() {
return AnimatedBuilder(
animation: UpdateService.instance,
builder: (context, _) {
final hasUpdate = UpdateService.instance.hasUpdate;
return ListTile(
leading: Icon(
hasUpdate ? Icons.new_releases_outlined : Icons.tag,
color: hasUpdate ? Theme.of(context).colorScheme.primary : null,
),
title: const Text('版本号'),
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
onTap: hasUpdate ? () => _openDownloadsPage() : null,
trailing: hasUpdate ? const _UpdateAvailableBadge() : null,
);
},
);
}
Widget _buildLogoutButton(BuildContext context, AuthProvider auth) { Widget _buildLogoutButton(BuildContext context, AuthProvider auth) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: OutlinedButton.icon( child: OutlinedButton.icon(
onPressed: () => _confirmLogout(context, auth), onPressed: () => _confirmLogout(context, auth),
icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error), icon: Icon(Icons.logout, color: Theme.of(context).colorScheme.error),
label: Text('退出登录', style: TextStyle(color: Theme.of(context).colorScheme.error)), label: Text(
'退出登录',
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size(double.infinity, 48), minimumSize: const Size(double.infinity, 48),
side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3)), side: BorderSide(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
), ),
), ),
); );
@@ -407,7 +472,8 @@ class _SettingsPageState extends State<SettingsPage> {
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium), child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium),
), ),
...packs.map((pack) => Card( ...packs.map(
(pack) => Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
child: Column( child: Column(
@@ -416,8 +482,14 @@ class _SettingsPageState extends State<SettingsPage> {
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text(pack.name, style: Theme.of(ctx).textTheme.titleSmall), Text(
Text(_formatBytes(pack.size), style: Theme.of(ctx).textTheme.labelLarge), pack.name,
style: Theme.of(ctx).textTheme.titleSmall,
),
Text(
_formatBytes(pack.size),
style: Theme.of(ctx).textTheme.labelLarge,
),
], ],
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -428,12 +500,19 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
if (pack.isExpired) ...[ if (pack.isExpired) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text('已过期', style: TextStyle(color: Theme.of(ctx).colorScheme.error, fontSize: 12)), Text(
'已过期',
style: TextStyle(
color: Theme.of(ctx).colorScheme.error,
fontSize: 12,
),
),
], ],
], ],
), ),
), ),
)), ),
),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
), ),
@@ -449,10 +528,15 @@ class _SettingsPageState extends State<SettingsPage> {
title: const Text('取消会员'), title: const Text('取消会员'),
content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'), content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.of(ctx).pop(true), onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(ctx).colorScheme.error), style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('确认取消'), child: const Text('确认取消'),
), ),
], ],
@@ -479,6 +563,59 @@ class _SettingsPageState extends State<SettingsPage> {
} }
} }
Future<void> _openExternalUrl(Uri uri) async {
try {
final opened = await launchUrl(uri, mode: LaunchMode.externalApplication);
if (!opened) {
ToastHelper.failure('无法打开链接: $uri');
}
} catch (e) {
ToastHelper.failure('无法打开链接: $e');
}
}
Future<void> _openDownloadsPage() async {
try {
await UpdateService.instance.openDownloadsPage();
} catch (e) {
ToastHelper.failure('打开下载页面失败: $e');
}
}
Future<void> _openNetworkTest(BuildContext context) async {
final agreed = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (dialogContext) => AlertDialog(
title: const Text('会话信息授权'),
content: _NetworkTestConsentText(
onOpenPrivacy: () => _openExternalUrl(
Uri.parse('https://www.gongyun.org/policy/privacy.html'),
),
onOpenTerms: () => _openExternalUrl(
Uri.parse('https://www.gongyun.org/policy/terms.html'),
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('不同意'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('同意'),
),
],
),
);
if (agreed == true && context.mounted) {
await Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const NetworkTestPage()));
}
}
Future<void> _confirmLogout(BuildContext context, AuthProvider auth) async { Future<void> _confirmLogout(BuildContext context, AuthProvider auth) async {
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
@@ -486,10 +623,15 @@ class _SettingsPageState extends State<SettingsPage> {
title: const Text('退出登录'), title: const Text('退出登录'),
content: const Text('确定要退出当前账号吗?'), content: const Text('确定要退出当前账号吗?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')), TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('取消'),
),
FilledButton( FilledButton(
onPressed: () => Navigator.of(ctx).pop(true), onPressed: () => Navigator.of(ctx).pop(true),
style: FilledButton.styleFrom(backgroundColor: Theme.of(context).colorScheme.error), style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: const Text('退出'), child: const Text('退出'),
), ),
], ],
@@ -506,7 +648,9 @@ class _SettingsPageState extends State<SettingsPage> {
String _formatBytes(int bytes) { String _formatBytes(int bytes) {
if (bytes < 1024) return '$bytes B'; if (bytes < 1024) return '$bytes B';
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
}
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
} }
@@ -515,6 +659,140 @@ class _SettingsPageState extends State<SettingsPage> {
} }
} }
class _UpdateAvailableBadge extends StatefulWidget {
const _UpdateAvailableBadge();
@override
State<_UpdateAvailableBadge> createState() => _UpdateAvailableBadgeState();
}
class _UpdateAvailableBadgeState extends State<_UpdateAvailableBadge>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _opacity;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
_opacity = Tween<double>(
begin: 0.3,
end: 1,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return FadeTransition(
opacity: _opacity,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: colorScheme.error,
shape: BoxShape.circle,
),
),
const SizedBox(width: 6),
Text(
'发现新版本',
style: TextStyle(
color: colorScheme.error,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 4),
Icon(Icons.chevron_right, size: 18, color: colorScheme.error),
],
),
);
}
}
class _NetworkTestConsentText extends StatefulWidget {
final VoidCallback onOpenPrivacy;
final VoidCallback onOpenTerms;
const _NetworkTestConsentText({
required this.onOpenPrivacy,
required this.onOpenTerms,
});
@override
State<_NetworkTestConsentText> createState() =>
_NetworkTestConsentTextState();
}
class _NetworkTestConsentTextState extends State<_NetworkTestConsentText> {
late final TapGestureRecognizer _privacyRecognizer;
late final TapGestureRecognizer _termsRecognizer;
@override
void initState() {
super.initState();
_privacyRecognizer = TapGestureRecognizer()..onTap = widget.onOpenPrivacy;
_termsRecognizer = TapGestureRecognizer()..onTap = widget.onOpenTerms;
}
@override
void didUpdateWidget(covariant _NetworkTestConsentText oldWidget) {
super.didUpdateWidget(oldWidget);
_privacyRecognizer.onTap = widget.onOpenPrivacy;
_termsRecognizer.onTap = widget.onOpenTerms;
}
@override
void dispose() {
_privacyRecognizer.dispose();
_termsRecognizer.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final linkStyle = TextStyle(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
);
return Text.rich(
TextSpan(
children: [
const TextSpan(text: '您需同意'),
TextSpan(
text: '隐私政策',
style: linkStyle,
recognizer: _privacyRecognizer,
),
const TextSpan(text: ''),
TextSpan(
text: '用户协议',
style: linkStyle,
recognizer: _termsRecognizer,
),
const TextSpan(
text:
'方可进入网络测试页。测试会访问公云存储服务,并通过第三方服务 ipwho.is 查询当前网络 IP、AS 与运营商信息;如 ipwho.is 不可用,将使用 ipapi.co 作为备用查询。',
),
],
),
);
}
}
class _SettingsTile extends StatelessWidget { class _SettingsTile extends StatelessWidget {
final IconData icon; final IconData icon;
final String title; final String title;
@@ -1442,8 +1442,12 @@ class _OfficialDownloadDesktopWebViewState
domStorageEnabled: true, domStorageEnabled: true,
supportZoom: false, supportZoom: false,
transparentBackground: true, transparentBackground: true,
isInspectable: true,
cacheMode: desktop.CacheMode.LOAD_DEFAULT,
supportMultipleWindows: false,
useShouldOverrideUrlLoading: true, useShouldOverrideUrlLoading: true,
useOnDownloadStart: true, useOnDownloadStart: true,
useHybridComposition: true,
), ),
onWebViewCreated: (controller) { onWebViewCreated: (controller) {
_controller = controller; _controller = controller;
@@ -1457,6 +1461,18 @@ class _OfficialDownloadDesktopWebViewState
); );
}, },
onLoadStop: (_, url) => _installHook(), onLoadStop: (_, url) => _installHook(),
shouldInterceptRequest: (_, request) async {
final url = request.url.toString();
if (widget.onNavigationUrl(url)) {
return null;
}
return null;
},
onLoadStart: (_, url) {
if (url != null) {
widget.onNavigationUrl(url.toString());
}
},
shouldOverrideUrlLoading: (_, action) { shouldOverrideUrlLoading: (_, action) {
final url = action.request.url?.toString(); final url = action.request.url?.toString();
if (url != null && widget.onNavigationUrl(url)) { if (url != null && widget.onNavigationUrl(url)) {
+78 -14
View File
@@ -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/auth_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/download_manager_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/file_manager_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/navigation_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/sync_provider.dart';
import 'package:cloudreve4_flutter/presentation/providers/upload_manager_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/announcement_dialog.dart';
import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart'; import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart';
import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart'; import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart';
@@ -57,12 +59,18 @@ class _AppShellState extends State<AppShell>
final Set<int> _visitedPageIndexes = <int>{0}; final Set<int> _visitedPageIndexes = <int>{0};
late AnimationController _syncSpinController; late AnimationController _syncSpinController;
String? _lastClipboardShareId; String? _lastClipboardShareId;
String? _lastUserId;
bool _cachedShowSyncTab = false;
bool get _showSyncTab => static bool _shouldShowSyncTab(double screenWidth) {
defaultTargetPlatform != TargetPlatform.android && if (defaultTargetPlatform != TargetPlatform.android &&
defaultTargetPlatform != TargetPlatform.iOS; defaultTargetPlatform != TargetPlatform.iOS) {
return true;
}
return screenWidth >= 800;
}
List<Widget> get _pages => _showSyncTab List<Widget> _pages(bool showSyncTab) => showSyncTab
? [ ? [
const OverviewPage(), const OverviewPage(),
const FilesPage(), const FilesPage(),
@@ -78,7 +86,7 @@ class _AppShellState extends State<AppShell>
]; ];
int _clampedIndex(int index) { int _clampedIndex(int index) {
final maxIndex = _pages.length - 1; final maxIndex = _pages(_cachedShowSyncTab).length - 1;
if (index < 0) return 0; if (index < 0) return 0;
if (index > maxIndex) return maxIndex; if (index > maxIndex) return maxIndex;
return index; return index;
@@ -95,6 +103,7 @@ class _AppShellState extends State<AppShell>
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
_showPostLoginAnnouncement(); _showPostLoginAnnouncement();
_checkClipboardShareLink(); _checkClipboardShareLink();
_lastUserId = context.read<AuthProvider>().user?.id;
}); });
} }
@@ -112,6 +121,56 @@ class _AppShellState extends State<AppShell>
} }
} }
void _handleTabSelected(int index) {
final nav = Provider.of<NavigationProvider>(context, listen: false);
nav.setIndex(index);
final userSetting = Provider.of<UserSettingProvider>(
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<FileManagerProvider>(
context,
listen: false,
);
final userSetting = Provider.of<UserSettingProvider>(
context,
listen: false,
);
final admin = Provider.of<AdminProvider>(context, listen: false);
final sync = Provider.of<SyncProvider>(context, listen: false);
fileManager.clearFiles();
userSetting.clear();
admin.clear();
if (sync.engineInitialized) {
sync.resetSync();
}
final nav = Provider.of<NavigationProvider>(context, listen: false);
nav.setIndex(0);
userSetting.loadCapacity();
}
Future<void> _showPostLoginAnnouncement() async { Future<void> _showPostLoginAnnouncement() async {
final authProvider = context.read<AuthProvider>(); final authProvider = context.read<AuthProvider>();
if (!authProvider.isAuthenticated) return; if (!authProvider.isAuthenticated) return;
@@ -193,6 +252,7 @@ class _AppShellState extends State<AppShell>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width; final screenWidth = MediaQuery.of(context).size.width;
final isDesktop = screenWidth >= 1000; final isDesktop = screenWidth >= 1000;
_cachedShowSyncTab = _shouldShowSyncTab(screenWidth);
return PopScope( return PopScope(
canPop: false, canPop: false,
@@ -217,8 +277,9 @@ class _AppShellState extends State<AppShell>
} }
} }
}, },
child: Consumer<NavigationProvider>( child: Consumer2<AuthProvider, NavigationProvider>(
builder: (context, navProvider, _) { builder: (context, auth, navProvider, _) {
_checkUserChange(auth);
if (isDesktop) { if (isDesktop) {
return _buildDesktopLayout(context, navProvider); return _buildDesktopLayout(context, navProvider);
} }
@@ -229,7 +290,7 @@ class _AppShellState extends State<AppShell>
} }
Widget _buildPageContent(BuildContext context, int currentIndex) { Widget _buildPageContent(BuildContext context, int currentIndex) {
final pages = _pages; final pages = _pages(_cachedShowSyncTab);
final visibleIndex = _clampedIndex(currentIndex); final visibleIndex = _clampedIndex(currentIndex);
_visitedPageIndexes.add(visibleIndex); _visitedPageIndexes.add(visibleIndex);
@@ -297,7 +358,7 @@ class _AppShellState extends State<AppShell>
return NavigationBar( return NavigationBar(
height: 64, height: 64,
selectedIndex: _clampedIndex(navProvider.currentIndex), selectedIndex: _clampedIndex(navProvider.currentIndex),
onDestinationSelected: (i) => navProvider.setIndex(i), onDestinationSelected: _handleTabSelected,
destinations: [ destinations: [
const NavigationDestination( const NavigationDestination(
icon: Icon(LucideIcons.layoutDashboard), icon: Icon(LucideIcons.layoutDashboard),
@@ -322,7 +383,7 @@ class _AppShellState extends State<AppShell>
), ),
label: '任务', label: '任务',
), ),
if (_showSyncTab) if (_cachedShowSyncTab)
NavigationDestination( NavigationDestination(
icon: Consumer<SyncProvider>( icon: Consumer<SyncProvider>(
builder: (context, sync, _) { builder: (context, sync, _) {
@@ -373,15 +434,18 @@ class _AppShellState extends State<AppShell>
children: [ children: [
NavigationRail( NavigationRail(
selectedIndex: _clampedIndex(navProvider.currentIndex), selectedIndex: _clampedIndex(navProvider.currentIndex),
onDestinationSelected: (i) => navProvider.setIndex(i), onDestinationSelected: _handleTabSelected,
leading: Padding( leading: Padding(
padding: const EdgeInsets.symmetric(vertical: 16), padding: const EdgeInsets.symmetric(vertical: 16),
child: GestureDetector( child: GestureDetector(
onTap: () => navProvider.setIndex(_pages.length - 1), onTap: () =>
navProvider.setIndex(_pages(_cachedShowSyncTab).length - 1),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
border: navProvider.currentIndex == _pages.length - 1 border:
navProvider.currentIndex ==
_pages(_cachedShowSyncTab).length - 1
? Border.all( ? Border.all(
color: theme.colorScheme.primary, color: theme.colorScheme.primary,
width: 2.5, width: 2.5,
@@ -424,7 +488,7 @@ class _AppShellState extends State<AppShell>
selectedIcon: const Icon(LucideIcons.listChecks, weight: 700), selectedIcon: const Icon(LucideIcons.listChecks, weight: 700),
label: const Text('任务'), label: const Text('任务'),
), ),
if (_showSyncTab) if (_cachedShowSyncTab)
NavigationRailDestination( NavigationRailDestination(
icon: Consumer<SyncProvider>( icon: Consumer<SyncProvider>(
builder: (context, sync, _) { builder: (context, sync, _) {
+268 -77
View File
@@ -5,6 +5,7 @@ import 'package:lucide_icons/lucide_icons.dart';
import '../../../data/models/sync_task_model.dart'; import '../../../data/models/sync_task_model.dart';
import '../../providers/sync_provider.dart'; import '../../providers/sync_provider.dart';
import '../../widgets/sync_stats_card.dart';
import '../../widgets/toast_helper.dart'; import '../../widgets/toast_helper.dart';
import 'sync_settings_page.dart'; import 'sync_settings_page.dart';
@@ -55,7 +56,45 @@ class _SyncPageState extends State<SyncPage> {
child: ListView( child: ListView(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
children: [ children: [
_buildStatusCard(sync, theme), 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: [
_buildStatusHeaderCard(sync, theme),
const SizedBox(height: 8),
statsCard,
],
);
},
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
_buildActiveTasksSection(sync, theme), _buildActiveTasksSection(sync, theme),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -68,65 +107,66 @@ class _SyncPageState extends State<SyncPage> {
} }
void _navigateToSettings() { void _navigateToSettings() {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute(builder: (_) => const SyncSettingsPage()), context,
); ).push(MaterialPageRoute(builder: (_) => const SyncSettingsPage()));
} }
Widget _buildStatusCard(SyncProvider sync, ThemeData theme) { Widget _buildStatusHeaderCard(SyncProvider sync, ThemeData theme) {
final isActive = sync.isActive; final isActive = sync.isActive;
final isPaused = sync.isPaused; final isPaused = sync.isPaused;
final hasError = sync.hasError; final hasError = sync.hasError;
Color statusColor; Color statusColor;
IconData statusIcon;
String statusText; String statusText;
if (isActive) { if (isActive) {
statusColor = theme.colorScheme.primary; statusColor = theme.colorScheme.primary;
statusIcon = Icons.sync;
statusText = _syncModeLabel(sync); statusText = _syncModeLabel(sync);
} else if (isPaused) { } else if (isPaused) {
statusColor = Colors.orange; statusColor = Colors.orange;
statusIcon = Icons.pause_circle_outline;
statusText = '已暂停'; statusText = '已暂停';
} else if (hasError) { } else if (hasError) {
statusColor = theme.colorScheme.error; statusColor = theme.colorScheme.error;
statusIcon = Icons.error_outline;
statusText = '同步错误'; statusText = '同步错误';
} else if (sync.state == SyncState.stopped) { } else if (sync.state == SyncState.stopped) {
statusColor = theme.disabledColor; statusColor = theme.disabledColor;
statusIcon = Icons.stop_circle_outlined;
statusText = '已停止'; statusText = '已停止';
} else { } else {
statusColor = theme.disabledColor; statusColor = theme.disabledColor;
statusIcon = Icons.cloud_off;
statusText = '未启动'; statusText = '未启动';
} }
return Card( return Container(
margin: const EdgeInsets.symmetric(horizontal: 5), width: double.infinity,
child: Padding( decoration: BoxDecoration(
padding: const EdgeInsets.all(16), 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( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Center(
children: [ child: Text(
Icon(statusIcon, color: statusColor, size: 28),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
statusText, statusText,
style: theme.textTheme.titleMedium?.copyWith( style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: statusColor,
), ),
), ),
if (sync.errorMessage != null) ),
Text( if (sync.errorMessage != null) ...[
const SizedBox(height: 4),
Center(
child: Text(
sync.errorMessage!, sync.errorMessage!,
style: theme.textTheme.bodySmall?.copyWith( style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error, color: theme.colorScheme.error,
@@ -134,51 +174,134 @@ class _SyncPageState extends State<SyncPage> {
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, 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<Color>(
statusColor,
), ),
), ),
if (sync.activeWorkerCount > 0) )
Badge( else
label: Text('${sync.activeWorkerCount}'), SizedBox(
child: Icon(LucideIcons.loader, color: statusColor, size: 24), width: 96,
height: 96,
child: CircularProgressIndicator(
value: 0,
strokeWidth: 6,
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
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,
),
), ),
], ],
), ),
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(width: 24),
IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatRow(
Icons.file_upload_outlined,
'${sync.cumUploaded}',
'已上传',
Colors.blue,
theme,
),
const SizedBox(height: 8), 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( Text(
sync.totalFiles > 0 sync.currentFile!,
? '${sync.syncedFiles} / ${sync.totalFiles} 文件' style: theme.textTheme.bodySmall?.copyWith(
: sync.state == SyncState.continuous color: theme.hintColor,
? '持续同步中' ),
: '正在同步...', overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall, maxLines: 1,
), ),
], ],
if (sync.lastSummary != null) ...[ if (isActive && sync.activeTotalCount > 0) ...[
const SizedBox(height: 12), const SizedBox(height: 8),
Wrap( Text(
spacing: 12, '${sync.activeCompletedCount} / ${sync.activeTotalCount} 文件',
runSpacing: 4, style: theme.textTheme.bodySmall?.copyWith(
children: [ color: theme.hintColor,
_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), const SizedBox(height: 16),
Row( Row(
children: [ children: [
if (!sync.isActive && !sync.isPaused) if (!sync.isActive && !sync.isPaused)
@@ -222,16 +345,31 @@ class _SyncPageState extends State<SyncPage> {
), ),
], ],
), ),
),
); );
} }
Widget _summaryChip(ThemeData theme, String label, int value) { Widget _buildStatRow(
IconData icon,
String value,
String label,
Color color,
ThemeData theme,
) {
return Row( return Row(
mainAxisSize: MainAxisSize.min,
children: [ children: [
Text('$label:', style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor)), Icon(icon, size: 16, color: color),
Text(' $value', style: theme.textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w600)), 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<SyncPage> {
Widget _buildCompletedTasksSection(SyncProvider sync, ThemeData theme) { Widget _buildCompletedTasksSection(SyncProvider sync, ThemeData theme) {
final tasks = sync.recentTasks final tasks = sync.recentTasks
.where((t) => .where(
(t.status == 'completed' || t.status == 'failed' || t.status == 'cancelled') && (t) =>
t.totalCount > 0) (t.status == 'completed' ||
t.status == 'failed' ||
t.status == 'cancelled') &&
t.totalCount > 0,
)
.toList(); .toList();
return _buildTaskSection( return _buildTaskSection(
@@ -308,7 +450,10 @@ class _SyncPageState extends State<SyncPage> {
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Center( 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<SyncPage> {
child: Row( child: Row(
children: [ children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _statusColor(task.status, theme).withValues(alpha: 0.1), color: _statusColor(
task.status,
theme,
).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Text(
@@ -396,8 +547,7 @@ class _SyncPageState extends State<SyncPage> {
), ),
), ),
), ),
if (isExpanded) if (isExpanded) _buildTaskDetailList(task, theme),
_buildTaskDetailList(task, theme),
], ],
), ),
); );
@@ -410,7 +560,13 @@ class _SyncPageState extends State<SyncPage> {
if (_loadingDetails.contains(task.id)) { if (_loadingDetails.contains(task.id)) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16), 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<SyncPage> {
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6),
child: Row( child: Row(
children: [ children: [
SizedBox(width: 70, child: Text('操作', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), SizedBox(
Expanded(child: Text('文件名', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), width: 70,
SizedBox(width: 55, child: Text('状态', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), child: Text(
SizedBox(width: 110, child: Text('时间', style: theme.textTheme.labelSmall?.copyWith(color: theme.hintColor))), '操作',
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<SyncPage> {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _actionColor(item.actionType, theme).withValues(alpha: 0.1), color: _actionColor(
item.actionType,
theme,
).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
child: Text( child: Text(
@@ -560,7 +750,8 @@ class _SyncPageState extends State<SyncPage> {
setState(() => _expandedTasks.add(taskId)); setState(() => _expandedTasks.add(taskId));
sync.watchTaskDetail(taskId); sync.watchTaskDetail(taskId);
if (sync.getCachedTaskDetail(taskId) == null && !_loadingDetails.contains(taskId)) { if (sync.getCachedTaskDetail(taskId) == null &&
!_loadingDetails.contains(taskId)) {
setState(() => _loadingDetails.add(taskId)); setState(() => _loadingDetails.add(taskId));
sync.getTaskDetail(taskId).whenComplete(() { sync.getTaskDetail(taskId).whenComplete(() {
if (mounted) { if (mounted) {
@@ -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<SyncPageAndroid> createState() => _SyncPageAndroidState();
}
class _SyncPageAndroidState extends State<SyncPageAndroid> {
final Set<String> _expandedTasks = {};
final Set<String> _loadingDetails = {};
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SyncProvider>().loadRecentTasks();
});
}
@override
Widget build(BuildContext context) {
final sync = context.watch<SyncProvider>();
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<SyncProvider>();
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<Color>(
statusColor,
),
),
)
else
SizedBox(
width: 96,
height: 96,
child: CircularProgressIndicator(
value: 0,
strokeWidth: 6,
backgroundColor:
theme.colorScheme.surfaceContainerHighest,
valueColor: AlwaysStoppedAnimation<Color>(
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<Color>(
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<SyncProvider>();
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<SyncProvider>().loadMoreTaskDetail(taskId);
}
Future<void> _stopSync(SyncProvider sync) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('停止同步'),
content: const Text('确定要停止文件同步吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(ctx).colorScheme.error,
),
child: const Text('停止'),
),
],
),
);
if (confirmed == true) {
await sync.stop();
}
}
}
@@ -1,6 +1,7 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:external_path/external_path.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:open_file/open_file.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 '../../../data/models/sync_config_model.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/sync_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/desktop_constrained.dart';
import '../../widgets/folder_picker.dart'; import '../../widgets/folder_picker.dart';
import '../../widgets/sync_stats_card.dart';
import '../../widgets/toast_helper.dart'; import '../../widgets/toast_helper.dart';
import 'sync_log_viewer_page.dart'; import 'sync_log_viewer_page.dart';
@@ -42,6 +46,13 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
_localRootController = TextEditingController( _localRootController = TextEditingController(
text: SyncDefaults.defaultLocalRoot(), 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}'); AppLogger.i('默认同步目录: ${_localRootController.text}');
@@ -60,6 +71,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
_maxWorkers = config.maxWorkers; _maxWorkers = config.maxWorkers;
_logLevel = config.logLevel; _logLevel = config.logLevel;
}); });
_applyAlbumPaths();
} }
_loadSyncLogInfo(); _loadSyncLogInfo();
}); });
@@ -109,10 +121,16 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
children: [ children: [
if (sync.isActive || sync.isPaused) if (sync.isActive || sync.isPaused)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Text( child: Text(
'同步运行中,无法切换模式。请先停止同步再修改。', '同步运行中,无法切换模式。请先停止同步再修改。',
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12), style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
), ),
), ),
RadioGroup<String>( RadioGroup<String>(
@@ -124,7 +142,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
}, },
child: Column( child: Column(
children: [ children: [
if (Platform.isWindows) if (Platform.isWindows || Platform.isLinux)
RadioListTile<String>( RadioListTile<String>(
title: Row( title: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -132,9 +150,13 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
const Text('镜像同步'), const Text('镜像同步'),
const SizedBox(width: 8), const SizedBox(width: 8),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 1,
),
decoration: BoxDecoration( 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), borderRadius: BorderRadius.circular(4),
), ),
child: Text( child: Text(
@@ -142,7 +164,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.primary, color: Theme.of(
context,
).colorScheme.primary,
), ),
), ),
), ),
@@ -182,7 +206,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
? const Text('同步运行中,无法修改') ? const Text('同步运行中,无法修改')
: Text(_conflictStrategyLabel(_conflictStrategy)), : Text(_conflictStrategyLabel(_conflictStrategy)),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: (sync.isActive || sync.isPaused) ? null : () => _pickConflictStrategy(), onTap: (sync.isActive || sync.isPaused)
? null
: () => _pickConflictStrategy(),
), ),
], ],
), ),
@@ -197,7 +223,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
? const Text('同步运行中,无法修改') ? const Text('同步运行中,无法修改')
: Text(_wcfDeleteModeLabel(_wcfDeleteMode)), : Text(_wcfDeleteModeLabel(_wcfDeleteMode)),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: (sync.isActive || sync.isPaused) ? null : () => _pickWcfDeleteMode(), onTap: (sync.isActive || sync.isPaused)
? null
: () => _pickWcfDeleteMode(),
), ),
], ],
), ),
@@ -206,14 +234,41 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
_buildSection( _buildSection(
title: '相册同步', title: '相册同步',
children: [ children: [
SwitchListTile( if (sync.isActive || sync.isPaused)
title: const Text('自动备份相册'), Padding(
subtitle: const Text('将手机照片自动备份到云端'), padding: const EdgeInsets.symmetric(
value: _syncMode == 'album', horizontal: 16,
onChanged: (v) { vertical: 8,
setState(() => _syncMode = v ? 'album' : 'full'); ),
_pushConfig(); child: Text(
'同步运行中,无法切换模式。请先停止同步再修改。',
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 12,
),
),
),
RadioGroup<String>(
groupValue: _syncMode,
onChanged: (sync.isActive || sync.isPaused)
? (_) {}
: (v) {
if (v != null) _handleAlbumModeChange(v);
}, },
child: Column(
children: [
RadioListTile<String>(
title: const Text('仅上传'),
subtitle: const Text('备份手机照片到云端'),
value: 'album_upload',
),
RadioListTile<String>(
title: const Text('仅下载'),
subtitle: const Text('从云端下载照片到手机'),
value: 'album_download',
),
],
),
), ),
], ],
), ),
@@ -253,7 +308,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
children: [ children: [
if (!sync.isActive && !sync.isPaused) if (!sync.isActive && !sync.isPaused)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: FilledButton.icon( child: FilledButton.icon(
@@ -265,7 +323,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
), ),
if (sync.isActive || sync.isPaused) if (sync.isActive || sync.isPaused)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: Row( child: Row(
children: [ children: [
if (sync.isActive) if (sync.isActive)
@@ -291,7 +352,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
icon: const Icon(Icons.stop), icon: const Icon(Icons.stop),
label: const Text('停止'), label: const Text('停止'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error, foregroundColor: Theme.of(
context,
).colorScheme.error,
), ),
), ),
), ),
@@ -300,7 +363,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
), ),
if (sync.isActive) if (sync.isActive)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
@@ -312,7 +378,10 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
), ),
if (sync.engineInitialized) if (sync.engineInitialized)
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: SizedBox( child: SizedBox(
width: double.infinity, width: double.infinity,
child: OutlinedButton.icon( child: OutlinedButton.icon(
@@ -394,7 +463,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
if (newMode == 'full') { if (newMode == 'full') {
final confirmed = await _showModeConfirmDialog( final confirmed = await _showModeConfirmDialog(
title: '全量同步', title: '全量同步',
description: '此模式下:\n\n' description:
'此模式下:\n\n'
'• 本地和远程双向同步所有文件\n' '• 本地和远程双向同步所有文件\n'
'• 本地新增、修改的文件将上传到远程\n' '• 本地新增、修改的文件将上传到远程\n'
'• 远程新增、修改的文件将下载到本地\n' '• 远程新增、修改的文件将下载到本地\n'
@@ -407,7 +477,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} else if (newMode == 'upload_only') { } else if (newMode == 'upload_only') {
final confirmed = await _showModeConfirmDialog( final confirmed = await _showModeConfirmDialog(
title: '仅上传本地到远程', title: '仅上传本地到远程',
description: '此模式下:\n\n' description:
'此模式下:\n\n'
'• 本地新增、修改的文件将上传到远程\n' '• 本地新增、修改的文件将上传到远程\n'
'• 本地重命名、移动的文件会在远程同步操作\n' '• 本地重命名、移动的文件会在远程同步操作\n'
'• 本地删除的文件不会删除远程副本\n' '• 本地删除的文件不会删除远程副本\n'
@@ -419,7 +490,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} else if (newMode == 'download_only') { } else if (newMode == 'download_only') {
final confirmed = await _showModeConfirmDialog( final confirmed = await _showModeConfirmDialog(
title: '仅下载远程到本地', title: '仅下载远程到本地',
description: '此模式下:\n\n' description:
'此模式下:\n\n'
'• 远程新增、修改的文件将下载到本地\n' '• 远程新增、修改的文件将下载到本地\n'
'• 远程删除的文件将同步删除本地副本\n' '• 远程删除的文件将同步删除本地副本\n'
'• 远程重命名、移动会在本地同步\n' '• 远程重命名、移动会在本地同步\n'
@@ -432,7 +504,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} else if (newMode == 'mirror_wcf') { } else if (newMode == 'mirror_wcf') {
final confirmed = await _showModeConfirmDialog( final confirmed = await _showModeConfirmDialog(
title: '镜像同步', title: '镜像同步',
description: '此模式下(仅 Windows):\n\n' description:
'此模式下(仅 Windows | Linux):\n\n'
'• 远程文件以占位符形式出现在本地\n' '• 远程文件以占位符形式出现在本地\n'
'• 占位符不占用磁盘空间,但在资源管理器中可见\n' '• 占位符不占用磁盘空间,但在资源管理器中可见\n'
'• 打开文件时自动从云端下载(水合)\n' '• 打开文件时自动从云端下载(水合)\n'
@@ -447,6 +520,27 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
setState(() => _syncMode = newMode); setState(() => _syncMode = newMode);
} }
void _handleAlbumModeChange(String mode) {
setState(() {
_syncMode = mode;
_remoteRoot = SyncDefaults.defaultAndroidRemoteRoot;
});
SyncDefaults.getDefaultAndroidLocalRoot().then((path) {
if (mounted) setState(() => _localRootController.text = path);
});
_pushConfig();
}
/// 当前模式为相册模式时,自动设置写死路径
Future<void> _applyAlbumPaths() async {
if (!Platform.isAndroid) return;
if (_syncMode == 'album_upload' || _syncMode == 'album_download') {
final path = await SyncDefaults.getDefaultAndroidLocalRoot();
_localRootController.text = path;
_remoteRoot = SyncDefaults.defaultAndroidRemoteRoot;
}
}
Future<bool?> _showModeConfirmDialog({ Future<bool?> _showModeConfirmDialog({
required String title, required String title,
required String description, required String description,
@@ -475,10 +569,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
title: '同步状态', title: '同步状态',
children: [ children: [
ListTile( ListTile(
leading: Icon( leading: Icon(_stateIcon(sync), color: _stateColor(sync)),
_stateIcon(sync),
color: _stateColor(sync),
),
title: Text(_stateLabel(sync)), title: Text(_stateLabel(sync)),
subtitle: sync.hasError && sync.errorMessage != null subtitle: sync.hasError && sync.errorMessage != null
? Text( ? Text(
@@ -518,21 +609,21 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
), ),
), ),
], ],
if (sync.lastSummary != null) ...[ // 实时累积统计卡片
if (sync.engineInitialized) ...[
const Divider(indent: 16, endIndent: 16), const Divider(indent: 16, endIndent: 16),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Wrap( child: SyncStatsCard(
spacing: 16, uploaded: sync.cumUploaded,
children: [ downloaded: sync.cumDownloaded,
_summaryChip('上传', sync.lastSummary!.uploaded), renamed: sync.cumRenamed,
_summaryChip('下载', sync.lastSummary!.downloaded), moved: sync.cumMoved,
_summaryChip('冲突', sync.lastSummary!.conflicts), conflicts: sync.cumConflicts,
_summaryChip('失败', sync.lastSummary!.failed), failed: sync.cumFailed,
_summaryChip('跳过', sync.lastSummary!.skipped), deletedLocal: sync.cumDeletedLocal,
_summaryChip('删本地', sync.lastSummary!.deletedLocal), deletedRemote: sync.cumDeletedRemote,
_summaryChip('删远程', sync.lastSummary!.deletedRemote), skipped: sync.cumSkipped,
],
), ),
), ),
], ],
@@ -540,15 +631,6 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
); );
} }
Widget _summaryChip(String label, int value) {
return Chip(
label: Text('$label: $value'),
labelStyle: Theme.of(context).textTheme.bodySmall,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
);
}
IconData _stateIcon(SyncProvider sync) { IconData _stateIcon(SyncProvider sync) {
if (sync.isActive) return Icons.sync; if (sync.isActive) return Icons.sync;
if (sync.isPaused) return Icons.pause_circle_outline; if (sync.isPaused) return Icons.pause_circle_outline;
@@ -797,9 +879,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'0 表示自动等于 CPU 核心数\n最大不超过 CPU 核心数的 2 倍,超出无效', '0 表示自动等于 CPU 核心数\n最大不超过 CPU 核心数的 2 倍,超出无效',
style: Theme.of(ctx).textTheme.bodySmall?.copyWith( style: Theme.of(
color: Theme.of(ctx).hintColor, ctx,
), ).textTheme.bodySmall?.copyWith(color: Theme.of(ctx).hintColor),
), ),
], ],
), ),
@@ -871,6 +953,8 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
if (config == null) return; if (config == null) return;
final updated = config.copyWith( final updated = config.copyWith(
localRoot: _localRootController.text,
remoteRoot: _remoteRoot,
syncMode: _syncMode, syncMode: _syncMode,
conflictStrategy: _conflictStrategy, conflictStrategy: _conflictStrategy,
wcfDeleteMode: _wcfDeleteMode, wcfDeleteMode: _wcfDeleteMode,
@@ -890,6 +974,67 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
return; return;
} }
// Android 相册模式:先申请对应权限
if (Platform.isAndroid && _syncMode == 'album_upload') {
final statuses = await [Permission.photos, Permission.videos].request();
if (!statuses[Permission.photos]!.isGranted ||
!statuses[Permission.videos]!.isGranted) {
if (mounted) {
ToastHelper.failure('需要相册和视频权限才能同步');
final shouldOpen = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('权限不足'),
content: const Text('相册同步需要访问照片和视频的权限,请在系统设置中开启。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('去设置'),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
return;
}
} else if (Platform.isAndroid && _syncMode == 'album_download') {
// 仅下载模式需要写入 Camera 目录,必须拥有所有文件管理权限
final status = await Permission.manageExternalStorage.request();
if (!status.isGranted) {
if (mounted) {
ToastHelper.failure('需要所有文件管理权限才能写入相册');
final shouldOpen = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('权限不足'),
content: const Text('下载照片到手机相册需要"所有文件管理权限",请在系统设置中开启。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('去设置'),
),
],
),
);
if (shouldOpen == true) {
await openAppSettings();
}
}
return;
}
}
final appSupportDir = await getApplicationSupportDirectory(); final appSupportDir = await getApplicationSupportDirectory();
final config = SyncConfigModel( final config = SyncConfigModel(
@@ -909,7 +1054,23 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
logLevel: _logLevel, logLevel: _logLevel,
); );
// Album 模式:先初始化引擎,再确保远程相册目录存在
if (_syncMode == 'album_upload' || _syncMode == 'album_download') {
await sync.startSync(config); await sync.startSync(config);
try {
final result = await SyncService.instance.checkCloudAlbumDirs(
'cloudreve://my',
);
if (!(result['cameraExists'] as bool? ?? false)) {
AppLogger.i('远程 DCIM/Camera 目录不完整,正在创建...');
await SyncService.instance.createCloudAlbumDirs('cloudreve://my');
}
} catch (e) {
AppLogger.w('检查/创建远程相册目录失败: $e');
}
} else {
await sync.startSync(config);
}
} }
Future<void> _stopSync(SyncProvider sync) async { Future<void> _stopSync(SyncProvider sync) async {
@@ -940,17 +1101,23 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} }
Future<void> _resetSync(SyncProvider sync) async { Future<void> _resetSync(SyncProvider sync) async {
final isAndroid = Platform.isAndroid;
final description = isAndroid
? '此操作将:\n\n'
'• 停止当前同步任务\n'
'• 清空同步数据库(任务记录、文件映射)\n\n'
'本地文件不会被删除。重置后需重新点击"开始同步"。'
: '此操作将:\n\n'
'• 停止当前同步任务\n'
'• 清空同步数据库(任务记录、文件映射)\n'
'• 删除本地同步目录中的所有文件(不影响远程)\n\n'
'重置后需重新点击"开始同步"。此操作不可恢复。';
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('重置同步'), title: const Text('重置同步'),
content: const Text( content: Text(description),
'此操作将:\n\n'
'• 停止当前同步任务\n'
'• 清空同步数据库(任务记录、文件映射)\n'
'• 删除本地同步目录中的所有文件(不影响远程)\n\n'
'重置后需重新点击"开始同步"。此操作不可恢复。',
),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.pop(ctx, false), onPressed: () => Navigator.pop(ctx, false),
@@ -968,7 +1135,7 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
); );
if (confirmed == true) { if (confirmed == true) {
await sync.resetSync(); await sync.resetSync(deleteLocalFiles: !Platform.isAndroid);
if (mounted) ToastHelper.success('同步已重置'); if (mounted) ToastHelper.success('同步已重置');
} }
} }
@@ -1085,13 +1252,25 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
if (mounted) ToastHelper.error('日志文件不存在'); if (mounted) ToastHelper.error('日志文件不存在');
return; return;
} }
final String downloadPath;
if (Platform.isAndroid) {
downloadPath = await ExternalPath.getExternalStoragePublicDirectory(
ExternalPath.DIRECTORY_DOWNLOAD,
);
} else {
final downloadDir = await getDownloadsDirectory(); final downloadDir = await getDownloadsDirectory();
if (downloadDir == null) { if (downloadDir == null) {
if (mounted) ToastHelper.error('无法获取下载目录'); if (mounted) ToastHelper.error('无法获取下载目录');
return; return;
} }
final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-').substring(0, 19); downloadPath = downloadDir.path;
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); await srcFile.copy(destPath);
if (mounted) ToastHelper.success('日志已导出到:$destPath'); if (mounted) ToastHelper.success('日志已导出到:$destPath');
} catch (e) { } catch (e) {
@@ -1100,11 +1279,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
} }
Future<void> _previewSyncLog() async { Future<void> _previewSyncLog() async {
Navigator.of(context).push( Navigator.of(
MaterialPageRoute( context,
builder: (_) => const SyncLogViewerPage(), ).push(MaterialPageRoute(builder: (_) => const SyncLogViewerPage()));
),
);
} }
Future<void> _clearSyncLog() async { Future<void> _clearSyncLog() async {
@@ -1120,7 +1297,9 @@ class _SyncSettingsPageState extends State<SyncSettingsPage> {
), ),
FilledButton( FilledButton(
onPressed: () => Navigator.pop(ctx, true), 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('清空'), child: const Text('清空'),
), ),
], ],
+14 -4
View File
@@ -63,10 +63,7 @@ class AdminProvider extends ChangeNotifier {
Future<void> loadAll() async { Future<void> loadAll() async {
_setState(AdminState.loading); _setState(AdminState.loading);
try { try {
await Future.wait([ await Future.wait([loadGroups(), loadUsers()]);
loadGroups(),
loadUsers(),
]);
_setState(AdminState.idle); _setState(AdminState.idle);
} catch (e) { } catch (e) {
_errorMessage = e.toString(); _errorMessage = e.toString();
@@ -213,6 +210,19 @@ class AdminProvider extends ChangeNotifier {
bool isUserSelected(int userId) => _selectedUserIds.contains(userId); 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) { void _setState(AdminState state) {
_state = state; _state = state;
notifyListeners(); notifyListeners();
@@ -3,7 +3,10 @@ import 'dart:async';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../../data/models/file_model.dart'; import '../../data/models/file_model.dart';
import '../../services/file_service.dart'; import '../../services/file_service.dart';
import '../../services/storage_service.dart';
import '../../services/thumbnail_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/app_logger.dart';
import '../../core/utils/file_utils.dart'; import '../../core/utils/file_utils.dart';
@@ -15,7 +18,11 @@ class RefreshResult {
final int added; final int added;
final int removed; final int removed;
final int updated; 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; bool get isUnchanged => added == 0 && removed == 0 && updated == 0;
} }
@@ -25,8 +32,11 @@ class FileManagerProvider extends ChangeNotifier {
List<FileModel> _files = []; List<FileModel> _files = [];
List<String> _selectedFiles = []; List<String> _selectedFiles = [];
FileViewType _viewType = FileViewType.list; FileViewType _viewType = FileViewType.list;
SortOption _sortOption = SortOption.default_;
bool _isLoading = false; bool _isLoading = false;
bool _isLoadingMore = false;
bool _hasMore = true; bool _hasMore = true;
String? _nextPageToken;
String? _errorMessage; String? _errorMessage;
String? _contextHint; String? _contextHint;
String? _highlightPath; String? _highlightPath;
@@ -36,15 +46,21 @@ class FileManagerProvider extends ChangeNotifier {
List<FileModel> get files => _files; List<FileModel> get files => _files;
List<String> get selectedFiles => _selectedFiles; List<String> get selectedFiles => _selectedFiles;
FileViewType get viewType => _viewType; FileViewType get viewType => _viewType;
SortOption get sortOption => _sortOption;
bool get isLoading => _isLoading; bool get isLoading => _isLoading;
bool get isLoadingMore => _isLoadingMore;
bool get hasMore => _hasMore; bool get hasMore => _hasMore;
String? get nextPageToken => _nextPageToken;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
String? get contextHint => _contextHint; String? get contextHint => _contextHint;
bool get hasSelection => _selectedFiles.isNotEmpty; bool get hasSelection => _selectedFiles.isNotEmpty;
String? get highlightPath => _highlightPath; String? get highlightPath => _highlightPath;
/// 加载文件列表 /// 加载文件列表
Future<void> loadFiles({bool refresh = false, Duration timeout = const Duration(seconds: 5)}) async { Future<void> loadFiles({
bool refresh = false,
Duration timeout = const Duration(seconds: 5),
}) async {
if (refresh) { if (refresh) {
_selectedFiles.clear(); _selectedFiles.clear();
} }
@@ -52,13 +68,18 @@ class FileManagerProvider extends ChangeNotifier {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
_nextPageToken = null;
}); });
try { try {
final response = await FileService().listFiles( final response = await FileService()
.listFiles(
uri: _currentPath, uri: _currentPath,
pageSize: 50, pageSize: 50,
).timeout(timeout); orderBy: _sortOption.field.apiKey,
orderDirection: _sortOption.direction.apiKey,
)
.timeout(timeout);
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? []; final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
final pagination = response['pagination'] as Map<String, dynamic>? ?? {}; final pagination = response['pagination'] as Map<String, dynamic>? ?? {};
@@ -67,7 +88,8 @@ class FileManagerProvider extends ChangeNotifier {
_files = filesData _files = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>)) .map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.toList(); .toList();
_hasMore = pagination['next_token'] != null; _nextPageToken = pagination['next_token'] as String?;
_hasMore = _nextPageToken != null;
_contextHint = response['context_hint'] as String?; _contextHint = response['context_hint'] as String?;
}); });
} on TimeoutException { } on TimeoutException {
@@ -87,12 +109,62 @@ class FileManagerProvider extends ChangeNotifier {
} }
} }
/// 加载更多文件(分页)
Future<void> 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<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
final pagination = response['pagination'] as Map<String, dynamic>? ?? {};
final newFiles = filesData
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
.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<void> enterFolder(String path) async { Future<void> enterFolder(String path) async {
_currentPath = path; _currentPath = path;
_selectedFiles.clear(); _selectedFiles.clear();
_highlightPath = null; _highlightPath = null;
_highlightTimer?.cancel(); _highlightTimer?.cancel();
_nextPageToken = null;
ThumbnailService.instance.clearAll(); ThumbnailService.instance.clearAll();
await loadFiles(); await loadFiles();
} }
@@ -111,6 +183,7 @@ class FileManagerProvider extends ChangeNotifier {
_selectedFiles.clear(); _selectedFiles.clear();
_highlightPath = null; _highlightPath = null;
_highlightTimer?.cancel(); _highlightTimer?.cancel();
_nextPageToken = null;
ThumbnailService.instance.clearAll(); ThumbnailService.instance.clearAll();
notifyListeners(); notifyListeners();
await loadFiles(); await loadFiles();
@@ -144,6 +217,30 @@ class FileManagerProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 设置排序选项并重新加载
Future<void> setSortOption(SortOption option) async {
if (_sortOption == option) return;
_sortOption = option;
notifyListeners();
await StorageService.instance.setString(
StorageKeys.fileSortOption,
option.toKey(),
);
await loadFiles(refresh: true);
}
/// 从持久化恢复排序偏好
Future<void> 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) { void setErrorMessage(String? message) {
_errorMessage = message; _errorMessage = message;
@@ -224,7 +321,11 @@ class FileManagerProvider extends ChangeNotifier {
} }
/// 移动文件(增量更新) /// 移动文件(增量更新)
Future<String?> moveFiles(List<String> uris, String destination, {bool copy = false}) async { Future<String?> moveFiles(
List<String> uris,
String destination, {
bool copy = false,
}) async {
try { try {
await FileService().moveFiles(uris: uris, dst: destination, copy: copy); await FileService().moveFiles(uris: uris, dst: destination, copy: copy);
clearSelection(); clearSelection();
@@ -253,7 +354,10 @@ class FileManagerProvider extends ChangeNotifier {
/// 重命名文件(原地更新,不刷新列表) /// 重命名文件(原地更新,不刷新列表)
Future<String?> renameFile(String path, String newName) async { Future<String?> renameFile(String path, String newName) async {
try { try {
final response = await FileService().renameFile(uri: path, newName: newName); final response = await FileService().renameFile(
uri: path,
newName: newName,
);
if (response.isEmpty) { if (response.isEmpty) {
await loadFiles(); await loadFiles();
return null; return null;
@@ -319,21 +423,29 @@ class FileManagerProvider extends ChangeNotifier {
_selectedFiles = []; _selectedFiles = [];
_currentPath = '/'; _currentPath = '/';
_errorMessage = null; _errorMessage = null;
_nextPageToken = null;
_hasMore = true;
}); });
} }
/// 智能刷新 - 只更新差异部分 /// 智能刷新 - 只更新差异部分(仅刷新首页)
Future<RefreshResult> refreshFiles({Duration timeout = const Duration(seconds: 5)}) async { Future<RefreshResult> refreshFiles({
Duration timeout = const Duration(seconds: 5),
}) async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_errorMessage = null; _errorMessage = null;
}); });
try { try {
final response = await FileService().listFiles( final response = await FileService()
.listFiles(
uri: _currentPath, uri: _currentPath,
pageSize: 50, pageSize: 50,
).timeout(timeout); orderBy: _sortOption.field.apiKey,
orderDirection: _sortOption.direction.apiKey,
)
.timeout(timeout);
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? []; final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
final newFiles = filesData final newFiles = filesData
@@ -378,9 +490,11 @@ class FileManagerProvider extends ChangeNotifier {
} }
} }
final pagination = response['pagination'] as Map<String, dynamic>?;
setState(() { setState(() {
_files = updatedFiles; _files = updatedFiles;
_hasMore = response['pagination']?['next_token'] != null; _nextPageToken = pagination?['next_token'] as String?;
_hasMore = _nextPageToken != null;
_contextHint = response['context_hint'] as String?; _contextHint = response['context_hint'] as String?;
}); });
+287 -16
View File
@@ -19,7 +19,6 @@ enum SyncState {
} }
class SyncProvider extends ChangeNotifier { class SyncProvider extends ChangeNotifier {
SyncState _state = SyncState.idle; SyncState _state = SyncState.idle;
String? _errorMessage; String? _errorMessage;
SyncSummaryModel? _lastSummary; SyncSummaryModel? _lastSummary;
@@ -48,6 +47,17 @@ class SyncProvider extends ChangeNotifier {
// 持久化的同步配置 // 持久化的同步配置
SyncConfigModel? _persistedConfig; 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; SyncState get state => _state;
String? get errorMessage => _errorMessage; String? get errorMessage => _errorMessage;
SyncSummaryModel? get lastSummary => _lastSummary; SyncSummaryModel? get lastSummary => _lastSummary;
@@ -62,6 +72,70 @@ class SyncProvider extends ChangeNotifier {
List<SyncTaskModel> get recentTasks => _recentTasks; List<SyncTaskModel> get recentTasks => _recentTasks;
int get activeWorkerCount => _activeWorkerCount; 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 => bool get isActive =>
_state == SyncState.initializing || _state == SyncState.initializing ||
_state == SyncState.initialSync || _state == SyncState.initialSync ||
@@ -75,8 +149,7 @@ class SyncProvider extends ChangeNotifier {
bool _engineInitialized = false; bool _engineInitialized = false;
bool get engineInitialized => _engineInitialized; bool get engineInitialized => _engineInitialized;
double get progress => double get progress => _totalFiles > 0 ? _syncedFiles / _totalFiles : 0.0;
_totalFiles > 0 ? _syncedFiles / _totalFiles : 0.0;
/// 从持久化存储恢复同步配置和状态 /// 从持久化存储恢复同步配置和状态
Future<void> restoreFromStorage() async { Future<void> restoreFromStorage() async {
@@ -89,17 +162,25 @@ class SyncProvider extends ChangeNotifier {
refreshToken: configMap['refreshToken'] as String? ?? '', refreshToken: configMap['refreshToken'] as String? ?? '',
localRoot: configMap['localRoot'] as String? ?? '', localRoot: configMap['localRoot'] as String? ?? '',
remoteRoot: configMap['remoteRoot'] as String? ?? 'cloudreve://my', remoteRoot: configMap['remoteRoot'] as String? ?? 'cloudreve://my',
syncMode: configMap['syncMode'] as String? ?? 'full', // 旧版 'album' 迁移为 'album_upload'
conflictStrategy: configMap['conflictStrategy'] as String? ?? 'keep_both', syncMode: (configMap['syncMode'] as String? ?? 'full') == 'album'
wcfDeleteMode: configMap['wcfDeleteMode'] as String? ?? 'wcf_delete_local_only', ? 'album_upload'
maxConcurrentTransfers: configMap['maxConcurrentTransfers'] as int? ?? 3, : (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, bandwidthLimitKbps: configMap['bandwidthLimitKbps'] as int? ?? 0,
maxWorkers: configMap['maxWorkers'] as int? ?? 0, maxWorkers: configMap['maxWorkers'] as int? ?? 0,
dataDir: configMap['dataDir'] as String? ?? '', dataDir: configMap['dataDir'] as String? ?? '',
clientId: configMap['clientId'] as String? ?? '', clientId: configMap['clientId'] as String? ?? '',
logLevel: configMap['logLevel'] as String? ?? 'info', 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) { } catch (e) {
AppLogger.e('恢复同步配置失败: $e'); AppLogger.e('恢复同步配置失败: $e');
} }
@@ -109,6 +190,23 @@ class SyncProvider extends ChangeNotifier {
if (savedState != null && savedState != 'idle' && savedState != 'stopped') { if (savedState != null && savedState != 'idle' && savedState != 'stopped') {
AppLogger.i('恢复同步状态: $savedState'); 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<void> _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<void> _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<void> _persistState(SyncState state) async { Future<void> _persistState(SyncState state) async {
final stateStr = switch (state) { final stateStr = switch (state) {
@@ -153,6 +292,7 @@ class SyncProvider extends ChangeNotifier {
_syncedFiles = 0; _syncedFiles = 0;
_totalFiles = 0; _totalFiles = 0;
_currentFile = null; _currentFile = null;
// 不再清零 cum — 从 DB 加载权威数据
notifyListeners(); notifyListeners();
// 确保 clientId 已注入(Dart 生成、持久化、全层共享) // 确保 clientId 已注入(Dart 生成、持久化、全层共享)
@@ -166,6 +306,10 @@ class SyncProvider extends ChangeNotifier {
try { try {
await SyncService.instance.init(configWithClientId); await SyncService.instance.init(configWithClientId);
_engineInitialized = true; _engineInitialized = true;
// 从 DB 加载累积统计(权威数据源,替代 SharedPreferences
await _loadCumStatsFromDb();
_subscribeEvents(); _subscribeEvents();
_state = SyncState.initialSync; _state = SyncState.initialSync;
@@ -176,8 +320,13 @@ class SyncProvider extends ChangeNotifier {
_startPolling(); _startPolling();
// 启动初始同步(后台运行) // 启动初始同步(后台运行)
SyncService.instance.startInitialSync().then((summary) async { SyncService.instance
.startInitialSync()
.then((summary) async {
_lastSummary = summary; _lastSummary = summary;
// 不再覆盖 cum — TaskItemUpdated 事件已实时递增
// 初始同步完成后从 DB 重新校准(避免事件遗漏)
await _loadCumStatsFromDb();
_state = SyncState.continuous; _state = SyncState.continuous;
await _persistState(SyncState.continuous); await _persistState(SyncState.continuous);
AppLogger.i('初始同步完成'); AppLogger.i('初始同步完成');
@@ -185,7 +334,8 @@ class SyncProvider extends ChangeNotifier {
// 自动启动持续同步 // 自动启动持续同步
SyncService.instance.startContinuousSync(); SyncService.instance.startContinuousSync();
}).catchError((e) async { })
.catchError((e) async {
_state = SyncState.error; _state = SyncState.error;
_errorMessage = e.toString(); _errorMessage = e.toString();
await _persistState(SyncState.error); await _persistState(SyncState.error);
@@ -212,7 +362,10 @@ class SyncProvider extends ChangeNotifier {
if (_persistedConfig == null) return; if (_persistedConfig == null) return;
final savedState = await StorageService.instance.getSyncState(); 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; return;
} }
@@ -283,6 +436,7 @@ class SyncProvider extends ChangeNotifier {
} }
int _pollErrorCount = 0; int _pollErrorCount = 0;
int _pollCount = 0;
Future<void> _pollStatus() async { Future<void> _pollStatus() async {
try { try {
@@ -305,7 +459,8 @@ class SyncProvider extends ChangeNotifier {
// 轮询活跃任务 + 刷新已完成任务 // 轮询活跃任务 + 刷新已完成任务
try { try {
final newActiveWorkerCount = await SyncService.instance.getActiveWorkerCount(); final newActiveWorkerCount = await SyncService.instance
.getActiveWorkerCount();
final newActiveTasks = await SyncService.instance.getActiveTasksTyped(); final newActiveTasks = await SyncService.instance.getActiveTasksTyped();
bool changed = newActiveWorkerCount != _activeWorkerCount; bool changed = newActiveWorkerCount != _activeWorkerCount;
@@ -334,6 +489,43 @@ class SyncProvider extends ChangeNotifier {
} }
} catch (_) {} } 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(); notifyListeners();
_adjustPollInterval(); _adjustPollInterval();
} catch (_) { } catch (_) {
@@ -439,11 +631,80 @@ class SyncProvider extends ChangeNotifier {
_lastSummary = summary; _lastSummary = summary;
_state = SyncState.continuous; _state = SyncState.continuous;
await _persistState(SyncState.continuous); await _persistState(SyncState.continuous);
SyncService.instance.startContinuousSync(); // 不再在此启动 continuous sync — .then() 回调已启动,避免重复
case SyncDiskSpaceWarning(): case SyncDiskSpaceWarning():
AppLogger.w('磁盘空间不足'); 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(); notifyListeners();
_persistCumStats();
}); });
} }
@@ -554,11 +815,11 @@ class SyncProvider extends ChangeNotifier {
_adjustPollInterval(); _adjustPollInterval();
} }
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 /// 重置同步:停止任务 → 清空 DB → (可选)清空本地目录 → 回到初始状态
Future<void> resetSync() async { Future<void> resetSync({bool deleteLocalFiles = true}) async {
// 引擎未初始化时仅清空本地状态 // 引擎未初始化时仅清空本地状态
try { try {
await SyncService.instance.resetSync(); await SyncService.instance.resetSync(deleteLocalFiles: deleteLocalFiles);
} catch (_) {} } catch (_) {}
_state = SyncState.idle; _state = SyncState.idle;
_errorMessage = null; _errorMessage = null;
@@ -569,6 +830,16 @@ class SyncProvider extends ChangeNotifier {
_downloadingCount = 0; _downloadingCount = 0;
_currentFile = null; _currentFile = null;
_lastSummary = null; _lastSummary = null;
_cumUploaded = 0;
_cumDownloaded = 0;
_cumRenamed = 0;
_cumMoved = 0;
_cumFailed = 0;
_cumConflicts = 0;
_cumDeletedLocal = 0;
_cumDeletedRemote = 0;
_cumSkipped = 0;
_persistCumStats();
_activeTasks = []; _activeTasks = [];
_recentTasks = []; _recentTasks = [];
_activeWorkerCount = 0; _activeWorkerCount = 0;
@@ -46,10 +46,7 @@ class UserSettingProvider extends ChangeNotifier {
/// 同时加载设置和容量 /// 同时加载设置和容量
Future<void> loadAll() async { Future<void> loadAll() async {
await Future.wait([ await Future.wait([loadSettings(), loadCapacity()]);
loadSettings(),
loadCapacity(),
]);
} }
/// 修改昵称 /// 修改昵称
@@ -244,6 +241,15 @@ class UserSettingProvider extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
/// 清除用户数据(切换账号时调用)
void clear() {
_settings = null;
_capacity = null;
_errorMessage = null;
_state = UserSettingState.idle;
notifyListeners();
}
void _setState(UserSettingState state) { void _setState(UserSettingState state) {
_state = state; _state = state;
notifyListeners(); notifyListeners();
@@ -201,6 +201,9 @@ $body
onLoadStop: (_, url) { onLoadStop: (_, url) {
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
}, },
onReceivedError: (_, request, error) {
if (mounted) setState(() => _loading = false);
},
); );
} }
+36 -5
View File
@@ -4,7 +4,7 @@ import 'package:lucide_icons/lucide_icons.dart';
import '../../core/utils/file_utils.dart'; import '../../core/utils/file_utils.dart';
/// 面包屑导航组件 /// 面包屑导航组件
class FileBreadcrumb extends StatelessWidget { class FileBreadcrumb extends StatefulWidget {
final String currentPath; final String currentPath;
final void Function(String path) onPathTap; final void Function(String path) onPathTap;
@@ -14,9 +14,38 @@ class FileBreadcrumb extends StatelessWidget {
required this.onPathTap, required this.onPathTap,
}); });
@override
State<FileBreadcrumb> createState() => _FileBreadcrumbState();
}
class _FileBreadcrumbState extends State<FileBreadcrumb> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final pathParts = currentPath.split('/'); final pathParts = widget.currentPath.split('/');
pathParts.removeWhere((part) => part.isEmpty); pathParts.removeWhere((part) => part.isEmpty);
final theme = Theme.of(context); final theme = Theme.of(context);
final colorScheme = theme.colorScheme; final colorScheme = theme.colorScheme;
@@ -34,6 +63,7 @@ class FileBreadcrumb extends StatelessWidget {
), ),
), ),
child: SingleChildScrollView( child: SingleChildScrollView(
controller: _controller,
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
child: Row( child: Row(
children: [ children: [
@@ -43,7 +73,7 @@ class FileBreadcrumb extends StatelessWidget {
path: '/', path: '/',
icon: LucideIcons.home, icon: LucideIcons.home,
primaryColor: colorScheme.primary, primaryColor: colorScheme.primary,
onTap: () => onPathTap('/'), onTap: () => widget.onPathTap('/'),
), ),
for (int i = 0; i < pathParts.length; i++) ...[ for (int i = 0; i < pathParts.length; i++) ...[
Padding( Padding(
@@ -60,8 +90,9 @@ class FileBreadcrumb extends StatelessWidget {
path: '/${pathParts.sublist(0, i + 1).join('/')}', path: '/${pathParts.sublist(0, i + 1).join('/')}',
icon: null, icon: null,
primaryColor: colorScheme.primary, primaryColor: colorScheme.primary,
onTap: () => onTap: () => widget.onPathTap(
onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'), '/${pathParts.sublist(0, i + 1).join('/')}',
),
), ),
], ],
], ],
+81 -10
View File
@@ -1,18 +1,23 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// 文件列表表头(桌面端) import '../../core/constants/sort_options.dart';
/// 文件列表表头(桌面端),支持点击排序
class FileListHeader extends StatelessWidget { class FileListHeader extends StatelessWidget {
final bool showCheckbox; final bool showCheckbox;
const FileListHeader({super.key, this.showCheckbox = false}); final SortOption? currentSort;
final ValueChanged<SortOption>? onSort;
const FileListHeader({
super.key,
this.showCheckbox = false,
this.currentSort,
this.onSort,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final style = TextStyle(
color: theme.hintColor,
fontSize: 12,
fontWeight: FontWeight.w500,
);
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
@@ -26,11 +31,77 @@ class FileListHeader extends StatelessWidget {
if (showCheckbox) const SizedBox(width: 40), if (showCheckbox) const SizedBox(width: 40),
// 图标占位 // 图标占位
const SizedBox(width: 36 + 16), const SizedBox(width: 36 + 16),
Expanded(flex: 5, child: Text('名称', style: style)), Expanded(
Expanded(flex: 2, child: Text('修改日期', style: style)), flex: 5,
Expanded(flex: 1, child: Text('大小', style: style)), 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));
}
}
} }
+23 -2
View File
@@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:lucide_icons/lucide_icons.dart'; import 'package:lucide_icons/lucide_icons.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -18,7 +19,12 @@ import 'glassmorphism_container.dart';
class SearchDialog extends StatefulWidget { class SearchDialog extends StatefulWidget {
const SearchDialog({super.key}); const SearchDialog({super.key});
static bool _isShowing = false;
static bool get isShowing => _isShowing;
static Future<void> show(BuildContext context) { static Future<void> show(BuildContext context) {
if (_isShowing) return Future.value();
_isShowing = true;
return showGeneralDialog( return showGeneralDialog(
context: context, context: context,
barrierDismissible: true, barrierDismissible: true,
@@ -41,7 +47,7 @@ class SearchDialog extends StatefulWidget {
}, },
pageBuilder: (context, animation, secondaryAnimation) => pageBuilder: (context, animation, secondaryAnimation) =>
const SearchDialog(), const SearchDialog(),
); ).whenComplete(() => _isShowing = false);
} }
@override @override
@@ -185,7 +191,20 @@ class _SearchDialogState extends State<SearchDialog> {
final isWide = screenWidth >= 600; final isWide = screenWidth >= 600;
final dialogWidth = isWide ? 560.0 : screenWidth - 32.0; final dialogWidth = isWide ? 560.0 : screenWidth - 32.0;
return Center( return Shortcuts(
shortcuts: <ShortcutActivator, Intent>{
const SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
},
child: Actions(
actions: <Type, Action<Intent>>{
DismissIntent: CallbackAction<DismissIntent>(
onInvoke: (_) {
Navigator.of(context).pop();
return null;
},
),
},
child: Center(
child: Container( child: Container(
width: dialogWidth, width: dialogWidth,
constraints: BoxConstraints(maxHeight: screenHeight * 0.75), constraints: BoxConstraints(maxHeight: screenHeight * 0.75),
@@ -209,6 +228,8 @@ class _SearchDialogState extends State<SearchDialog> {
), ),
), ),
), ),
),
),
); );
} }
@@ -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<PieChartSectionData> _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();
}
}
+23 -66
View File
@@ -1,5 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../data/models/app_update_model.dart'; import '../../data/models/app_update_model.dart';
@@ -31,6 +29,8 @@ class _UpdatePromptState extends State<UpdatePrompt> {
final update = await UpdateService.instance.checkForUpdate(); final update = await UpdateService.instance.checkForUpdate();
if (update == null || !mounted) return; if (update == null || !mounted) return;
final shouldShow = await UpdateService.instance.shouldShowPrompt(update);
if (!shouldShow || !mounted) return;
_dialogVisible = true; _dialogVisible = true;
try { try {
@@ -41,101 +41,58 @@ class _UpdatePromptState extends State<UpdatePrompt> {
} }
Future<void> _showUpdateDialog(AppUpdateInfo update) async { Future<void> _showUpdateDialog(AppUpdateInfo update) async {
var downloading = false;
var progress = 0.0;
String? status;
await showDialog<void>( await showDialog<void>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (dialogContext) { builder: (dialogContext) {
return StatefulBuilder( Future<void> openDownloadsPage() async {
builder: (context, setDialogState) {
final canDownload =
update.downloadUrl != null &&
(Platform.isAndroid || Platform.isWindows);
Future<void> startUpdate() async {
if (downloading || !canDownload) return;
setDialogState(() {
downloading = true;
progress = 0;
status = '正在下载更新...';
});
try { try {
final file = await UpdateService.instance.downloadUpdate( await UpdateService.instance.openDownloadsPage();
update,
onProgress: (received, total) {
if (!mounted || total <= 0) return;
setDialogState(() {
progress = received / total;
});
},
);
if (file == null) return;
setDialogState(() {
progress = 1;
status = '下载完成,正在打开安装包...';
});
await UpdateService.instance.openInstaller(file);
if (dialogContext.mounted) Navigator.of(dialogContext).pop(); if (dialogContext.mounted) Navigator.of(dialogContext).pop();
} catch (e) { } catch (e) {
setDialogState(() { ToastHelper.failure('打开下载页面失败: $e');
downloading = false;
status = '下载或打开安装包失败';
});
ToastHelper.failure('更新失败: $e');
} }
} }
Future<void> skipPrompt() async {
await UpdateService.instance.skipPromptForFiveDays(update);
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
}
final releaseNotes = (update.description ?? '').trim();
return AlertDialog( return AlertDialog(
title: Text('发现新版本 ${update.version}'), title: Text('发现新版本 ${update.version}'),
content: ConstrainedBox( content: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420), constraints: const BoxConstraints(maxWidth: 420, maxHeight: 360),
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(update.title), Text('新版本号:${update.version}'),
if ((update.description ?? '').isNotEmpty) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
Text( Text(
update.description!, '版本日志',
maxLines: 6, style: Theme.of(dialogContext).textTheme.titleSmall,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 6),
Text(releaseNotes.isEmpty ? update.title : releaseNotes),
], ],
if (downloading) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: progress == 0 ? null : progress,
), ),
const SizedBox(height: 8),
Text(status ?? ''),
],
],
), ),
), ),
actions: [ actions: [
TextButton(onPressed: skipPrompt, child: const Text('跳过')),
TextButton( TextButton(
onPressed: downloading onPressed: () => Navigator.of(dialogContext).pop(),
? null child: const Text('取消'),
: () => Navigator.of(dialogContext).pop(),
child: const Text('稍后'),
),
FilledButton(
onPressed: downloading ? null : startUpdate,
child: const Text('立即更新'),
), ),
FilledButton(onPressed: openDownloadsPage, child: const Text('更新')),
], ],
); );
}, },
); );
},
);
} }
@override @override
+8
View File
@@ -7,6 +7,7 @@ import '../presentation/pages/recycle_bin/recycle_bin_page.dart';
import '../presentation/pages/webdav/webdav_page.dart'; import '../presentation/pages/webdav/webdav_page.dart';
import '../presentation/pages/remote_download/remote_download_page.dart'; import '../presentation/pages/remote_download/remote_download_page.dart';
import '../presentation/pages/settings/settings_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/sync/sync_settings_page.dart';
import '../presentation/pages/preview/image_preview_page.dart'; import '../presentation/pages/preview/image_preview_page.dart';
import '../presentation/pages/preview/pdf_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 documentPreview = '/document-preview';
static const String markdownPreview = '/markdown-preview'; static const String markdownPreview = '/markdown-preview';
static const String categoryFiles = '/category-files'; static const String categoryFiles = '/category-files';
static const String syncStatus = '/sync-status';
static const String syncSettings = '/sync-settings'; static const String syncSettings = '/sync-settings';
static const String shareLink = '/share-link'; static const String shareLink = '/share-link';
static const String cloudreveFileApp = '/cloudreve-file-app'; static const String cloudreveFileApp = '/cloudreve-file-app';
@@ -231,6 +233,12 @@ class AppRouter {
builder: (context) => const SyncSettingsPage(), builder: (context) => const SyncSettingsPage(),
); );
case RouteNames.syncStatus:
return MaterialPageRoute(
settings: settings,
builder: (context) => const SyncPageAndroid(),
);
case RouteNames.shareLink: case RouteNames.shareLink:
final args = settings.arguments; final args = settings.arguments;
if (args is ShareLinkCandidate) { if (args is ShareLinkCandidate) {
+223
View File
@@ -0,0 +1,223 @@
import 'dart:convert';
import 'package:media_kit/media_kit.dart';
import '../core/constants/storage_keys.dart';
import '../core/utils/app_logger.dart';
import 'host_mapping_proxy_service.dart';
import 'server_service.dart';
import 'storage_service.dart';
class CustomLineService {
CustomLineService._();
static final CustomLineService instance = CustomLineService._();
HostMappingProxyEndpoint? get currentProxyEndpoint =>
HostMappingProxyService.instance.endpoint;
Future<SelectedCustomLineNode?> getSelectedNode() async {
final raw = await StorageService.instance.getString(
StorageKeys.selectedCustomLineNode,
);
if (raw == null || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) return null;
final node = SelectedCustomLineNode.fromJson(decoded);
if (!node.canRewriteDownloadUrl) return null;
return node;
} catch (_) {
return null;
}
}
Future<void> setSelectedNode(SelectedCustomLineNode node) async {
if (!node.canRewriteDownloadUrl) {
throw ArgumentError('The selected custom line node has no usable IP.');
}
await StorageService.instance.setString(
StorageKeys.selectedCustomLineNode,
jsonEncode(node.toJson()),
);
}
Future<void> clearSelectedNode() async {
await StorageService.instance.remove(StorageKeys.selectedCustomLineNode);
await HostMappingProxyService.instance.stop();
}
Future<CustomLineDownloadRoute> routeDownloadUrl(String url) async {
if (!_canUseCustomLine()) {
await HostMappingProxyService.instance.stop();
return CustomLineDownloadRoute(url: url);
}
final node = await getSelectedNode();
if (node == null) return CustomLineDownloadRoute(url: url);
final uri = Uri.tryParse(url);
if (uri == null || uri.host.isEmpty) {
return CustomLineDownloadRoute(url: url);
}
final endpoint = await activateSelectedNodeForHost(uri.host);
if (endpoint == null) {
return CustomLineDownloadRoute(url: url);
}
return CustomLineDownloadRoute(
url: url,
nodeName: node.name,
originalHost: uri.host,
proxy: endpoint,
);
}
Future<HostMappingProxyEndpoint?> activateSelectedNodeForHost(String host) {
return activateSelectedNodeForHosts([host]);
}
Future<HostMappingProxyEndpoint?> activateSelectedNodeForHosts(
Iterable<String> hosts,
) async {
if (!_canUseCustomLine()) {
await HostMappingProxyService.instance.stop();
return null;
}
final node = await getSelectedNode();
if (node == null) {
await HostMappingProxyService.instance.stop();
return null;
}
final hostMap = <String, String>{};
void addHost(String value) {
final host = value.trim();
if (host.isNotEmpty) hostMap[host] = node.ip;
}
addHost(node.host);
for (final host in hosts) {
addHost(host);
}
if (hostMap.isEmpty) {
await HostMappingProxyService.instance.stop();
return null;
}
return HostMappingProxyService.instance.configure(hostMap);
}
Future<HostMappingProxyEndpoint?> activateSelectedNodeForUrl(String url) {
final uri = Uri.tryParse(url);
return activateSelectedNodeForHost(uri?.host ?? '');
}
Future<HostMappingProxyEndpoint?> activateSelectedNodeForUrls(
Iterable<String> urls,
) {
final hosts = urls
.map((url) => Uri.tryParse(url)?.host ?? '')
.where((host) => host.trim().isNotEmpty);
return activateSelectedNodeForHosts(hosts);
}
Future<void> configureMediaPlayerProxy(Player player, String url) async {
final endpoint = await activateSelectedNodeForUrl(url);
final platform = player.platform;
if (platform is! NativePlayer) return;
try {
await platform.setProperty('http-proxy', endpoint?.proxyUri ?? '');
AppLogger.d('媒体播放器自定义线路代理已配置: ${endpoint?.proxyUri ?? 'DIRECT'}');
} catch (e) {
AppLogger.d('媒体播放器自定义线路代理配置失败: $e');
}
}
bool _canUseCustomLine() {
final user = ServerService.instance.currentServer?.user;
final groupName = user?.group?.name.trim().toLowerCase();
return user != null &&
groupName != null &&
groupName.isNotEmpty &&
groupName != 'user';
}
}
class SelectedCustomLineNode {
final String id;
final String name;
final String ip;
final String host;
final String protocol;
final int? port;
const SelectedCustomLineNode({
required this.id,
required this.name,
required this.ip,
required this.host,
this.protocol = '',
this.port,
});
bool get canRewriteDownloadUrl =>
ip.trim().isNotEmpty && host.trim().isNotEmpty;
String routeScheme(String fallback) {
final value = protocol.trim().toLowerCase();
return value == 'http' || value == 'https' ? value : fallback;
}
int? get routePort => port != null && port! > 0 ? port : null;
factory SelectedCustomLineNode.fromJson(Map<String, dynamic> json) {
return SelectedCustomLineNode(
id: json['id']?.toString() ?? '',
name: json['name']?.toString() ?? '',
ip: json['ip']?.toString() ?? '',
host: json['host']?.toString() ?? '',
protocol: json['protocol']?.toString() ?? '',
port: _asNullableInt(json['port']),
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'name': name,
'ip': ip,
'host': host,
if (protocol.trim().isNotEmpty) 'protocol': protocol,
if (routePort != null) 'port': routePort,
};
}
static int? _asNullableInt(Object? value) {
if (value is int) return value;
if (value is num) return value.round();
return int.tryParse(value?.toString() ?? '');
}
}
class CustomLineDownloadRoute {
final String url;
final Map<String, String> headers;
final String? nodeName;
final String? originalHost;
final HostMappingProxyEndpoint? proxy;
const CustomLineDownloadRoute({
required this.url,
this.headers = const {},
this.nodeName,
this.originalHost,
this.proxy,
});
bool get changed => proxy != null || headers.isNotEmpty;
}
+66 -26
View File
@@ -2,10 +2,12 @@ import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:background_downloader/background_downloader.dart' as bd; import 'package:background_downloader/background_downloader.dart' as bd;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:external_path/external_path.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart';
import '../core/constants/storage_keys.dart'; import '../core/constants/storage_keys.dart';
import '../data/models/download_task_model.dart'; import '../data/models/download_task_model.dart';
import 'custom_line_service.dart';
import 'file_service.dart'; import 'file_service.dart';
import 'storage_service.dart'; import 'storage_service.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
@@ -36,7 +38,8 @@ class DownloadService {
/// 设置回调处理器 /// 设置回调处理器
static void setCallbackHandler( static void setCallbackHandler(
Function(String taskId, DownloadStatus status, int progress) handler) { Function(String taskId, DownloadStatus status, int progress) handler,
) {
_callbackHandler = handler; _callbackHandler = handler;
} }
@@ -65,7 +68,10 @@ class DownloadService {
throw Exception('存储权限被拒绝'); 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()) { if (!await directory.exists()) {
await directory.create(recursive: true); await directory.create(recursive: true);
} }
@@ -103,22 +109,23 @@ class DownloadService {
/// 读取 WiFi-only 下载设置 /// 读取 WiFi-only 下载设置
Future<bool> isWifiOnlyEnabled() async { Future<bool> isWifiOnlyEnabled() async {
return await StorageService.instance return await StorageService.instance.getBool(
.getBool(StorageKeys.downloadWifiOnly) ?? StorageKeys.downloadWifiOnly,
) ??
false; false;
} }
/// 读取重试次数设置 /// 读取重试次数设置
Future<int> getRetries() async { Future<int> getRetries() async {
return await StorageService.instance return await StorageService.instance.getInt(StorageKeys.downloadRetries) ??
.getInt(StorageKeys.downloadRetries) ??
3; 3;
} }
/// 初始化下载器 /// 初始化下载器
Future<void> initialize( Future<void> initialize({
{Function(String taskId, DownloadStatus status, int progress)? Function(String taskId, DownloadStatus status, int progress)?
callbackHandler}) async { callbackHandler,
}) async {
if (callbackHandler != null) { if (callbackHandler != null) {
setCallbackHandler(callbackHandler); setCallbackHandler(callbackHandler);
AppLogger.d('回调处理器已更新'); AppLogger.d('回调处理器已更新');
@@ -133,9 +140,10 @@ class DownloadService {
if (Platform.isAndroid) { if (Platform.isAndroid) {
bd.FileDownloader().configureNotification( bd.FileDownloader().configureNotification(
running: const bd.TaskNotification( running: const bd.TaskNotification(
'正在下载', '文件: {filename} - {progress}'), '正在下载',
complete: '文件: {filename} - {progress}',
const bd.TaskNotification('下载完成', '文件: {filename} 已保存'), ),
complete: const bd.TaskNotification('下载完成', '文件: {filename} 已保存'),
error: const bd.TaskNotification('下载失败', '文件: {filename} 下载出错'), error: const bd.TaskNotification('下载失败', '文件: {filename} 下载出错'),
paused: const bd.TaskNotification('已暂停', '文件: {filename} 已暂停'), paused: const bd.TaskNotification('已暂停', '文件: {filename} 已暂停'),
progressBar: true, progressBar: true,
@@ -170,14 +178,15 @@ class DownloadService {
_internalIdToExternalTaskId[metaInternalId] = update.task.taskId; _internalIdToExternalTaskId[metaInternalId] = update.task.taskId;
_bdTasks[metaInternalId] = update.task as bd.DownloadTask; _bdTasks[metaInternalId] = update.task as bd.DownloadTask;
AppLogger.d( AppLogger.d(
'从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId'); '从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId',
);
} }
final resolvedInternalId = final resolvedInternalId = _externalTaskIdToInternalId[update.task.taskId];
_externalTaskIdToInternalId[update.task.taskId];
if (resolvedInternalId == null) { if (resolvedInternalId == null) {
AppLogger.d( AppLogger.d(
'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}'); 'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}',
);
return; return;
} }
@@ -201,7 +210,8 @@ class DownloadService {
} }
AppLogger.d( 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; final progress = status == DownloadStatus.completed ? 100 : 0;
_callbackHandler?.call(resolvedInternalId, status, progress); _callbackHandler?.call(resolvedInternalId, status, progress);
@@ -252,7 +262,14 @@ class DownloadService {
await file.delete(); await file.delete();
} }
return _startBdDownload(task, url, dir); final route = await CustomLineService.instance.routeDownloadUrl(url);
if (route.changed) {
AppLogger.d(
'自定义线路下载代理已应用: node=${route.nodeName}, host=${route.originalHost}, proxy=${route.proxy?.proxyUri}',
);
}
return _startBdDownload(task, route.url, dir, headers: route.headers);
} catch (e) { } catch (e) {
AppLogger.d('下载失败: $e'); AppLogger.d('下载失败: $e');
rethrow; rethrow;
@@ -261,11 +278,17 @@ class DownloadService {
/// 使用 background_downloader 开始下载 /// 使用 background_downloader 开始下载
Future<String?> _startBdDownload( Future<String?> _startBdDownload(
DownloadTaskModel task, String url, Directory dir) async { DownloadTaskModel task,
String url,
Directory dir, {
Map<String, String> headers = const {},
}) async {
await _configureCustomLineProxy();
final wifiOnly = await isWifiOnlyEnabled(); final wifiOnly = await isWifiOnlyEnabled();
final retries = await getRetries(); final retries = await getRetries();
final bdTask = bd.DownloadTask( final bdTask = bd.DownloadTask(
url: url, url: url,
headers: headers,
filename: task.fileName, filename: task.fileName,
directory: dir.path, directory: dir.path,
baseDirectory: bd.BaseDirectory.root, baseDirectory: bd.BaseDirectory.root,
@@ -291,14 +314,14 @@ class DownloadService {
task.backgroundTaskId = bdTask.taskId; task.backgroundTaskId = bdTask.taskId;
AppLogger.d( 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; return bdTask.taskId;
} }
/// 恢复下载(用于重启后恢复暂停的任务) /// 恢复下载(用于重启后恢复暂停的任务)
Future<String?> resumeDownloadAfterRestart( Future<String?> resumeDownloadAfterRestart(DownloadTaskModel task) async {
DownloadTaskModel task) async {
try { try {
if (!_isInitialized) { if (!_isInitialized) {
await initialize(); await initialize();
@@ -328,10 +351,19 @@ class DownloadService {
} }
// 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask // 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask
final route = await CustomLineService.instance.routeDownloadUrl(url);
if (route.changed) {
AppLogger.d(
'自定义线路下载代理已应用: node=${route.nodeName}, host=${route.originalHost}, proxy=${route.proxy?.proxyUri}',
);
}
await _configureCustomLineProxy();
final wifiOnly = await isWifiOnlyEnabled(); final wifiOnly = await isWifiOnlyEnabled();
final retries = await getRetries(); final retries = await getRetries();
final bdTask = bd.DownloadTask( final bdTask = bd.DownloadTask(
url: url, url: route.url,
headers: route.headers,
filename: task.fileName, filename: task.fileName,
directory: dir.path, directory: dir.path,
baseDirectory: bd.BaseDirectory.root, baseDirectory: bd.BaseDirectory.root,
@@ -349,11 +381,11 @@ class DownloadService {
task.backgroundTaskId = bdTask.taskId; task.backgroundTaskId = bdTask.taskId;
// 如果有已下载的部分,尝试 resume;否则 enqueue // 如果有已下载的部分,尝试 resume;否则 enqueue
final partialFile = final partialFile = File('${dir.path}/${task.fileName}.part');
File('${dir.path}/${task.fileName}.part');
if (task.downloadedBytes > 0 && await partialFile.exists()) { if (task.downloadedBytes > 0 && await partialFile.exists()) {
AppLogger.d( AppLogger.d(
'断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes'); '断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes',
);
await bd.FileDownloader().resume(bdTask); await bd.FileDownloader().resume(bdTask);
} else { } else {
AppLogger.d('重新下载: ${task.fileName}'); AppLogger.d('重新下载: ${task.fileName}');
@@ -370,6 +402,14 @@ class DownloadService {
} }
} }
Future<void> _configureCustomLineProxy() async {
final endpoint = CustomLineService.instance.currentProxyEndpoint;
final config = endpoint == null ? false : (endpoint.host, endpoint.port);
await bd.FileDownloader().configure(
globalConfig: (bd.Config.proxy, config),
);
}
/// 暂停下载 /// 暂停下载
Future<void> pauseDownload(String taskId) async { Future<void> pauseDownload(String taskId) async {
final bdTask = _bdTasks[taskId]; final bdTask = _bdTasks[taskId];
+16
View File
@@ -1,6 +1,7 @@
import 'api_service.dart'; import 'api_service.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
import '../core/utils/file_utils.dart'; import '../core/utils/file_utils.dart';
import 'custom_line_service.dart';
/// 文件服务 /// 文件服务
class FileService { class FileService {
@@ -156,10 +157,25 @@ class FileService {
data: data, data: data,
headers: headers, headers: headers,
); );
await _activateCustomLineForUrls(response);
return response; return response;
} }
Future<void> _activateCustomLineForUrls(Map<String, dynamic> response) async {
final urls = response['urls'];
if (urls is! List) return;
final resolvedUrls = <String>[];
for (final item in urls) {
if (item is! Map) continue;
final url = item['url']?.toString();
if (url == null || url.isEmpty) continue;
resolvedUrls.add(url);
}
await CustomLineService.instance.activateSelectedNodeForUrls(resolvedUrls);
}
/// 创建直接链接(分享链接) /// 创建直接链接(分享链接)
Future<List<Map<String, dynamic>>> createDirectLinks({ Future<List<Map<String, dynamic>>> createDirectLinks({
required List<String> uris, required List<String> uris,
@@ -0,0 +1,314 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import '../core/utils/app_logger.dart';
class HostMappingProxyService {
HostMappingProxyService._();
static final HostMappingProxyService instance = HostMappingProxyService._();
ServerSocket? _server;
HostMappingProxyEndpoint? _endpoint;
Map<String, String> _hostMap = const {};
HostMappingProxyEndpoint? get endpoint => _hostMap.isEmpty ? null : _endpoint;
Future<HostMappingProxyEndpoint?> configure(
Map<String, String> hostMap,
) async {
final cleaned = <String, String>{};
for (final entry in hostMap.entries) {
final host = _normalizeHost(entry.key);
final ip = entry.value.trim();
if (host.isNotEmpty && ip.isNotEmpty) {
cleaned[host] = ip;
}
}
if (cleaned.isEmpty) {
await stop();
return null;
}
_hostMap = cleaned;
if (_server == null) {
_server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
_endpoint = HostMappingProxyEndpoint(
host: InternetAddress.loopbackIPv4.address,
port: _server!.port,
);
_server!.listen(
_handleClient,
onError: (e) {
AppLogger.d('自定义线路本地代理监听错误: $e');
},
);
AppLogger.d('自定义线路本地代理已启动: ${_endpoint!.proxyUri}');
}
return _endpoint;
}
Future<void> stop() async {
_hostMap = const {};
final server = _server;
_server = null;
_endpoint = null;
if (server != null) {
await server.close();
AppLogger.d('自定义线路本地代理已关闭');
}
}
void _handleClient(Socket client) {
unawaited(_serveClient(client));
}
Future<void> _serveClient(Socket client) async {
Socket? remote;
StreamController<List<int>>? rest;
try {
client.setOption(SocketOption.tcpNoDelay, true);
final initial = await _readInitialRequest(client);
rest = initial.rest;
final lines = latin1.decode(initial.header).split('\r\n');
if (lines.isEmpty || lines.first.trim().isEmpty) {
throw const FormatException('empty proxy request');
}
final first = lines.first.split(' ');
if (first.length < 3) {
throw FormatException('invalid proxy request: ${lines.first}');
}
final method = first[0].toUpperCase();
if (method == 'CONNECT') {
final target = _splitAuthority(first[1], 443);
remote = await _connectMapped(target.host, target.port);
client.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
await client.flush();
} else {
final target = _httpTarget(first[1], lines);
remote = await _connectMapped(target.host, target.port);
remote.add(_rewriteHttpHeader(initial.header, target.requestTarget));
}
remote.setOption(SocketOption.tcpNoDelay, true);
unawaited(rest.stream.pipe(remote).catchError((_) {}));
unawaited(remote.cast<List<int>>().pipe(client).catchError((_) {}));
} catch (e) {
AppLogger.d('自定义线路本地代理请求失败: $e');
try {
client.add(utf8.encode('HTTP/1.1 502 Bad Gateway\r\n\r\n'));
await client.flush();
} catch (_) {}
await rest?.close();
remote?.destroy();
client.destroy();
}
}
Future<_ProxyInitialRequest> _readInitialRequest(Socket socket) {
final completer = Completer<_ProxyInitialRequest>();
final builder = BytesBuilder(copy: false);
final rest = StreamController<List<int>>(sync: true);
late StreamSubscription<Uint8List> subscription;
subscription = socket.listen(
(chunk) {
if (completer.isCompleted) {
rest.add(chunk);
return;
}
builder.add(chunk);
final bytes = builder.toBytes();
final end = _headerEnd(bytes);
if (end >= 0) {
final headerEnd = end + 4;
completer.complete(
_ProxyInitialRequest(
header: bytes.sublist(0, headerEnd),
rest: rest,
),
);
if (bytes.length > headerEnd) {
rest.add(bytes.sublist(headerEnd));
}
} else if (bytes.length > 64 * 1024) {
completer.completeError(
const FormatException('proxy request header too large'),
);
unawaited(subscription.cancel());
unawaited(rest.close());
}
},
onError: (Object error, StackTrace stackTrace) {
if (!completer.isCompleted) {
completer.completeError(error, stackTrace);
} else {
rest.addError(error, stackTrace);
}
},
onDone: () {
if (!completer.isCompleted) {
completer.completeError(const SocketException('proxy client closed'));
}
unawaited(rest.close());
},
cancelOnError: false,
);
return completer.future;
}
int _headerEnd(List<int> bytes) {
for (var i = 0; i <= bytes.length - 4; i++) {
if (bytes[i] == 13 &&
bytes[i + 1] == 10 &&
bytes[i + 2] == 13 &&
bytes[i + 3] == 10) {
return i;
}
}
return -1;
}
Future<Socket> _connectMapped(String host, int port) {
final normalized = _normalizeHost(host);
final targetHost = _hostMap[normalized] ?? host;
return Socket.connect(
targetHost,
port,
timeout: const Duration(seconds: 12),
);
}
_ProxyTarget _httpTarget(String target, List<String> lines) {
final uri = Uri.tryParse(target);
if (uri != null && uri.hasScheme && uri.host.isNotEmpty) {
return _ProxyTarget(
host: uri.host,
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? 443 : 80),
requestTarget: _originForm(uri),
);
}
final hostHeader = _headerValue(lines, 'Host');
final authority = _splitAuthority(hostHeader, 80);
return _ProxyTarget(
host: authority.host,
port: authority.port,
requestTarget: target,
);
}
List<int> _rewriteHttpHeader(List<int> header, String requestTarget) {
final text = latin1.decode(header);
final lines = text.split('\r\n');
final first = lines.first.split(' ');
first[1] = requestTarget.isEmpty ? '/' : requestTarget;
lines[0] = first.join(' ');
final filtered = lines
.where((line) => !line.toLowerCase().startsWith('proxy-connection:'))
.join('\r\n');
return latin1.encode(filtered);
}
String _originForm(Uri uri) {
final path = uri.path.isEmpty ? '/' : uri.path;
return uri.hasQuery ? '$path?${uri.query}' : path;
}
String _headerValue(List<String> lines, String name) {
final prefix = '${name.toLowerCase()}:';
for (final line in lines.skip(1)) {
if (line.toLowerCase().startsWith(prefix)) {
return line.substring(line.indexOf(':') + 1).trim();
}
}
throw FormatException('missing $name header');
}
_ProxyAuthority _splitAuthority(String value, int defaultPort) {
final authority = value.trim();
if (authority.startsWith('[')) {
final end = authority.indexOf(']');
if (end > 0) {
final host = authority.substring(1, end);
final port = authority.length > end + 2
? int.tryParse(authority.substring(end + 2)) ?? defaultPort
: defaultPort;
return _ProxyAuthority(host, port);
}
}
final colon = authority.lastIndexOf(':');
if (colon > 0 && colon < authority.length - 1) {
final port = int.tryParse(authority.substring(colon + 1));
if (port != null) {
return _ProxyAuthority(authority.substring(0, colon), port);
}
}
return _ProxyAuthority(authority, defaultPort);
}
String _normalizeHost(String host) {
final value = host.trim();
final uri = Uri.tryParse(value);
final resolved = uri != null && uri.hasScheme && uri.host.isNotEmpty
? uri.host
: value;
return resolved.toLowerCase().replaceAll(RegExp(r'\.$'), '');
}
}
class HostMappingProxyEndpoint {
final String host;
final int port;
const HostMappingProxyEndpoint({required this.host, required this.port});
String get proxyUri => 'http://$host:$port';
}
class HostMappingHttpOverrides extends HttpOverrides {
@override
HttpClient createHttpClient(SecurityContext? context) {
final client = super.createHttpClient(context);
client.findProxy = (_) {
final endpoint = HostMappingProxyService.instance.endpoint;
if (endpoint == null) return 'DIRECT';
return 'PROXY ${endpoint.host}:${endpoint.port}; DIRECT';
};
return client;
}
}
class _ProxyInitialRequest {
final Uint8List header;
final StreamController<List<int>> rest;
const _ProxyInitialRequest({required this.header, required this.rest});
}
class _ProxyAuthority {
final String host;
final int port;
const _ProxyAuthority(this.host, this.port);
}
class _ProxyTarget {
final String host;
final int port;
final String requestTarget;
const _ProxyTarget({
required this.host,
required this.port,
required this.requestTarget,
});
}
+21 -26
View File
@@ -45,12 +45,7 @@ class ServerService {
} catch (e) { } catch (e) {
AppLogger.d('加载服务器列表失败: $e'); AppLogger.d('加载服务器列表失败: $e');
// 加载失败时使用默认服务器 // 加载失败时使用默认服务器
_servers = [ _servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)];
ServerModel(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
),
];
_currentServer = _servers.first; _currentServer = _servers.first;
} }
} }
@@ -65,15 +60,10 @@ class ServerService {
} }
} }
_currentServer = (savedDefaultServer ?? _currentServer =
ServerModel( (savedDefaultServer ??
label: _defaultLabel, ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl))
baseUrl: _defaultBaseUrl, .copyWith(label: _defaultLabel, baseUrl: _defaultBaseUrl);
))
.copyWith(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
);
_servers = [_currentServer!]; _servers = [_currentServer!];
} }
@@ -91,7 +81,9 @@ class ServerService {
/// 保存上次选中的服务器 /// 保存上次选中的服务器
Future<void> _saveLastSelected() async { Future<void> _saveLastSelected() async {
if (_currentServer != null) { if (_currentServer != null) {
await StorageService.instance.setLastSelectedServerLabel(_currentServer!.label); await StorageService.instance.setLastSelectedServerLabel(
_currentServer!.label,
);
} }
} }
@@ -125,16 +117,21 @@ class ServerService {
String? password, String? password,
UserModel? user, UserModel? user,
bool? rememberMe, bool? rememberMe,
bool clearEmail = false,
bool clearPassword = false,
bool clearUser = false,
}) async { }) async {
if (_currentServer == null) { if (_currentServer == null) {
throw Exception('没有选中的服务器'); throw Exception('没有选中的服务器');
} }
_currentServer = _currentServer!.copyWith( _currentServer = ServerModel(
email: email, label: _currentServer!.label,
password: password, baseUrl: _currentServer!.baseUrl,
user: user,
rememberMe: rememberMe ?? _currentServer!.rememberMe, rememberMe: rememberMe ?? _currentServer!.rememberMe,
email: clearEmail ? null : (email ?? _currentServer!.email),
password: clearPassword ? null : (password ?? _currentServer!.password),
user: clearUser ? null : (user ?? _currentServer!.user),
); );
// 更新列表中的引用 // 更新列表中的引用
@@ -152,17 +149,15 @@ class ServerService {
email: null, email: null,
password: null, password: null,
user: null, user: null,
clearEmail: true,
clearPassword: true,
clearUser: true,
); );
} }
/// 重置为默认服务器列表 /// 重置为默认服务器列表
Future<void> resetToDefault() async { Future<void> resetToDefault() async {
_servers = [ _servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)];
ServerModel(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
),
];
_currentServer = _servers.first; _currentServer = _servers.first;
await _saveServers(); await _saveServers();
await _saveLastSelected(); await _saveLastSelected();
+27 -4
View File
@@ -81,11 +81,13 @@ class StorageService {
/// 设置 /// 设置
Future<String?> get themeMode => getString(StorageKeys.themeMode); Future<String?> get themeMode => getString(StorageKeys.themeMode);
Future<bool> setThemeMode(String value) => setString(StorageKeys.themeMode, value); Future<bool> setThemeMode(String value) =>
setString(StorageKeys.themeMode, value);
/// 服务器地址配置 /// 服务器地址配置
Future<String?> get customBaseUrl => getString(StorageKeys.customBaseUrl); Future<String?> get customBaseUrl => getString(StorageKeys.customBaseUrl);
Future<bool> setCustomBaseUrl(String? value) => setString(StorageKeys.customBaseUrl, value); Future<bool> setCustomBaseUrl(String? value) =>
setString(StorageKeys.customBaseUrl, value);
Future<bool> removeCustomBaseUrl() => remove(StorageKeys.customBaseUrl); Future<bool> removeCustomBaseUrl() => remove(StorageKeys.customBaseUrl);
/// 服务器列表 /// 服务器列表
@@ -115,8 +117,10 @@ class StorageService {
} }
/// 上次选中的服务器 label /// 上次选中的服务器 label
Future<String?> get lastSelectedServerLabel => getString(StorageKeys.lastSelectedServer); Future<String?> get lastSelectedServerLabel =>
Future<bool> setLastSelectedServerLabel(String? value) => setString(StorageKeys.lastSelectedServer, value); getString(StorageKeys.lastSelectedServer);
Future<bool> setLastSelectedServerLabel(String? value) =>
setString(StorageKeys.lastSelectedServer, value);
/// 搜索历史(最新在前,最多 20 条) /// 搜索历史(最新在前,最多 20 条)
Future<List<String>> getSearchHistory() async { Future<List<String>> getSearchHistory() async {
@@ -188,6 +192,25 @@ class StorageService {
Future<void> clearSyncData() async { Future<void> clearSyncData() async {
await remove(StorageKeys.syncConfig); await remove(StorageKeys.syncConfig);
await remove(StorageKeys.syncState); await remove(StorageKeys.syncState);
await remove(StorageKeys.syncCumStats);
}
/// 保存同步累积统计
Future<bool> setSyncCumStats(Map<String, int> stats) async {
final json = jsonEncode(stats);
return await setString(StorageKeys.syncCumStats, json);
}
/// 读取同步累积统计
Future<Map<String, int>?> getSyncCumStats() async {
final json = await getString(StorageKeys.syncCumStats);
if (json == null) return null;
try {
final map = jsonDecode(json) as Map<String, dynamic>;
return map.map((k, v) => MapEntry(k, v as int));
} catch (_) {
return null;
}
} }
// ===== client_id 持久化 ===== // ===== client_id 持久化 =====
+102 -21
View File
@@ -14,6 +14,7 @@ class SyncService {
SyncService._(); SyncService._();
bool _initialized = false; bool _initialized = false;
StreamSubscription<ffi_types.SyncEventFfi>? _rustEventSub;
/// 事件流,供 SyncProvider 订阅 /// 事件流,供 SyncProvider 订阅
final _eventController = StreamController<SyncEventModel>.broadcast(); final _eventController = StreamController<SyncEventModel>.broadcast();
@@ -22,17 +23,22 @@ class SyncService {
/// 初始化同步引擎(已初始化时更新配置) /// 初始化同步引擎(已初始化时更新配置)
Future<void> init(SyncConfigModel config) async { Future<void> init(SyncConfigModel config) async {
if (_initialized) { 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()); await ffi.updateSyncConfig(config: config.toFfi());
AppLogger.d('[FFI] ← updateSyncConfig: ok'); AppLogger.d('[FFI] ← updateSyncConfig: ok');
return; 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()); await ffi.initSyncEngine(config: config.toFfi());
_initialized = true; _initialized = true;
_subscribeRustEvents();
AppLogger.d('[FFI] ← initSyncEngine: ok'); AppLogger.d('[FFI] ← initSyncEngine: ok');
} }
@@ -40,7 +46,9 @@ class SyncService {
Future<SyncSummaryModel> startInitialSync() async { Future<SyncSummaryModel> startInitialSync() async {
AppLogger.d('[FFI] → startInitialSync'); AppLogger.d('[FFI] → startInitialSync');
final summary = await 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); return SyncSummaryModel.fromFfi(summary);
} }
@@ -76,7 +84,9 @@ class SyncService {
Future<SyncSummaryModel> forceSync() async { Future<SyncSummaryModel> forceSync() async {
AppLogger.d('[FFI] → forceSync'); AppLogger.d('[FFI] → forceSync');
final summary = await 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); return SyncSummaryModel.fromFfi(summary);
} }
@@ -90,7 +100,9 @@ class SyncService {
/// 更新同步配置(推送到 Rust 引擎,引擎未初始化时忽略) /// 更新同步配置(推送到 Rust 引擎,引擎未初始化时忽略)
Future<void> updateConfig(SyncConfigModel config) async { Future<void> updateConfig(SyncConfigModel config) async {
if (!_initialized) return; 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 { try {
await ffi.updateSyncConfig(config: config.toFfi()); await ffi.updateSyncConfig(config: config.toFfi());
AppLogger.d('[FFI] ← updateSyncConfig: ok'); AppLogger.d('[FFI] ← updateSyncConfig: ok');
@@ -104,7 +116,9 @@ class SyncService {
/// 获取同步状态快照(轮询高频调用,trace 级别) /// 获取同步状态快照(轮询高频调用,trace 级别)
Future<SyncStatusModel> getStatus() async { Future<SyncStatusModel> getStatus() async {
final status = await ffi.getSyncStatus(); 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); return SyncStatusModel.fromFfi(status);
} }
@@ -119,7 +133,9 @@ class SyncService {
Future<List<SyncTaskModel>> getActiveTasksTyped() async { Future<List<SyncTaskModel>> getActiveTasksTyped() async {
final tasks = await ffi.getActiveTasks(); final tasks = await ffi.getActiveTasks();
AppLogger.t('[FFI] ← getActiveTasks: count=${tasks.length}'); AppLogger.t('[FFI] ← getActiveTasks: count=${tasks.length}');
return tasks.map((t) => SyncTaskModel( return tasks
.map(
(t) => SyncTaskModel(
id: t.id, id: t.id,
trigger: t.trigger, trigger: t.trigger,
totalCount: t.totalCount, totalCount: t.totalCount,
@@ -129,7 +145,9 @@ class SyncService {
createdAt: t.createdAt, createdAt: t.createdAt,
updatedAt: t.updatedAt, updatedAt: t.updatedAt,
finishedAt: t.finishedAt, finishedAt: t.finishedAt,
)).toList(); ),
)
.toList();
} }
/// 获取最近同步任务列表(轮询高频调用,trace 级别) /// 获取最近同步任务列表(轮询高频调用,trace 级别)
@@ -137,7 +155,9 @@ class SyncService {
AppLogger.t('[FFI] → getRecentTasks: limit=$limit'); AppLogger.t('[FFI] → getRecentTasks: limit=$limit');
final tasks = await ffi.getRecentTasks(limit: limit); final tasks = await ffi.getRecentTasks(limit: limit);
AppLogger.t('[FFI] ← getRecentTasks: count=${tasks.length}'); AppLogger.t('[FFI] ← getRecentTasks: count=${tasks.length}');
return tasks.map((t) => SyncTaskModel( return tasks
.map(
(t) => SyncTaskModel(
id: t.id, id: t.id,
trigger: t.trigger, trigger: t.trigger,
totalCount: t.totalCount, totalCount: t.totalCount,
@@ -147,7 +167,9 @@ class SyncService {
createdAt: t.createdAt, createdAt: t.createdAt,
updatedAt: t.updatedAt, updatedAt: t.updatedAt,
finishedAt: t.finishedAt, finishedAt: t.finishedAt,
)).toList(); ),
)
.toList();
} }
/// 获取任务详情(按需查询,trace 级别) /// 获取任务详情(按需查询,trace 级别)
@@ -155,7 +177,9 @@ class SyncService {
AppLogger.t('[FFI] → getTaskDetail: taskId=$taskId'); AppLogger.t('[FFI] → getTaskDetail: taskId=$taskId');
final items = await ffi.getTaskDetail(taskId: taskId); final items = await ffi.getTaskDetail(taskId: taskId);
AppLogger.t('[FFI] ← getTaskDetail: count=${items.length}'); AppLogger.t('[FFI] ← getTaskDetail: count=${items.length}');
return items.map((i) => SyncTaskItemModel( return items
.map(
(i) => SyncTaskItemModel(
id: i.id.toInt(), id: i.id.toInt(),
taskId: i.taskId, taskId: i.taskId,
relativePath: i.relativePath, relativePath: i.relativePath,
@@ -165,7 +189,9 @@ class SyncService {
errorMessage: i.errorMessage, errorMessage: i.errorMessage,
createdAt: i.createdAt, createdAt: i.createdAt,
updatedAt: i.updatedAt, updatedAt: i.updatedAt,
)).toList(); ),
)
.toList();
} }
/// 分页查询任务详情(trace 级别) /// 分页查询任务详情(trace 级别)
@@ -174,14 +200,20 @@ class SyncService {
int limit = 20, int limit = 20,
int offset = 0, int offset = 0,
}) async { }) async {
AppLogger.t('[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset'); AppLogger.t(
final items = await ffi.queryTaskItems(filter: ffi_types.TaskItemFilterFfi( '[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset',
);
final items = await ffi.queryTaskItems(
filter: ffi_types.TaskItemFilterFfi(
taskId: taskId, taskId: taskId,
limit: limit, limit: limit,
offset: offset, offset: offset,
)); ),
);
AppLogger.t('[FFI] ← queryTaskItems: count=${items.length}'); AppLogger.t('[FFI] ← queryTaskItems: count=${items.length}');
return items.map((i) => SyncTaskItemModel( return items
.map(
(i) => SyncTaskItemModel(
id: i.id.toInt(), id: i.id.toInt(),
taskId: i.taskId, taskId: i.taskId,
relativePath: i.relativePath, relativePath: i.relativePath,
@@ -191,7 +223,29 @@ class SyncService {
errorMessage: i.errorMessage, errorMessage: i.errorMessage,
createdAt: i.createdAt, createdAt: i.createdAt,
updatedAt: i.updatedAt, updatedAt: i.updatedAt,
)).toList(); ),
)
.toList();
}
/// 从 DB 聚合累积统计(轮询高频调用,trace 级别)
Future<Map<String, int>> 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 级别 ========== // ========== 以下为低频操作,保持 debug 级别 ==========
@@ -207,12 +261,16 @@ class SyncService {
Future<Map<String, dynamic>> checkCloudAlbumDirs(String baseUri) async { Future<Map<String, dynamic>> checkCloudAlbumDirs(String baseUri) async {
AppLogger.d('[FFI] → checkCloudAlbumDirs: uri=$baseUri'); AppLogger.d('[FFI] → checkCloudAlbumDirs: uri=$baseUri');
final result = await ffi.checkCloudAlbumDirs(baseUri: 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 { return {
'dcimExists': result.dcimExists, 'dcimExists': result.dcimExists,
'picturesExists': result.picturesExists, 'picturesExists': result.picturesExists,
'dcimUri': result.dcimUri, 'dcimUri': result.dcimUri,
'picturesUri': result.picturesUri, 'picturesUri': result.picturesUri,
'cameraExists': result.cameraExists,
'cameraUri': result.cameraUri,
}; };
} }
@@ -226,12 +284,35 @@ class SyncService {
/// 销毁同步引擎 /// 销毁同步引擎
Future<void> dispose() async { Future<void> dispose() async {
AppLogger.d('[FFI] → disposeSyncEngine'); AppLogger.d('[FFI] → disposeSyncEngine');
_rustEventSub?.cancel();
_rustEventSub = null;
await _eventController.close(); await _eventController.close();
await ffi.disposeSyncEngine(); await ffi.disposeSyncEngine();
_initialized = false; _initialized = false;
AppLogger.d('[FFI] ← disposeSyncEngine: ok'); 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<void> setLogLevel(String level) async { Future<void> setLogLevel(String level) async {
AppLogger.d('[FFI] → setSyncLogLevel: level=$level'); AppLogger.d('[FFI] → setSyncLogLevel: level=$level');
@@ -240,9 +321,9 @@ class SyncService {
} }
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 /// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
Future<void> resetSync() async { Future<void> resetSync({bool deleteLocalFiles = true}) async {
AppLogger.d('[FFI] → resetSync'); AppLogger.d('[FFI] → resetSync: deleteLocalFiles=$deleteLocalFiles');
await ffi.resetSync(); await ffi.resetSync(deleteLocalFiles: deleteLocalFiles);
AppLogger.d('[FFI] ← resetSync: ok'); AppLogger.d('[FFI] ← resetSync: ok');
} }
} }
+15 -3
View File
@@ -2,6 +2,7 @@ import 'api_service.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
import '../core/utils/file_utils.dart'; import '../core/utils/file_utils.dart';
import '../core/utils/time_flow_decoder.dart'; import '../core/utils/time_flow_decoder.dart';
import 'custom_line_service.dart';
/// 缩略图缓存条目 /// 缩略图缓存条目
class _ThumbCacheEntry { class _ThumbCacheEntry {
@@ -41,6 +42,9 @@ class ThumbnailService {
// 1. 检查内存缓存 // 1. 检查内存缓存
final cached = _urlCache[cacheKey]; final cached = _urlCache[cacheKey];
if (cached != null && !cached.isExpired) { if (cached != null && !cached.isExpired) {
await CustomLineService.instance.activateSelectedNodeForUrl(
cached.imageUrl,
);
return cached.imageUrl; return cached.imageUrl;
} }
if (cached != null) { if (cached != null) {
@@ -64,7 +68,10 @@ class ThumbnailService {
} }
} }
Future<String?> _fetchThumbnailUrl(String fileUri, String? contextHint) async { Future<String?> _fetchThumbnailUrl(
String fileUri,
String? contextHint,
) async {
try { try {
final uri = FileUtils.toCloudreveUri(fileUri); final uri = FileUtils.toCloudreveUri(fileUri);
final headers = contextHint != null final headers = contextHint != null
@@ -97,12 +104,15 @@ class ThumbnailService {
if (url.isEmpty) return null; if (url.isEmpty) return null;
AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url'); AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url');
await CustomLineService.instance.activateSelectedNodeForUrl(url);
// 解析过期时间,缓存提前 30 秒过期 // 解析过期时间,缓存提前 30 秒过期
DateTime expiresAt; DateTime expiresAt;
if (expiresStr != null) { if (expiresStr != null) {
try { try {
expiresAt = DateTime.parse(expiresStr).subtract(const Duration(seconds: 30)); expiresAt = DateTime.parse(
expiresStr,
).subtract(const Duration(seconds: 30));
} catch (_) { } catch (_) {
expiresAt = DateTime.now().add(const Duration(minutes: 5)); expiresAt = DateTime.now().add(const Duration(minutes: 5));
} }
@@ -118,7 +128,9 @@ class ThumbnailService {
return url; return url;
} catch (e) { } catch (e) {
AppLogger.d('ThumbnailService: failed to get thumbnail URL for $fileUri: $e'); AppLogger.d(
'ThumbnailService: failed to get thumbnail URL for $fileUri: $e',
);
return null; return null;
} }
} }
+78 -12
View File
@@ -9,11 +9,22 @@ import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
import '../core/constants/storage_keys.dart';
import '../core/utils/app_logger.dart'; import '../core/utils/app_logger.dart';
import '../data/models/app_update_model.dart'; import '../data/models/app_update_model.dart';
import 'storage_service.dart';
class UpdateService { class UpdateService extends ChangeNotifier {
UpdateService._(); UpdateService._()
: _dio = Dio(_dioOptions),
_packageInfoProvider = PackageInfo.fromPlatform;
@visibleForTesting
UpdateService.test({
Dio? dio,
Future<PackageInfo> Function()? packageInfoProvider,
}) : _dio = dio ?? Dio(_dioOptions),
_packageInfoProvider = packageInfoProvider ?? PackageInfo.fromPlatform;
static final UpdateService instance = UpdateService._(); static final UpdateService instance = UpdateService._();
@@ -23,28 +34,39 @@ class UpdateService {
static final Uri releasesPageUrl = Uri.parse( static final Uri releasesPageUrl = Uri.parse(
'https://git.saont.net/gongyun/app/releases', 'https://git.saont.net/gongyun/app/releases',
); );
static final Uri downloadsPageUrl = Uri.parse(
'https://www.gongyun.org/downloads.html',
);
final Dio _dio = Dio( static final BaseOptions _dioOptions = BaseOptions(
BaseOptions(
connectTimeout: const Duration(seconds: 12), connectTimeout: const Duration(seconds: 12),
receiveTimeout: const Duration(seconds: 20), receiveTimeout: const Duration(seconds: 20),
followRedirects: true, followRedirects: true,
responseType: ResponseType.plain, responseType: ResponseType.plain,
),
); );
final Dio _dio;
final Future<PackageInfo> Function() _packageInfoProvider;
bool _checking = false; bool _checking = false;
AppUpdateInfo? _availableUpdate;
AppUpdateInfo? get availableUpdate => _availableUpdate;
bool get hasUpdate => _availableUpdate != null;
Future<AppUpdateInfo?> checkForUpdate() async { Future<AppUpdateInfo?> checkForUpdate() async {
if (_checking) return null; if (_checking) return null;
if (!_supportsAutoDownload) return null;
_checking = true; _checking = true;
try { try {
final current = await PackageInfo.fromPlatform(); final current = await _packageInfoProvider();
final latest = await _fetchLatestRelease(currentVersion: current.version); final latest = await _fetchLatestRelease(currentVersion: current.version);
if (latest == null) return null; if (latest == null ||
if (_compareVersions(latest.version, current.version) <= 0) return null; _compareVersions(latest.version, current.version) <= 0) {
_setAvailableUpdate(null);
return null;
}
_setAvailableUpdate(latest);
return latest; return latest;
} catch (e, stackTrace) { } catch (e, stackTrace) {
AppLogger.e('检查更新失败: $e\n$stackTrace'); AppLogger.e('检查更新失败: $e\n$stackTrace');
@@ -54,6 +76,34 @@ class UpdateService {
} }
} }
Future<bool> shouldShowPrompt(AppUpdateInfo update) async {
final storage = StorageService.instance;
final skipVersion = await storage.getString(
StorageKeys.updatePromptSkipVersion,
);
final skipUntil = await storage.getInt(StorageKeys.updatePromptSkipUntil);
if (skipVersion != update.version || skipUntil == null) return true;
final now = DateTime.now().millisecondsSinceEpoch;
if (skipUntil > now) return false;
await storage.remove(StorageKeys.updatePromptSkipUntil);
await storage.remove(StorageKeys.updatePromptSkipVersion);
return true;
}
Future<void> skipPromptForFiveDays(AppUpdateInfo update) async {
final skipUntil = DateTime.now()
.add(const Duration(days: 5))
.millisecondsSinceEpoch;
final storage = StorageService.instance;
await storage.setString(
StorageKeys.updatePromptSkipVersion,
update.version,
);
await storage.setInt(StorageKeys.updatePromptSkipUntil, skipUntil);
}
Future<File?> downloadUpdate( Future<File?> downloadUpdate(
AppUpdateInfo update, { AppUpdateInfo update, {
void Function(int received, int total)? onProgress, void Function(int received, int total)? onProgress,
@@ -96,6 +146,15 @@ class UpdateService {
} }
} }
Future<void> openDownloadsPage() async {
if (!await launchUrl(
downloadsPageUrl,
mode: LaunchMode.externalApplication,
)) {
throw Exception('无法打开下载页面: $downloadsPageUrl');
}
}
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async { Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
final response = await _dio.getUri<String>(releasesRssUrl); final response = await _dio.getUri<String>(releasesRssUrl);
final body = response.data; final body = response.data;
@@ -119,7 +178,6 @@ class UpdateService {
final update = await _buildUpdateInfo(release); final update = await _buildUpdateInfo(release);
if (update == null) continue; if (update == null) continue;
if (update.downloadUrl == null) continue;
return update; return update;
} }
@@ -206,8 +264,6 @@ class UpdateService {
return null; return null;
} }
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
bool _isReleaseAttachment(Uri uri) { bool _isReleaseAttachment(Uri uri) {
return uri.path.contains('/releases/download/'); return uri.path.contains('/releases/download/');
} }
@@ -345,6 +401,16 @@ class UpdateService {
return Uri.decodeComponent(segment); return Uri.decodeComponent(segment);
} }
void _setAvailableUpdate(AppUpdateInfo? update) {
final previous = _availableUpdate;
if (previous?.version == update?.version &&
previous?.pageUrl == update?.pageUrl) {
return;
}
_availableUpdate = update;
notifyListeners();
}
@visibleForTesting @visibleForTesting
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease(); Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
} }
+9 -2
View File
@@ -43,8 +43,11 @@ Future<void> resumeSync() => RustSyncApi.instance.api.crateApiFfiResumeSync();
Future<SyncSummaryFfi> forceSync() => Future<SyncSummaryFfi> forceSync() =>
RustSyncApi.instance.api.crateApiFfiForceSync(); RustSyncApi.instance.api.crateApiFfiForceSync();
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 /// 重置同步:停止任务 → 清空 DB → (可选)清空本地目录 → 回到初始状态
Future<void> resetSync() => RustSyncApi.instance.api.crateApiFfiResetSync(); Future<void> resetSync({required bool deleteLocalFiles}) => RustSyncApi
.instance
.api
.crateApiFfiResetSync(deleteLocalFiles: deleteLocalFiles);
/// 获取同步状态快照 /// 获取同步状态快照
Future<SyncStatusFfi> getSyncStatus() => Future<SyncStatusFfi> getSyncStatus() =>
@@ -112,3 +115,7 @@ Future<List<SyncTaskItemFfi>> getTaskDetail({required String taskId}) =>
Future<List<SyncTaskItemFfi>> queryTaskItems({ Future<List<SyncTaskItemFfi>> queryTaskItems({
required TaskItemFilterFfi filter, required TaskItemFilterFfi filter,
}) => RustSyncApi.instance.api.crateApiFfiQueryTaskItems(filter: filter); }) => RustSyncApi.instance.api.crateApiFfiQueryTaskItems(filter: filter);
/// 获取累积统计(从 DB 聚合,跨所有同步任务)
Future<SyncCumStatsFfi> getSyncCumStats() =>
RustSyncApi.instance.api.crateApiFfiGetSyncCumStats();
+63 -3
View File
@@ -8,7 +8,7 @@ import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:freezed_annotation/freezed_annotation.dart' hide protected; import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'ffi_types.freezed.dart'; 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: 云端相册目录检查结果 /// Android: 云端相册目录检查结果
class CloudAlbumCheckResultFfi { class CloudAlbumCheckResultFfi {
@@ -16,12 +16,16 @@ class CloudAlbumCheckResultFfi {
final bool picturesExists; final bool picturesExists;
final String? dcimUri; final String? dcimUri;
final String? picturesUri; final String? picturesUri;
final bool cameraExists;
final String? cameraUri;
const CloudAlbumCheckResultFfi({ const CloudAlbumCheckResultFfi({
required this.dcimExists, required this.dcimExists,
required this.picturesExists, required this.picturesExists,
this.dcimUri, this.dcimUri,
this.picturesUri, this.picturesUri,
required this.cameraExists,
this.cameraUri,
}); });
@override @override
@@ -29,7 +33,9 @@ class CloudAlbumCheckResultFfi {
dcimExists.hashCode ^ dcimExists.hashCode ^
picturesExists.hashCode ^ picturesExists.hashCode ^
dcimUri.hashCode ^ dcimUri.hashCode ^
picturesUri.hashCode; picturesUri.hashCode ^
cameraExists.hashCode ^
cameraUri.hashCode;
@override @override
bool operator ==(Object other) => bool operator ==(Object other) =>
@@ -39,7 +45,9 @@ class CloudAlbumCheckResultFfi {
dcimExists == other.dcimExists && dcimExists == other.dcimExists &&
picturesExists == other.picturesExists && picturesExists == other.picturesExists &&
dcimUri == other.dcimUri && dcimUri == other.dcimUri &&
picturesUri == other.picturesUri; picturesUri == other.picturesUri &&
cameraExists == other.cameraExists &&
cameraUri == other.cameraUri;
} }
/// 同步配置 /// 同步配置
@@ -118,6 +126,58 @@ class SyncConfigFfi {
logLevel == other.logLevel; 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 @freezed
sealed class SyncErrorFfi with _$SyncErrorFfi implements FrbException { sealed class SyncErrorFfi with _$SyncErrorFfi implements FrbException {
const SyncErrorFfi._(); const SyncErrorFfi._();
+126 -25
View File
@@ -67,7 +67,7 @@ class RustSyncApi
String get codegenVersion => '2.12.0'; String get codegenVersion => '2.12.0';
@override @override
int get rustContentHash => 264517153; int get rustContentHash => 1656309947;
static const kDefaultExternalLibraryLoaderConfig = static const kDefaultExternalLibraryLoaderConfig =
ExternalLibraryLoaderConfig( ExternalLibraryLoaderConfig(
@@ -97,6 +97,8 @@ abstract class RustSyncApiApi extends BaseApi {
Future<SyncConfigFfi> crateApiFfiGetSyncConfig(); Future<SyncConfigFfi> crateApiFfiGetSyncConfig();
Future<SyncCumStatsFfi> crateApiFfiGetSyncCumStats();
Future<SyncStatusFfi> crateApiFfiGetSyncStatus(); Future<SyncStatusFfi> crateApiFfiGetSyncStatus();
Future<List<SyncTaskItemFfi>> crateApiFfiGetTaskDetail({ Future<List<SyncTaskItemFfi>> crateApiFfiGetTaskDetail({
@@ -115,7 +117,7 @@ abstract class RustSyncApiApi extends BaseApi {
Stream<SyncEventFfi> crateApiFfiRegisterSyncEventSink(); Stream<SyncEventFfi> crateApiFfiRegisterSyncEventSink();
Future<void> crateApiFfiResetSync(); Future<void> crateApiFfiResetSync({required bool deleteLocalFiles});
Future<void> crateApiFfiResumeSync(); Future<void> crateApiFfiResumeSync();
@@ -376,7 +378,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
const TaskConstMeta(debugName: "get_sync_config", argNames: []); const TaskConstMeta(debugName: "get_sync_config", argNames: []);
@override @override
Future<SyncStatusFfi> crateApiFfiGetSyncStatus() { Future<SyncCumStatsFfi> crateApiFfiGetSyncCumStats() {
return handler.executeNormal( return handler.executeNormal(
NormalTask( NormalTask(
callFfi: (port_) { callFfi: (port_) {
@@ -388,6 +390,33 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
port: port_, 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<SyncStatusFfi> crateApiFfiGetSyncStatus() {
return handler.executeNormal(
NormalTask(
callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding);
pdeCallFfi(
generalizedFrbRustBinding,
serializer,
funcId: 10,
port: port_,
);
},
codec: SseCodec( codec: SseCodec(
decodeSuccessData: sse_decode_sync_status_ffi, decodeSuccessData: sse_decode_sync_status_ffi,
decodeErrorData: sse_decode_sync_error_ffi, decodeErrorData: sse_decode_sync_error_ffi,
@@ -414,7 +443,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 10, funcId: 11,
port: port_, port: port_,
); );
}, },
@@ -442,7 +471,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 11, funcId: 12,
port: port_, port: port_,
); );
}, },
@@ -470,7 +499,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 12, funcId: 13,
port: port_, port: port_,
); );
}, },
@@ -497,7 +526,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 13, funcId: 14,
port: port_, port: port_,
); );
}, },
@@ -527,7 +556,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 14, funcId: 15,
port: port_, port: port_,
); );
}, },
@@ -557,7 +586,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 15, funcId: 16,
port: port_, port: port_,
); );
}, },
@@ -581,15 +610,16 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
); );
@override @override
Future<void> crateApiFfiResetSync() { Future<void> crateApiFfiResetSync({required bool deleteLocalFiles}) {
return handler.executeNormal( return handler.executeNormal(
NormalTask( NormalTask(
callFfi: (port_) { callFfi: (port_) {
final serializer = SseSerializer(generalizedFrbRustBinding); final serializer = SseSerializer(generalizedFrbRustBinding);
sse_encode_bool(deleteLocalFiles, serializer);
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 16, funcId: 17,
port: port_, port: port_,
); );
}, },
@@ -598,14 +628,16 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
decodeErrorData: sse_decode_sync_error_ffi, decodeErrorData: sse_decode_sync_error_ffi,
), ),
constMeta: kCrateApiFfiResetSyncConstMeta, constMeta: kCrateApiFfiResetSyncConstMeta,
argValues: [], argValues: [deleteLocalFiles],
apiImpl: this, apiImpl: this,
), ),
); );
} }
TaskConstMeta get kCrateApiFfiResetSyncConstMeta => TaskConstMeta get kCrateApiFfiResetSyncConstMeta => const TaskConstMeta(
const TaskConstMeta(debugName: "reset_sync", argNames: []); debugName: "reset_sync",
argNames: ["deleteLocalFiles"],
);
@override @override
Future<void> crateApiFfiResumeSync() { Future<void> crateApiFfiResumeSync() {
@@ -616,7 +648,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 17, funcId: 18,
port: port_, port: port_,
); );
}, },
@@ -644,7 +676,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 18, funcId: 19,
port: port_, port: port_,
); );
}, },
@@ -671,7 +703,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 19, funcId: 20,
port: port_, port: port_,
); );
}, },
@@ -698,7 +730,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 20, funcId: 21,
port: port_, port: port_,
); );
}, },
@@ -725,7 +757,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 21, funcId: 22,
port: port_, port: port_,
); );
}, },
@@ -757,7 +789,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 22, funcId: 23,
port: port_, port: port_,
); );
}, },
@@ -787,7 +819,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 23, funcId: 24,
port: port_, port: port_,
); );
}, },
@@ -815,7 +847,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 24, funcId: 25,
port: port_, port: port_,
); );
}, },
@@ -846,7 +878,7 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
pdeCallFfi( pdeCallFfi(
generalizedFrbRustBinding, generalizedFrbRustBinding,
serializer, serializer,
funcId: 25, funcId: 26,
port: port_, port: port_,
); );
}, },
@@ -916,13 +948,15 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
) { ) {
// Codec=Dco (DartCObject based), see doc to use other codecs // Codec=Dco (DartCObject based), see doc to use other codecs
final arr = raw as List<dynamic>; final arr = raw as List<dynamic>;
if (arr.length != 4) if (arr.length != 6)
throw Exception('unexpected arr length: expect 4 but see ${arr.length}'); throw Exception('unexpected arr length: expect 6 but see ${arr.length}');
return CloudAlbumCheckResultFfi( return CloudAlbumCheckResultFfi(
dcimExists: dco_decode_bool(arr[0]), dcimExists: dco_decode_bool(arr[0]),
picturesExists: dco_decode_bool(arr[1]), picturesExists: dco_decode_bool(arr[1]),
dcimUri: dco_decode_opt_String(arr[2]), dcimUri: dco_decode_opt_String(arr[2]),
picturesUri: dco_decode_opt_String(arr[3]), 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<dynamic>;
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 @protected
SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw) { SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw) {
// Codec=Dco (DartCObject based), see doc to use other codecs // 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_picturesExists = sse_decode_bool(deserializer);
var var_dcimUri = sse_decode_opt_String(deserializer); var var_dcimUri = sse_decode_opt_String(deserializer);
var var_picturesUri = 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( return CloudAlbumCheckResultFfi(
dcimExists: var_dcimExists, dcimExists: var_dcimExists,
picturesExists: var_picturesExists, picturesExists: var_picturesExists,
dcimUri: var_dcimUri, dcimUri: var_dcimUri,
picturesUri: var_picturesUri, 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 @protected
SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer) { SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // 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_bool(self.picturesExists, serializer);
sse_encode_opt_String(self.dcimUri, serializer); sse_encode_opt_String(self.dcimUri, serializer);
sse_encode_opt_String(self.picturesUri, serializer); sse_encode_opt_String(self.picturesUri, serializer);
sse_encode_bool(self.cameraExists, serializer);
sse_encode_opt_String(self.cameraUri, serializer);
} }
@protected @protected
@@ -1822,6 +1906,23 @@ class RustSyncApiApiImpl extends RustSyncApiApiImplPlatform
sse_encode_String(self.logLevel, serializer); 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 @protected
void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer) { void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer) {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
+12
View File
@@ -66,6 +66,9 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
@protected @protected
SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw); SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw);
@protected
SyncCumStatsFfi dco_decode_sync_cum_stats_ffi(dynamic raw);
@protected @protected
SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw); SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw);
@@ -156,6 +159,9 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
@protected @protected
SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer); SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer);
@protected
SyncCumStatsFfi sse_decode_sync_cum_stats_ffi(SseDeserializer deserializer);
@protected @protected
SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer); SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer);
@@ -266,6 +272,12 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
@protected @protected
void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer); void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_cum_stats_ffi(
SyncCumStatsFfi self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer); void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer);
+12
View File
@@ -68,6 +68,9 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
@protected @protected
SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw); SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw);
@protected
SyncCumStatsFfi dco_decode_sync_cum_stats_ffi(dynamic raw);
@protected @protected
SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw); SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw);
@@ -158,6 +161,9 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
@protected @protected
SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer); SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer);
@protected
SyncCumStatsFfi sse_decode_sync_cum_stats_ffi(SseDeserializer deserializer);
@protected @protected
SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer); SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer);
@@ -268,6 +274,12 @@ abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
@protected @protected
void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer); void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer);
@protected
void sse_encode_sync_cum_stats_ffi(
SyncCumStatsFfi self,
SseSerializer serializer,
);
@protected @protected
void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer); void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer);
+1
View File
@@ -149,6 +149,7 @@ else()
COMMAND ${CARGO_CMD} build ${CARGO_PROFILE} COMMAND ${CARGO_CMD} build ${CARGO_PROFILE}
--manifest-path "${SYNC_CORE_DIR}/Cargo.toml" --manifest-path "${SYNC_CORE_DIR}/Cargo.toml"
--target-dir "${RUST_TARGET_DIR}" --target-dir "${RUST_TARGET_DIR}"
--features sync-core/linux-fuse,sync-core/event_sink_enabled
COMMENT "Building Rust sync engine..." COMMENT "Building Rust sync engine..."
) )
+65
View File
@@ -379,6 +379,16 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" 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]] [[package]]
name = "fallible-iterator" name = "fallible-iterator"
version = "0.3.0" version = "0.3.0"
@@ -482,6 +492,22 @@ dependencies = [
"libc", "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]] [[package]]
name = "futures" name = "futures"
version = "0.3.32" version = "0.3.32"
@@ -1194,6 +1220,18 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "notify" name = "notify"
version = "7.0.0" version = "7.0.0"
@@ -1316,6 +1354,16 @@ dependencies = [
"log", "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]] [[package]]
name = "parking_lot" name = "parking_lot"
version = "0.12.5" version = "0.12.5"
@@ -1821,6 +1869,16 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" 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]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@@ -1870,9 +1928,11 @@ dependencies = [
name = "sync-android" name = "sync-android"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"android_logger",
"anyhow", "anyhow",
"async-trait", "async-trait",
"jni", "jni",
"log",
"thiserror 2.0.18", "thiserror 2.0.18",
"tokio", "tokio",
"tracing", "tracing",
@@ -1882,6 +1942,7 @@ dependencies = [
name = "sync-core" name = "sync-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"android_logger",
"anyhow", "anyhow",
"async-trait", "async-trait",
"bytes", "bytes",
@@ -1889,10 +1950,13 @@ dependencies = [
"dashmap 6.1.0", "dashmap 6.1.0",
"filetime", "filetime",
"flutter_rust_bridge", "flutter_rust_bridge",
"fuser",
"futures", "futures",
"futures-util", "futures-util",
"hex", "hex",
"jni", "jni",
"libc",
"log",
"mime_guess", "mime_guess",
"notify-debouncer-full", "notify-debouncer-full",
"once_cell", "once_cell",
@@ -2060,6 +2124,7 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
+1 -1
View File
@@ -1,5 +1,5 @@
rust_input: crate::api rust_input: crate::api
rust_root: sync-core/ 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_entrypoint_class_name: RustSyncApi
dart_enums_style: true dart_enums_style: true
+7
View File
@@ -14,3 +14,10 @@ tracing = "0.1"
anyhow = { workspace = true } anyhow = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
async-trait = "0.1" async-trait = "0.1"
# 通用日志接口
log = "0.4"
# 仅在 Android 平台下生效的日志后端
[target.'cfg(target_os = "android")'.dependencies]
android_logger = "0.15"
+6 -1
View File
@@ -11,7 +11,7 @@ crate-type = ["cdylib", "lib"]
flutter_rust_bridge = "=2.12.0" flutter_rust_bridge = "=2.12.0"
# 异步运行时 # 异步运行时
tokio = { workspace = true } tokio = { workspace = true, features = ["signal"] }
# HTTP # HTTP
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false } 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] [target.'cfg(target_os = "linux")'.dependencies]
sync-linux = { path = "../sync-linux", optional = true } 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] [target.'cfg(target_os = "android")'.dependencies]
sync-android = { path = "../sync-android", optional = true } sync-android = { path = "../sync-android", optional = true }
jni = { version = "0.21", optional = true } jni = { version = "0.21", optional = true }
android_logger = "0.15"
log = "0.4"
[features] [features]
default = [] default = []
windows-cfapi = ["sync-windows", "windows"] windows-cfapi = ["sync-windows", "windows"]
linux-notify = ["sync-linux"] linux-notify = ["sync-linux"]
linux-fuse = ["fuser", "libc"]
android-media = ["sync-android", "jni"] android-media = ["sync-android", "jni"]
event_sink_enabled = [] event_sink_enabled = []
+267 -41
View File
@@ -4,6 +4,75 @@ use std::sync::Arc;
use crate::api::ffi_types::*; use crate::api::ffi_types::*;
use crate::sync_engine::SyncEngine; 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<S> Layer<S> 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<Arc<SyncEngine>> = once_cell::sync::OnceCell::new(); static ENGINE: once_cell::sync::OnceCell<Arc<SyncEngine>> = 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() { let sync_mode = match ffi.sync_mode.as_str() {
"upload_only" => SyncMode::UploadOnly, "upload_only" => SyncMode::UploadOnly,
"download_only" => SyncMode::DownloadOnly, "download_only" => SyncMode::DownloadOnly,
"album" => SyncMode::Album, "album_upload" => SyncMode::AlbumUpload,
"album_download" => SyncMode::AlbumDownload,
"mirror_wcf" => SyncMode::MirrorWcf, "mirror_wcf" => SyncMode::MirrorWcf,
_ => SyncMode::Full, _ => SyncMode::Full,
}; };
@@ -86,7 +156,8 @@ fn config_to_ffi(c: &crate::models::SyncConfig) -> SyncConfigFfi {
SyncMode::Full => "full", SyncMode::Full => "full",
SyncMode::UploadOnly => "upload_only", SyncMode::UploadOnly => "upload_only",
SyncMode::DownloadOnly => "download_only", SyncMode::DownloadOnly => "download_only",
SyncMode::Album => "album", SyncMode::AlbumUpload => "album_upload",
SyncMode::AlbumDownload => "album_download",
SyncMode::MirrorWcf => "mirror_wcf", SyncMode::MirrorWcf => "mirror_wcf",
}; };
@@ -174,12 +245,17 @@ fn album_result_to_ffi(r: crate::models::CloudAlbumCheckResult) -> CloudAlbumChe
pictures_exists: r.pictures_exists, pictures_exists: r.pictures_exists,
dcim_uri: r.dcim_uri, dcim_uri: r.dcim_uri,
pictures_uri: r.pictures_uri, pictures_uri: r.pictures_uri,
camera_exists: r.camera_exists,
camera_uri: r.camera_uri,
} }
} }
/// 获取引擎引用,未初始化则返回错误 /// 获取引擎引用,未初始化则返回错误
fn get_engine() -> Result<&'static SyncEngine, SyncErrorFfi> { 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 /// 内部:应用日志级别到 reload handle
@@ -244,6 +320,10 @@ pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi>
eprintln!("[sync-core] 警告: 无法创建日志文件 {}", log_path.display()); eprintln!("[sync-core] 警告: 无法创建日志文件 {}", log_path.display());
} }
// Android: 初始化 Logcat 日志后端(tracing → log → android_logger → Logcat
#[cfg(target_os = "android")]
android_log::init_android_logger();
// 尝试初始化 subscriber(仅首次有效,后续调用忽略) // 尝试初始化 subscriber(仅首次有效,后续调用忽略)
{ {
use tracing_subscriber::layer::SubscriberExt; 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); 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 { if let Some(file) = log_file {
let _ = registry let _ = registry
.with(tracing_subscriber::fmt::layer() .with(
tracing_subscriber::fmt::layer()
.with_writer(std::sync::Mutex::new(file)) .with_writer(std::sync::Mutex::new(file))
.with_ansi(false)) .with_ansi(false),
.with(tracing_subscriber::fmt::layer() )
.with_writer(std::io::stderr)) .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.try_init(); .try_init();
} else { } else {
let _ = registry let _ = registry
.with(tracing_subscriber::fmt::layer() .with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.with_writer(std::io::stderr))
.try_init(); .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_bandwidth = config.bandwidth_limit_kbps;
let log_level = config.log_level.clone(); 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)?; .map_err(error_to_ffi)?;
ENGINE.set(Arc::new(engine)) ENGINE
.set(Arc::new(engine))
.map_err(|_| SyncErrorFfi::InternalError { .map_err(|_| SyncErrorFfi::InternalError {
message: "引擎已初始化".to_string(), message: "引擎已初始化".to_string(),
})?; })?;
@@ -292,7 +378,10 @@ pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi>
tracing::info!("同步引擎初始化完成, 日志文件: {}", log_path.display()); tracing::info!("同步引擎初始化完成, 日志文件: {}", log_path.display());
tracing::info!( tracing::info!(
"配置: 模式={}, 冲突策略={}, 并发={}, 带宽限制={}kbps", "配置: 模式={}, 冲突策略={}, 并发={}, 带宽限制={}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 { if log_bandwidth > 0 {
tracing::info!("仅对下载限速生效, 由于Cloudreve实现原因, 上传限速无法生效"); tracing::info!("仅对下载限速生效, 由于Cloudreve实现原因, 上传限速无法生效");
@@ -301,6 +390,41 @@ pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi>
// 应用配置中的日志级别(热修改覆盖默认 debug) // 应用配置中的日志级别(热修改覆盖默认 debug)
apply_log_level(&log_level); 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(()) Ok(())
} }
@@ -316,11 +440,16 @@ pub async fn dispose_sync_engine() -> Result<(), SyncErrorFfi> {
engine.cleanup_wcf(); engine.cleanup_wcf();
} }
#[cfg(feature = "linux-fuse")]
{
engine.cleanup_fuse();
}
tracing::info!("同步引擎已停止"); tracing::info!("同步引擎已停止");
Ok(()) Ok(())
} }
/// 进程退出前同步清理(WCF 模式下必须调用,确保占位符释放) /// 进程退出前同步清理(WCF/FUSE 模式下必须调用,确保占位符释放和挂载点卸载
/// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用 /// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用
#[frb] #[frb]
pub fn sync_shutdown() -> Result<(), SyncErrorFfi> { pub fn sync_shutdown() -> Result<(), SyncErrorFfi> {
@@ -333,6 +462,14 @@ pub fn sync_shutdown() -> Result<(), SyncErrorFfi> {
}; };
engine.cleanup_wcf(); engine.cleanup_wcf();
} }
#[cfg(feature = "linux-fuse")]
{
let engine = match ENGINE.get() {
Some(e) => e,
None => return Ok(()),
};
engine.cleanup_fuse();
}
tracing::info!("同步引擎已同步清理"); tracing::info!("同步引擎已同步清理");
Ok(()) Ok(())
} }
@@ -345,10 +482,17 @@ pub async fn start_initial_sync() -> Result<SyncSummaryFfi, SyncErrorFfi> {
tracing::debug!("[FFI] start_initial_sync ←"); tracing::debug!("[FFI] start_initial_sync ←");
let engine = get_engine()?; let engine = get_engine()?;
engine.ensure_token_fresh(); engine.ensure_token_fresh();
engine.run_initial_sync().await engine
.run_initial_sync()
.await
.map(|s| { .map(|s| {
tracing::debug!("[FFI] start_initial_sync → uploaded={}, downloaded={}, conflicts={}, failed={}", tracing::debug!(
s.uploaded, s.downloaded, s.conflicts, s.failed); "[FFI] start_initial_sync → uploaded={}, downloaded={}, conflicts={}, failed={}",
s.uploaded,
s.downloaded,
s.conflicts,
s.failed
);
summary_to_ffi(s) summary_to_ffi(s)
}) })
.map_err(error_to_ffi) .map_err(error_to_ffi)
@@ -398,21 +542,34 @@ pub async fn resume_sync() -> Result<(), SyncErrorFfi> {
pub async fn force_sync() -> Result<SyncSummaryFfi, SyncErrorFfi> { pub async fn force_sync() -> Result<SyncSummaryFfi, SyncErrorFfi> {
tracing::debug!("[FFI] force_sync ←"); tracing::debug!("[FFI] force_sync ←");
let engine = get_engine()?; let engine = get_engine()?;
engine.force_sync().await engine
.force_sync()
.await
.map(|s| { .map(|s| {
tracing::debug!("[FFI] force_sync → uploaded={}, downloaded={}, conflicts={}, failed={}", tracing::debug!(
s.uploaded, s.downloaded, s.conflicts, s.failed); "[FFI] force_sync → uploaded={}, downloaded={}, conflicts={}, failed={}",
s.uploaded,
s.downloaded,
s.conflicts,
s.failed
);
summary_to_ffi(s) summary_to_ffi(s)
}) })
.map_err(error_to_ffi) .map_err(error_to_ffi)
} }
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态 /// 重置同步:停止任务 → 清空 DB → (可选)清空本地目录 → 回到初始状态
#[frb] #[frb]
pub async fn reset_sync() -> Result<(), SyncErrorFfi> { pub async fn reset_sync(delete_local_files: bool) -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] reset_sync ←"); tracing::debug!(
"[FFI] reset_sync ← delete_local_files={}",
delete_local_files
);
let engine = get_engine()?; 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] #[frb]
pub async fn get_sync_status() -> Result<SyncStatusFfi, SyncErrorFfi> { pub async fn get_sync_status() -> Result<SyncStatusFfi, SyncErrorFfi> {
let engine = get_engine()?; let engine = get_engine()?;
let s = engine.status(); let s = engine.status().await;
tracing::trace!("[FFI] get_sync_status → state={:?}, synced={}, total={}", s.state, s.synced_files, s.total_files); tracing::trace!(
"[FFI] get_sync_status → state={:?}, synced={}, total={}",
s.state,
s.synced_files,
s.total_files
);
Ok(status_to_ffi(s)) Ok(status_to_ffi(s))
} }
@@ -440,7 +602,11 @@ pub async fn get_active_worker_count() -> Result<u32, SyncErrorFfi> {
pub async fn get_sync_config() -> Result<SyncConfigFfi, SyncErrorFfi> { pub async fn get_sync_config() -> Result<SyncConfigFfi, SyncErrorFfi> {
let engine = get_engine()?; let engine = get_engine()?;
let c = engine.config().await; 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)) 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", 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); config.sync_mode, config.conflict_strategy, config.wcf_delete_mode, config.max_concurrent_transfers, config.bandwidth_limit_kbps);
let engine = get_engine()?; 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 管理 ========== // ========== Token 管理 ==========
@@ -482,19 +651,34 @@ pub async fn sync_album_to_cloud(
album_paths: Vec<String>, album_paths: Vec<String>,
remote_dcim_uri: String, remote_dcim_uri: String,
) -> Result<(), SyncErrorFfi> { ) -> 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()?; 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 目录 /// 检查云端是否存在 DCIM/Pictures 目录
#[frb] #[frb]
pub async fn check_cloud_album_dirs(base_uri: String) -> Result<CloudAlbumCheckResultFfi, SyncErrorFfi> { pub async fn check_cloud_album_dirs(
base_uri: String,
) -> Result<CloudAlbumCheckResultFfi, SyncErrorFfi> {
tracing::debug!("[FFI] check_cloud_album_dirs ← uri={}", base_uri); tracing::debug!("[FFI] check_cloud_album_dirs ← uri={}", base_uri);
let engine = get_engine()?; let engine = get_engine()?;
engine.check_album_dirs(&base_uri).await engine
.check_album_dirs(&base_uri)
.await
.map(|r| { .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) album_result_to_ffi(r)
}) })
.map_err(error_to_ffi) .map_err(error_to_ffi)
@@ -505,18 +689,26 @@ pub async fn check_cloud_album_dirs(base_uri: String) -> Result<CloudAlbumCheckR
pub async fn create_cloud_album_dirs(base_uri: String) -> Result<(), SyncErrorFfi> { pub async fn create_cloud_album_dirs(base_uri: String) -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] create_cloud_album_dirs ← uri={}", base_uri); tracing::debug!("[FFI] create_cloud_album_dirs ← uri={}", base_uri);
let engine = get_engine()?; 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 事件推送通道 /// 注册 Rust→Dart 事件推送通道
#[frb] #[frb]
pub fn register_sync_event_sink(sink: crate::frb_generated::StreamSink<SyncEventFfi>) -> Result<(), SyncErrorFfi> { pub fn register_sync_event_sink(
sink: crate::frb_generated::StreamSink<SyncEventFfi>,
) -> Result<(), SyncErrorFfi> {
tracing::debug!("[FFI] register_sync_event_sink ←"); tracing::debug!("[FFI] register_sync_event_sink ←");
let engine = get_engine()?; let engine = get_engine()?;
// 使用 tokio runtime 注册 // flutter_rust_bridge 可能在非 Tokio 线程调用此同步函数,
let rt = tokio::runtime::Handle::current(); // 使用 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)); rt.block_on(engine.register_event_sink(sink));
Ok(()) Ok(())
} }
@@ -569,16 +761,26 @@ pub async fn get_recent_tasks(limit: u32) -> Result<Vec<SyncTaskFfi>, SyncErrorF
pub async fn get_task_detail(task_id: String) -> Result<Vec<SyncTaskItemFfi>, SyncErrorFfi> { pub async fn get_task_detail(task_id: String) -> Result<Vec<SyncTaskItemFfi>, SyncErrorFfi> {
tracing::trace!("[FFI] get_task_detail ← task_id={}", task_id); tracing::trace!("[FFI] get_task_detail ← task_id={}", task_id);
let engine = get_engine()?; 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()); tracing::trace!("[FFI] get_task_detail → count={}", items.len());
Ok(items.into_iter().map(task_item_to_ffi).collect()) Ok(items.into_iter().map(task_item_to_ffi).collect())
} }
/// 多维度查询任务项 /// 多维度查询任务项
#[frb] #[frb]
pub async fn query_task_items(filter: TaskItemFilterFfi) -> Result<Vec<SyncTaskItemFfi>, SyncErrorFfi> { pub async fn query_task_items(
tracing::trace!("[FFI] query_task_items ← task_id={:?}, action={:?}, status={:?}, limit={}", filter: TaskItemFilterFfi,
filter.task_id, filter.action_type, filter.status, filter.limit); ) -> Result<Vec<SyncTaskItemFfi>, 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 engine = get_engine()?;
let model_filter = crate::models::TaskItemFilter { let model_filter = crate::models::TaskItemFilter {
task_id: filter.task_id, task_id: filter.task_id,
@@ -588,11 +790,35 @@ pub async fn query_task_items(filter: TaskItemFilterFfi) -> Result<Vec<SyncTaskI
limit: filter.limit.max(1).min(1000), limit: filter.limit.max(1).min(1000),
offset: filter.offset, offset: filter.offset,
}; };
let items = engine.query_task_items(&model_filter).await.map_err(error_to_ffi)?; let items = engine
.query_task_items(&model_filter)
.await
.map_err(error_to_ffi)?;
tracing::trace!("[FFI] query_task_items → count={}", items.len()); tracing::trace!("[FFI] query_task_items → count={}", items.len());
Ok(items.into_iter().map(task_item_to_ffi).collect()) Ok(items.into_iter().map(task_item_to_ffi).collect())
} }
/// 获取累积统计(从 DB 聚合,跨所有同步任务)
#[frb]
pub async fn get_sync_cum_stats() -> Result<SyncCumStatsFfi, SyncErrorFfi> {
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 { fn task_to_ffi(t: crate::models::SyncTask) -> SyncTaskFfi {
SyncTaskFfi { SyncTaskFfi {
id: t.id, id: t.id,
+25 -3
View File
@@ -61,7 +61,9 @@ pub struct SyncSummaryFfi {
/// 同步事件(Rust → Dart 推送) /// 同步事件(Rust → Dart 推送)
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum SyncEventFfi { pub enum SyncEventFfi {
StateChanged { new_state: String }, StateChanged {
new_state: String,
},
Progress { Progress {
synced: u64, synced: u64,
total: u64, total: u64,
@@ -84,8 +86,12 @@ pub enum SyncEventFfi {
recoverable: bool, recoverable: bool,
}, },
TokenExpired, TokenExpired,
DiskSpaceWarning { available_mb: u64 }, DiskSpaceWarning {
InitialSyncComplete { summary: SyncSummaryFfi }, available_mb: u64,
},
InitialSyncComplete {
summary: SyncSummaryFfi,
},
// Worker 事件 // Worker 事件
WorkerStarted { WorkerStarted {
@@ -122,6 +128,8 @@ pub struct CloudAlbumCheckResultFfi {
pub pictures_exists: bool, pub pictures_exists: bool,
pub dcim_uri: Option<String>, pub dcim_uri: Option<String>,
pub pictures_uri: Option<String>, pub pictures_uri: Option<String>,
pub camera_exists: bool,
pub camera_uri: Option<String>,
} }
/// 同步任务摘要(FFI /// 同步任务摘要(FFI
@@ -152,6 +160,20 @@ pub struct SyncTaskItemFfi {
pub updated_at: String, 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 /// 任务项查询过滤器(FFI
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct TaskItemFilterFfi { pub struct TaskItemFilterFfi {
+207 -16
View File
@@ -1,5 +1,6 @@
use crate::errors::{Result, SyncError}; use crate::errors::{Result, SyncError};
use crate::models::*; use crate::models::*;
use crate::server_error_code::api_code_to_error;
use reqwest::Client; use reqwest::Client;
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
@@ -154,12 +155,11 @@ impl ApiClient {
return Err(SyncError::Network(format!("HTTP {}", resp.status()))); return Err(SyncError::Network(format!("HTTP {}", resp.status())));
} }
let api_resp: ApiResponse<serde_json::Value> = resp.json().await?; let api_resp: ApiResponse<serde_json::Value> = resp.json().await?;
if api_resp.code == 401 { if api_resp.code == 0 {
return Err(SyncError::Auth("Login required".into())); return Ok(api_resp.data.unwrap_or_default());
}
if api_resp.code == 40004 {
return Err(SyncError::ObjectExisted);
} }
// 40073 锁冲突需要特殊处理 data
if api_resp.code == 40073 { if api_resp.code == 40073 {
let items = api_resp.data let items = api_resp.data
.and_then(|d| d.as_array().cloned()) .and_then(|d| d.as_array().cloned())
@@ -177,12 +177,11 @@ impl ApiClient {
.collect(); .collect();
return Err(SyncError::LockConflict { tokens: items }); return Err(SyncError::LockConflict { tokens: items });
} }
if api_resp.code != 0 {
return Err(SyncError::Network( let msg = api_resp.msg
api_resp.msg.unwrap_or_else(|| format!("错误码: {}", api_resp.code)), .filter(|m| !m.is_empty())
)); .unwrap_or_default();
} Err(api_code_to_error(api_resp.code, &msg))
Ok(api_resp.data.unwrap_or_default())
} }
/// 发送带认证的请求,自动处理 401(刷新 token 后重试一次) /// 发送带认证的请求,自动处理 401(刷新 token 后重试一次)
@@ -196,9 +195,22 @@ impl ApiClient {
// 第一次尝试 // 第一次尝试
let token = self.token().await; let token = self.token().await;
let resp = request_builder(token) let resp = match request_builder(token)
.header("X-Cr-Client-Id", &client_id) .header("X-Cr-Client-Id", &client_id)
.send().await?; .send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(
"请求发送失败: kind={:?}, url={:?}, error={}",
e.is_connect(),
e.url().map(|u| u.as_str()),
e
);
return Err(e.into());
}
};
let result = self.parse_response(resp).await; let result = self.parse_response(resp).await;
if let Err(SyncError::Auth(_)) = result { if let Err(SyncError::Auth(_)) = result {
@@ -206,9 +218,22 @@ impl ApiClient {
self.refresh_access_token().await?; self.refresh_access_token().await?;
// 用新 token 重试 // 用新 token 重试
let new_token = self.token().await; let new_token = self.token().await;
let resp = request_builder(new_token) let resp = match request_builder(new_token)
.header("X-Cr-Client-Id", &client_id) .header("X-Cr-Client-Id", &client_id)
.send().await?; .send()
.await
{
Ok(r) => r,
Err(e) => {
tracing::warn!(
"重试请求发送失败: kind={:?}, url={:?}, error={}",
e.is_connect(),
e.url().map(|u| u.as_str()),
e
);
return Err(e.into());
}
};
return self.parse_response(resp).await; return self.parse_response(resp).await;
} }
@@ -261,7 +286,22 @@ impl ApiClient {
} }
for dir_uri in dirs_to_recurse { for dir_uri in dirs_to_recurse {
self.list_all_files_recursive(&dir_uri, result).await?; let mut retry = 0u32;
loop {
match self.list_all_files_recursive(&dir_uri, result).await {
Ok(()) => break,
Err(e) => {
retry += 1;
if retry > 3 {
tracing::error!("递归列出目录失败,跳过: {}: {}", dir_uri, e);
break;
}
let delay = crate::utils::retry_delay_ms(retry, 2000, 30000);
tracing::warn!("递归列出目录失败 (重试 {}/3): {}: {}, {}ms后重试", retry, dir_uri, e, delay);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
}
}
} }
Ok(()) Ok(())
@@ -274,6 +314,33 @@ impl ApiClient {
page: u32, page: u32,
page_size: u32, page_size: u32,
next_page_token: Option<&str>, next_page_token: Option<&str>,
) -> Result<ListFilesResponse> {
let max_retries = 3u32;
let mut attempt = 0u32;
loop {
attempt += 1;
match self.list_files_page_inner(uri, page, page_size, next_page_token).await {
Ok(resp) => return Ok(resp),
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
Err(e) if attempt <= max_retries => {
let delay = crate::utils::retry_delay_ms(attempt, 2000, 30000);
tracing::warn!(
"列出文件失败 (重试 {}/{}): uri={}, error={}, {}ms后重试",
attempt, max_retries, uri, e, delay,
);
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
}
Err(e) => return Err(e),
}
}
}
async fn list_files_page_inner(
&self,
uri: &str,
page: u32,
page_size: u32,
next_page_token: Option<&str>,
) -> Result<ListFilesResponse> { ) -> Result<ListFilesResponse> {
let data = self.send_with_auth_retry(|token| { let data = self.send_with_auth_retry(|token| {
let mut req = self.client let mut req = self.client
@@ -389,14 +456,56 @@ impl ApiClient {
let chunk_size = data.get("chunk_size") let chunk_size = data.get("chunk_size")
.and_then(|c| c.as_u64()) .and_then(|c| c.as_u64())
.unwrap_or(10 * 1024 * 1024); .unwrap_or(10 * 1024 * 1024);
let upload_urls: Vec<String> = data.get("upload_urls")
.and_then(|u| u.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str().map(String::from)).collect())
.unwrap_or_default();
let storage_policy_type = data.get("storage_policy")
.and_then(|sp| sp.get("type"))
.and_then(|t| t.as_str())
.unwrap_or("local")
.to_string();
let callback_secret = data.get("callback_secret")
.and_then(|s| s.as_str())
.unwrap_or("")
.to_string();
let file_name = uri.rsplit('/').next().unwrap_or(uri).to_string();
tracing::info!(
"[{}] 创建上传会话: policy={}, urls={}, chunk_size={}",
file_name, storage_policy_type, upload_urls.len(), chunk_size,
);
Ok(UploadSession { Ok(UploadSession {
session_id, session_id,
chunk_size, chunk_size,
upload_urls,
storage_policy_type,
callback_secret,
file_name,
}) })
} }
pub async fn upload_chunk( pub async fn upload_chunk(
&self,
session: &UploadSession,
index: u32,
data: &[u8],
file_size: u64,
task_id: &str,
) -> Result<()> {
if let Some(url) = session.chunk_upload_url(index as usize) {
// 远程存储策略:直接上传到外部 URLOneDrive/S3/OSS 等)
self.upload_chunk_to_remote(url, data, index, file_size, session, task_id).await
} else {
// 本地存储策略:上传到 Cloudreve 服务端
self.upload_chunk_local(&session.session_id, index, data).await
}
}
/// 本地存储:上传分片到 /file/upload/{session_id}/{index}
async fn upload_chunk_local(
&self, &self,
session_id: &str, session_id: &str,
index: u32, index: u32,
@@ -417,6 +526,88 @@ impl ApiClient {
Ok(()) Ok(())
} }
/// 远程存储:上传分片到外部 URL(OneDrive/S3 等),无需 Cloudreve token
/// 必须带 Content-Range 头:bytes {start}-{end}/{total}
async fn upload_chunk_to_remote(
&self,
url: &str,
data: &[u8],
index: u32,
file_size: u64,
session: &UploadSession,
task_id: &str,
) -> Result<()> {
let chunk_size = session.chunk_size;
let file_name = &session.file_name;
let start = index as u64 * chunk_size;
let end = start + data.len() as u64 - 1;
let content_range = format!("bytes {}-{}/{}", start, end, file_size);
let content_len = data.len().to_string();
tracing::debug!("[{}][{}] 远程存储上传分片 {}: Content-Range={}", task_id, file_name, index, content_range);
let resp = self.client
.put(url)
.header("Content-Length", &content_len)
.header("Content-Range", &content_range)
.body(data.to_vec())
.timeout(std::time::Duration::from_secs(300))
.send()
.await
.map_err(|e| {
tracing::warn!("[{}][{}] 远程存储上传失败: error={}", task_id, file_name, e);
SyncError::from(e)
})?;
if !resp.status().is_success() {
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
return Err(SyncError::UploadFailed(format!(
"远程存储返回 HTTP {}: {}",
status,
body.chars().take(200).collect::<String>()
)));
}
// 202 = 分片已接收,上传未完成,继续下一个分片
// 200/201 = 上传完成,文件已创建
let status = resp.status();
if status.as_u16() == 202 {
tracing::debug!("[{}][{}] 远程存储分片 {} 已接收(202),继续上传", task_id, file_name, index);
} else if status.as_u16() == 200 || status.as_u16() == 201 {
tracing::info!("[{}][{}] 远程存储上传完成({}), 分片 {}", task_id, file_name, status, index);
}
Ok(())
}
/// 远程存储上传完成后回调 Cloudreve 服务端
/// POST /callback/{storage_policy_type}/{session_id}/{callback_secret}
pub async fn callback_upload_complete(&self, session: &UploadSession, task_id: &str) -> Result<()> {
if session.callback_secret.is_empty() {
tracing::warn!("[{}][{}] 上传回调跳过: callback_secret 为空", task_id, session.file_name);
return Ok(());
}
let url = format!(
"{}/callback/{}/{}/{}",
self.base_url,
session.storage_policy_type,
session.session_id,
session.callback_secret,
);
tracing::info!("[{}][{}] 上传完成回调: policy={}, session={}", task_id, session.file_name, session.storage_policy_type, session.session_id);
self.send_with_auth_retry(|token| {
self.client
.post(&url)
.bearer_auth(&token)
}).await?;
tracing::info!("[{}][{}] 上传完成回调成功: policy={}, session={}", task_id, session.file_name, session.storage_policy_type, session.session_id);
Ok(())
}
// ===== 下载 ===== // ===== 下载 =====
pub async fn get_download_url(&self, uris: &[&str]) -> Result<Vec<String>> { pub async fn get_download_url(&self, uris: &[&str]) -> Result<Vec<String>> {
+14 -9
View File
@@ -14,7 +14,12 @@ pub fn compute_diff(
// 构建索引: relative_path → entry(统一正斜杠) // 构建索引: relative_path → entry(统一正斜杠)
let local_map: HashMap<String, &LocalFileEntry> = local_files let local_map: HashMap<String, &LocalFileEntry> = local_files
.iter() .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(); .collect();
let remote_map: HashMap<String, &RemoteFileEntry> = remote_files let remote_map: HashMap<String, &RemoteFileEntry> = remote_files
@@ -43,9 +48,9 @@ pub fn compute_diff(
let db = db_mappings.get(path.as_str()); let db = db_mappings.get(path.as_str());
match (local, remote, db) { match (local, remote, db) {
// 本地有,远程无 → 上传(UploadOnly、FullMirrorWcf // 本地有,远程无 → 上传(UploadOnly、FullMirrorWcf、AlbumUpload
(Some(l), None, _) => { (Some(l), None, _) => {
if matches!(sync_mode, SyncMode::DownloadOnly) { if matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::AlbumDownload) {
continue; continue;
} }
if !l.is_dir && l.size == 0 { if !l.is_dir && l.size == 0 {
@@ -70,9 +75,9 @@ pub fn compute_diff(
} }
} }
// 远程有,本地无 → 下载(DownloadOnly、FullMirrorWcf // 远程有,本地无 → 下载(DownloadOnly、FullMirrorWcf、AlbumDownload
(None, Some(r), _) => { (None, Some(r), _) => {
if matches!(sync_mode, SyncMode::UploadOnly) { if matches!(sync_mode, SyncMode::UploadOnly | SyncMode::AlbumUpload) {
continue; continue;
} }
if r.is_dir { if r.is_dir {
@@ -125,8 +130,8 @@ pub fn compute_diff(
} }
} }
// 远程目录结构(UploadOnly、FullMirrorWcf // 远程目录结构(UploadOnly、FullMirrorWcf、AlbumUpload
if !matches!(sync_mode, SyncMode::DownloadOnly) { if !matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::AlbumDownload) {
for (path, local) in &local_map { for (path, local) in &local_map {
if local.is_dir && !remote_map.contains_key(path.as_str()) { if local.is_dir && !remote_map.contains_key(path.as_str()) {
plan.mkdirs_remote.push(path.clone()); 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); let conflicts = std::mem::take(&mut plan.conflicts);
for conflict in conflicts { for conflict in conflicts {
match sync_mode { match sync_mode {
SyncMode::UploadOnly | SyncMode::MirrorWcf => { SyncMode::UploadOnly | SyncMode::MirrorWcf | SyncMode::AlbumUpload => {
// 冲突一律覆盖上传(MirrorWcf 本地编辑优先) // 冲突一律覆盖上传(MirrorWcf 本地编辑优先)
let action = SyncAction { let action = SyncAction {
relative_path: conflict.relative_path.clone(), 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); plan.uploads.push(action);
} }
SyncMode::DownloadOnly => { SyncMode::DownloadOnly | SyncMode::AlbumDownload => {
// 冲突一律覆盖下载 // 冲突一律覆盖下载
let action = SyncAction { let action = SyncAction {
relative_path: conflict.relative_path.clone(), relative_path: conflict.relative_path.clone(),
+46 -1
View File
@@ -1,3 +1,4 @@
use std::error::Error as StdError;
use thiserror::Error; use thiserror::Error;
#[derive(Error, Debug)] #[derive(Error, Debug)]
@@ -14,6 +15,18 @@ pub enum SyncError {
#[error("远程文件已存在")] #[error("远程文件已存在")]
ObjectExisted, ObjectExisted,
#[error("存储策略不允许: {0}")]
StoragePolicyDenied(String),
#[error("上传失败: {0}")]
UploadFailed(String),
#[error("文件未找到: {0}")]
FileNotFound(String),
#[error("权限不足: {0}")]
PermissionDenied(String),
#[error("文件锁定冲突")] #[error("文件锁定冲突")]
LockConflict { tokens: Vec<LockConflictItem> }, LockConflict { tokens: Vec<LockConflictItem> },
@@ -56,7 +69,39 @@ impl From<r2d2::Error> for SyncError {
impl From<reqwest::Error> for SyncError { impl From<reqwest::Error> for SyncError {
fn from(e: reqwest::Error) -> Self { fn from(e: reqwest::Error) -> Self {
SyncError::Network(e.to_string()) let mut detail = String::new();
if e.is_connect() {
detail.push_str("连接失败");
} else if e.is_timeout() {
detail.push_str("请求超时");
} else if e.is_request() {
detail.push_str("请求构建失败");
} else if e.is_body() {
detail.push_str("请求体错误");
} else if e.is_decode() {
detail.push_str("响应解码失败");
} else if e.is_redirect() {
detail.push_str("重定向过多");
}
let url = e.url().map(|u| u.to_string()).unwrap_or_default();
let source = StdError::source(&e)
.map(|s| format!(": {s}"))
.unwrap_or_default();
let msg = e.to_string();
// 如果 detail 为空,用 reqwest 原始消息
if detail.is_empty() {
SyncError::Network(if url.is_empty() {
format!("{msg}{source}")
} else {
format!("{msg} [{url}]{source}")
})
} else {
SyncError::Network(if url.is_empty() {
format!("{detail}: {msg}{source}")
} else {
format!("{detail}: {msg} [{url}]{source}")
})
}
} }
} }
+133 -19
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi, default_rust_auto_opaque = RustAutoOpaqueMoi,
); );
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0"; 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 // 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::SseCodec, _, _, _>(
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( fn wire__crate__api__ffi__get_sync_status_impl(
port_: flutter_rust_bridge::for_generated::MessagePort, port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr, ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -604,11 +639,12 @@ fn wire__crate__api__ffi__reset_sync_impl(
}; };
let mut deserializer = let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message); flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_delete_local_files = <bool>::sse_decode(&mut deserializer);
deserializer.end(); deserializer.end();
move |context| async move { move |context| async move {
transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>( transform_result_sse::<_, crate::api::ffi_types::SyncErrorFfi>(
(move || async move { (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) Ok(output_ok)
})() })()
.await, .await,
@@ -981,11 +1017,15 @@ impl SseDecode for crate::api::ffi_types::CloudAlbumCheckResultFfi {
let mut var_picturesExists = <bool>::sse_decode(deserializer); let mut var_picturesExists = <bool>::sse_decode(deserializer);
let mut var_dcimUri = <Option<String>>::sse_decode(deserializer); let mut var_dcimUri = <Option<String>>::sse_decode(deserializer);
let mut var_picturesUri = <Option<String>>::sse_decode(deserializer); let mut var_picturesUri = <Option<String>>::sse_decode(deserializer);
let mut var_cameraExists = <bool>::sse_decode(deserializer);
let mut var_cameraUri = <Option<String>>::sse_decode(deserializer);
return crate::api::ffi_types::CloudAlbumCheckResultFfi { return crate::api::ffi_types::CloudAlbumCheckResultFfi {
dcim_exists: var_dcimExists, dcim_exists: var_dcimExists,
pictures_exists: var_picturesExists, pictures_exists: var_picturesExists,
dcim_uri: var_dcimUri, dcim_uri: var_dcimUri,
pictures_uri: var_picturesUri, 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 = <u32>::sse_decode(deserializer);
let mut var_downloaded = <u32>::sse_decode(deserializer);
let mut var_renamed = <u32>::sse_decode(deserializer);
let mut var_moved = <u32>::sse_decode(deserializer);
let mut var_failed = <u32>::sse_decode(deserializer);
let mut var_conflicts = <u32>::sse_decode(deserializer);
let mut var_deletedLocal = <u32>::sse_decode(deserializer);
let mut var_deletedRemote = <u32>::sse_decode(deserializer);
let mut var_skipped = <u32>::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 { impl SseDecode for crate::api::ffi_types::SyncErrorFfi {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_decode(deserializer: &mut flutter_rust_bridge::for_generated::SseDeserializer) -> Self { 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), 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), 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), 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), 9 => wire__crate__api__ffi__get_sync_cum_stats_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__ffi__get_task_detail_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__hydrate_file_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__init_sync_engine_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__pause_sync_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__query_task_items_impl(port, ptr, rust_vec_len, data_len), 14 => wire__crate__api__ffi__pause_sync_impl(port, ptr, rust_vec_len, data_len),
15 => { 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) 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__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__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__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_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__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__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_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__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_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), 26 => wire__crate__api__ffi__update_tokens_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(), _ => 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.pictures_exists.into_into_dart().into_dart(),
self.dcim_uri.into_into_dart().into_dart(), self.dcim_uri.into_into_dart().into_dart(),
self.pictures_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() .into_dart()
} }
@@ -1540,6 +1609,34 @@ impl flutter_rust_bridge::IntoIntoDart<crate::api::ffi_types::SyncConfigFfi>
} }
} }
// Codec=Dco (DartCObject based), see doc to use other codecs // 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<crate::api::ffi_types::SyncCumStatsFfi>
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 { impl flutter_rust_bridge::IntoDart for crate::api::ffi_types::SyncErrorFfi {
fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi { fn into_dart(self) -> flutter_rust_bridge::for_generated::DartAbi {
match self { match self {
@@ -1887,6 +1984,8 @@ impl SseEncode for crate::api::ffi_types::CloudAlbumCheckResultFfi {
<bool>::sse_encode(self.pictures_exists, serializer); <bool>::sse_encode(self.pictures_exists, serializer);
<Option<String>>::sse_encode(self.dcim_uri, serializer); <Option<String>>::sse_encode(self.dcim_uri, serializer);
<Option<String>>::sse_encode(self.pictures_uri, serializer); <Option<String>>::sse_encode(self.pictures_uri, serializer);
<bool>::sse_encode(self.camera_exists, serializer);
<Option<String>>::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) {
<u32>::sse_encode(self.uploaded, serializer);
<u32>::sse_encode(self.downloaded, serializer);
<u32>::sse_encode(self.renamed, serializer);
<u32>::sse_encode(self.moved, serializer);
<u32>::sse_encode(self.failed, serializer);
<u32>::sse_encode(self.conflicts, serializer);
<u32>::sse_encode(self.deleted_local, serializer);
<u32>::sse_encode(self.deleted_remote, serializer);
<u32>::sse_encode(self.skipped, serializer);
}
}
impl SseEncode for crate::api::ffi_types::SyncErrorFfi { impl SseEncode for crate::api::ffi_types::SyncErrorFfi {
// Codec=Sse (Serialization based), see doc to use other codecs // Codec=Sse (Serialization based), see doc to use other codecs
fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) { fn sse_encode(self, serializer: &mut flutter_rust_bridge::for_generated::SseSerializer) {
+32 -16
View File
@@ -1,23 +1,16 @@
#[cfg(unix)]
use std::collections::HashSet;
use crate::errors::Result; use crate::errors::Result;
use crate::models::LocalFileEntry; use crate::models::LocalFileEntry;
use crate::utils::quick_hash; use crate::utils::quick_hash;
#[cfg(unix)]
use std::collections::HashSet;
use std::path::Path; use std::path::Path;
use walkdir::WalkDir; use walkdir::WalkDir;
/// 需要跳过的文件/目录名前缀和名称 /// 需要跳过的文件/目录名前缀和名称
pub const SKIP_NAMES: &[&str] = &[ pub const SKIP_NAMES: &[&str] = &[".DS_Store", "Thumbs.db", "desktop.ini"];
".DS_Store",
"Thumbs.db",
"desktop.ini",
];
/// 需要跳过的文件扩展名(同步临时文件) /// 需要跳过的文件扩展名(同步临时文件)
pub const SKIP_EXTENSIONS: &[&str] = &[ pub const SKIP_EXTENSIONS: &[&str] = &["sync_tmp", "sync_temp"];
"sync_tmp",
"sync_temp",
];
pub struct FsScanner; 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 { if entry.path_is_symlink() && !follow_symlinks {
continue; continue;
@@ -68,6 +70,10 @@ impl FsScanner {
if SKIP_NAMES.iter().any(|s| file_name == *s) { if SKIP_NAMES.iter().any(|s| file_name == *s) {
continue; continue;
} }
// 跳过隐藏目录/文件(以 . 开头)
if file_name.starts_with('.') {
continue;
}
if file_name.starts_with(".sync_") { if file_name.starts_with(".sync_") {
continue; 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()) .unwrap_or(entry.path())
.to_path_buf(); .to_path_buf();
@@ -120,7 +128,8 @@ impl FsScanner {
}); });
} else if metadata.is_file() { } else if metadata.is_file() {
let size = metadata.len(); let size = metadata.len();
let mtime_ms = metadata.modified() let mtime_ms = metadata
.modified()
.ok() .ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64) .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) Ok(entries)
} }
} }
/// 根据文件扩展名推断 MIME 类型 /// 根据文件扩展名推断 MIME 类型
pub fn guess_mime_type(path: &Path) -> Option<String> { pub fn guess_mime_type(path: &Path) -> Option<String> {
mime_guess::from_path(path) mime_guess::from_path(path).first().map(|m| m.to_string())
.first()
.map(|m| m.to_string())
} }
+1
View File
@@ -5,6 +5,7 @@ pub mod errors;
pub mod utils; pub mod utils;
pub mod sync_db; pub mod sync_db;
pub mod api_client; pub mod api_client;
pub mod server_error_code;
pub mod fs_scanner; pub mod fs_scanner;
pub mod conflict_resolver; pub mod conflict_resolver;
pub mod transfer; pub mod transfer;
+77 -9
View File
@@ -49,7 +49,8 @@ pub enum SyncMode {
Full, Full,
UploadOnly, UploadOnly,
DownloadOnly, DownloadOnly,
Album, AlbumUpload,
AlbumDownload,
MirrorWcf, MirrorWcf,
} }
@@ -241,8 +242,14 @@ pub enum LocalFileEvent {
Created(Vec<PathBuf>), Created(Vec<PathBuf>),
Modified(Vec<PathBuf>), Modified(Vec<PathBuf>),
Deleted(Vec<PathBuf>), Deleted(Vec<PathBuf>),
Renamed { old_paths: Vec<PathBuf>, new_paths: Vec<PathBuf> }, Renamed {
Moved { old_paths: Vec<PathBuf>, new_paths: Vec<PathBuf> }, old_paths: Vec<PathBuf>,
new_paths: Vec<PathBuf>,
},
Moved {
old_paths: Vec<PathBuf>,
new_paths: Vec<PathBuf>,
},
} }
impl LocalFileEvent { impl LocalFileEvent {
@@ -262,9 +269,18 @@ impl LocalFileEvent {
pub enum RemoteFileEvent { pub enum RemoteFileEvent {
Created(RemoteFileEntry), Created(RemoteFileEntry),
Modified(RemoteFileEntry), Modified(RemoteFileEntry),
Deleted { uri: String, name: String }, Deleted {
Renamed { old_uri: String, new_entry: RemoteFileEntry }, uri: String,
Moved { old_uri: String, new_entry: RemoteFileEntry }, name: String,
},
Renamed {
old_uri: String,
new_entry: RemoteFileEntry,
},
Moved {
old_uri: String,
new_entry: RemoteFileEntry,
},
} }
// ===== 平台回调事件 (Windows CFApi) ===== // ===== 平台回调事件 (Windows CFApi) =====
@@ -283,6 +299,35 @@ pub enum PlatformCallbackEvent {
pub struct UploadSession { pub struct UploadSession {
pub session_id: String, pub session_id: String,
pub chunk_size: u64, pub chunk_size: u64,
/// 远程存储的上传 URL 列表(每个分片一个 URL)
/// 仅当 storage_policy_type != "local" 时有值
pub upload_urls: Vec<String>,
/// 存储策略类型:local / onedrive / s3 / oss / cos / etc.
pub storage_policy_type: String,
/// 回调密钥(远程存储上传完成后需要回调通知服务端)
pub callback_secret: String,
/// 上传文件名(仅用于日志标识,并发时区分不同文件)
pub file_name: String,
}
impl UploadSession {
/// 是否使用远程存储直接上传(非本地策略)
pub fn is_remote_storage(&self) -> bool {
self.storage_policy_type != "local" && !self.upload_urls.is_empty()
}
/// 获取第 index 个分片的上传 URL
/// 远程存储:返回 upload_urls[index](不足则用最后一个)
/// 本地存储:返回 None,由调用方拼接 /file/upload/{session_id}/{index}
pub fn chunk_upload_url(&self, index: usize) -> Option<&str> {
if !self.is_remote_storage() {
return None;
}
if self.upload_urls.is_empty() {
return None;
}
Some(self.upload_urls[index.min(self.upload_urls.len() - 1)].as_str())
}
} }
// ===== API 分页响应 ===== // ===== API 分页响应 =====
@@ -410,6 +455,10 @@ pub enum WorkerTrigger {
InitialSync, InitialSync,
Continuous, Continuous,
Manual, Manual,
/// WCF 按需水合
Hydration,
/// WCF 远程事件(占位符/删除/重命名/移动)
WcfEvent,
} }
impl WorkerTrigger { impl WorkerTrigger {
@@ -418,6 +467,8 @@ impl WorkerTrigger {
WorkerTrigger::InitialSync => "initial_sync", WorkerTrigger::InitialSync => "initial_sync",
WorkerTrigger::Continuous => "continuous", WorkerTrigger::Continuous => "continuous",
WorkerTrigger::Manual => "manual", WorkerTrigger::Manual => "manual",
WorkerTrigger::Hydration => "hydration",
WorkerTrigger::WcfEvent => "wcf_event",
} }
} }
} }
@@ -442,7 +493,6 @@ impl WorkerStatus {
WorkerStatus::Cancelled => "cancelled", WorkerStatus::Cancelled => "cancelled",
} }
} }
} }
impl std::str::FromStr for WorkerStatus { impl std::str::FromStr for WorkerStatus {
@@ -480,7 +530,6 @@ impl TaskItemStatus {
TaskItemStatus::Skipped => "skipped", TaskItemStatus::Skipped => "skipped",
} }
} }
} }
impl std::str::FromStr for TaskItemStatus { impl std::str::FromStr for TaskItemStatus {
@@ -511,6 +560,8 @@ pub enum TaskActionType {
MkdirLocal, MkdirLocal,
ConflictResolve, ConflictResolve,
CreatePlaceholder, CreatePlaceholder,
/// WCF 按需水合(用户打开占位符文件时触发下载)
Hydration,
} }
impl TaskActionType { impl TaskActionType {
@@ -526,9 +577,9 @@ impl TaskActionType {
TaskActionType::MkdirLocal => "mkdir_local", TaskActionType::MkdirLocal => "mkdir_local",
TaskActionType::ConflictResolve => "conflict_resolve", TaskActionType::ConflictResolve => "conflict_resolve",
TaskActionType::CreatePlaceholder => "create_placeholder", TaskActionType::CreatePlaceholder => "create_placeholder",
TaskActionType::Hydration => "hydration",
} }
} }
} }
impl std::str::FromStr for TaskActionType { impl std::str::FromStr for TaskActionType {
@@ -546,6 +597,7 @@ impl std::str::FromStr for TaskActionType {
"mkdir_local" => Ok(TaskActionType::MkdirLocal), "mkdir_local" => Ok(TaskActionType::MkdirLocal),
"conflict_resolve" => Ok(TaskActionType::ConflictResolve), "conflict_resolve" => Ok(TaskActionType::ConflictResolve),
"create_placeholder" => Ok(TaskActionType::CreatePlaceholder), "create_placeholder" => Ok(TaskActionType::CreatePlaceholder),
"hydration" => Ok(TaskActionType::Hydration),
_ => Err(()), _ => Err(()),
} }
} }
@@ -579,6 +631,20 @@ pub struct SyncTaskItem {
pub updated_at: String, 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)] #[derive(Debug, Clone, Default)]
pub struct TaskItemFilter { pub struct TaskItemFilter {
@@ -598,4 +664,6 @@ pub struct CloudAlbumCheckResult {
pub pictures_exists: bool, pub pictures_exists: bool,
pub dcim_uri: Option<String>, pub dcim_uri: Option<String>,
pub pictures_uri: Option<String>, pub pictures_uri: Option<String>,
pub camera_exists: bool,
pub camera_uri: Option<String>,
} }
File diff suppressed because it is too large Load Diff
+11 -8
View File
@@ -1,16 +1,16 @@
/// 平台适配器 trait — 各平台(Windows WCF / Linux / Android)实现此接口 #[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
#[cfg(feature = "windows-cfapi")]
use async_trait::async_trait;
#[cfg(feature = "windows-cfapi")]
use crate::errors::Result; use crate::errors::Result;
#[cfg(feature = "windows-cfapi")] #[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
use crate::models::{LocalFileEvent, RemoteFileEntry}; 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; use std::path::Path;
#[cfg(feature = "windows-cfapi")] #[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
use tokio::sync::mpsc; use tokio::sync::mpsc;
#[cfg(feature = "windows-cfapi")] #[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
#[async_trait] #[async_trait]
pub trait PlatformAdapter: Send + Sync { pub trait PlatformAdapter: Send + Sync {
/// 初始同步后的平台初始化 /// 初始同步后的平台初始化
@@ -34,3 +34,6 @@ pub trait PlatformAdapter: Send + Sync {
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
pub mod wcf; pub mod wcf;
#[cfg(feature = "linux-fuse")]
pub mod fuse;
+92
View File
@@ -0,0 +1,92 @@
use crate::errors::SyncError;
/// Cloudreve 服务端错误码 → 中文描述映射
pub fn server_code_desc(code: i32) -> Option<&'static str> {
Some(match code {
// HTTP 语义码
203 => "部分操作未成功",
401 => "未登录",
403 => "无权限访问",
404 => "资源不存在",
409 => "资源冲突",
// 4xxxx 业务码
40001 => "参数错误",
40002 => "上传失败",
40003 => "文件夹创建失败",
40004 => "对象已存在",
40005 => "签名已过期",
40006 => "当前存储策略不允许",
40007 => "用户组不允许此操作",
40008 => "需要管理员权限",
40009 => "主节点未注册",
40010 => "需要绑定手机",
40011 => "上传会话已过期",
40012 => "无效的分片序号",
40013 => "无效的 Content-Length",
40014 => "批量源大小超限",
40016 => "父目录不存在",
40017 => "用户被封禁",
40018 => "用户未激活",
40019 => "功能未启用",
40020 => "凭据无效",
40021 => "用户不存在",
40022 => "两步验证码错误",
40023 => "登录会话不存在",
40026 => "验证码错误",
40027 => "验证码需要刷新",
40035 => "存储策略不存在",
40044 => "文件未找到",
40045 => "列出文件失败",
40049 => "文件过大",
40050 => "文件类型不允许",
40051 => "用户容量不足",
40052 => "非法对象名",
40053 => "根目录受保护",
40054 => "同名文件正在上传",
40055 => "元数据不匹配",
40057 => "可用存储策略已变更",
40071 => "签名无效",
40073 => "文件锁定冲突",
40074 => "URI 数量过多",
40075 => "锁令牌已过期",
40077 => "实体不存在",
40078 => "文件在回收站中",
40079 => "文件数量已达上限",
40081 => "批量操作未完全完成",
40082 => "仅所有者可操作",
// 5xxxx 服务端内部错误
50001 => "数据库操作失败",
50002 => "加密失败",
50004 => "IO 操作失败",
50006 => "缓存操作失败",
50007 => "回调失败",
50010 => "节点离线",
_ => return None,
})
}
/// 将服务端业务错误码转为 SyncError
pub fn api_code_to_error(code: i32, msg: &str) -> SyncError {
let desc = server_code_desc(code);
let detail = if msg.is_empty() {
desc.unwrap_or("未知错误").to_string()
} else if let Some(d) = desc {
if msg != d {
format!("{}: {}", d, msg)
} else {
msg.to_string()
}
} else {
msg.to_string()
};
match code {
401 => SyncError::Auth(detail),
40004 => SyncError::ObjectExisted,
40006 | 40035 | 40057 => SyncError::StoragePolicyDenied(detail),
40002 | 40011 | 40012 | 40013 | 40054 => SyncError::UploadFailed(detail),
40044 | 40077 => SyncError::FileNotFound(detail),
40017 | 40018 | 40007 | 40008 => SyncError::PermissionDenied(detail),
_ => SyncError::Network(format!("[{}] {}", code, detail)),
}
}
+141 -9
View File
@@ -175,7 +175,9 @@ impl SyncDb {
// 添加 task_item 唯一索引(去重:保留 id 最小的记录) // 添加 task_item 唯一索引(去重:保留 id 最小的记录)
// 先清理重复数据,再建唯一索引 // 先清理重复数据,再建唯一索引
let existing: Vec<String> = conn let existing: Vec<String> = 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))? .query_map([], |r| r.get(0))?
.filter_map(|r| r.ok()) .filter_map(|r| r.ok())
.collect(); .collect();
@@ -200,7 +202,8 @@ impl SyncDb {
SyncMode::Full => "full", SyncMode::Full => "full",
SyncMode::UploadOnly => "upload_only", SyncMode::UploadOnly => "upload_only",
SyncMode::DownloadOnly => "download_only", SyncMode::DownloadOnly => "download_only",
SyncMode::Album => "album", SyncMode::AlbumUpload => "album_upload",
SyncMode::AlbumDownload => "album_download",
SyncMode::MirrorWcf => "mirror_wcf", SyncMode::MirrorWcf => "mirror_wcf",
}; };
@@ -327,12 +330,38 @@ impl SyncDb {
Ok(result) Ok(result)
} }
/// 查询所有占位符文件映射(用于 WCF 退出时清理) /// 删除指定 remote_uri 的文件映射
#[cfg(feature = "windows-cfapi")] pub async fn delete_mapping_by_remote_uri(
pub async fn list_placeholder_mappings(
&self, &self,
sync_root_id: &str, sync_root_id: &str,
) -> Result<Vec<FileMapping>> { 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<Vec<FileMapping>> {
let pool = self.read_pool.clone(); let pool = self.read_pool.clone();
let sync_root_id = sync_root_id.to_string(); let sync_root_id = sync_root_id.to_string();
@@ -345,7 +374,8 @@ impl SyncDb {
FROM file_mapping WHERE sync_root_id = ?1 AND is_placeholder = 1", FROM file_mapping WHERE sync_root_id = ?1 AND is_placeholder = 1",
)?; )?;
let mappings = stmt.query_map(rusqlite::params![sync_root_id], |row| { let mappings = stmt
.query_map(rusqlite::params![sync_root_id], |row| {
Ok(FileMapping { Ok(FileMapping {
id: row.get(0)?, id: row.get(0)?,
sync_root_id: row.get(1)?, sync_root_id: row.get(1)?,
@@ -361,10 +391,13 @@ impl SyncDb {
sync_status: parse_sync_status(&row.get::<_, String>(11)?), sync_status: parse_sync_status(&row.get::<_, String>(11)?),
is_placeholder: row.get::<_, i32>(12)? != 0, is_placeholder: row.get::<_, i32>(12)? != 0,
}) })
})?.filter_map(|m| m.ok()).collect(); })?
.filter_map(|m| m.ok())
.collect();
Ok(mappings) Ok(mappings)
}).await??; })
.await??;
Ok(result) Ok(result)
} }
@@ -727,6 +760,46 @@ impl SyncDb {
Ok(conn.last_insert_rowid()) 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(单次持锁,用事务包裹) /// 批量插入 task_item(单次持锁,用事务包裹)
pub async fn create_sync_task_items_batch(&self, items: &[SyncTaskItem]) -> Result<()> { pub async fn create_sync_task_items_batch(&self, items: &[SyncTaskItem]) -> Result<()> {
let conn = self.write_conn.lock().await; let conn = self.write_conn.lock().await;
@@ -848,6 +921,63 @@ impl SyncDb {
Ok(result) Ok(result)
} }
/// 从 DB 聚合累积统计(跨所有同步任务)
/// downloaded 包含 download + hydrationcreate_placeholder 不计入,仅重建映射非实际下载)
pub async fn get_cum_stats(&self) -> Result<SyncCumStats> {
let pool = self.read_pool.clone();
let result = tokio::task::spawn_blocking(move || -> Result<SyncCumStats> {
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 /// 清空所有同步数据(保留 sync_root 和 album_sync_record
pub async fn reset_sync_data(&self) -> Result<()> { pub async fn reset_sync_data(&self) -> Result<()> {
let conn = self.write_conn.lock().await; let conn = self.write_conn.lock().await;
@@ -933,6 +1063,8 @@ fn sync_task_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SyncTask> {
"initial_sync" => WorkerTrigger::InitialSync, "initial_sync" => WorkerTrigger::InitialSync,
"continuous" => WorkerTrigger::Continuous, "continuous" => WorkerTrigger::Continuous,
"manual" => WorkerTrigger::Manual, "manual" => WorkerTrigger::Manual,
"hydration" => WorkerTrigger::Hydration,
"wcf_event" => WorkerTrigger::WcfEvent,
_ => WorkerTrigger::Manual, _ => WorkerTrigger::Manual,
}; };
let status_str: String = row.get(5)?; let status_str: String = row.get(5)?;
+65 -10
View File
@@ -7,29 +7,54 @@ use super::SyncEngine;
impl SyncEngine { impl SyncEngine {
pub async fn sync_album(&self, album_paths: Vec<String>, remote_dcim_uri: &str) -> Result<()> { pub async fn sync_album(&self, album_paths: Vec<String>, remote_dcim_uri: &str) -> Result<()> {
let synced = self.db.get_album_sync_records().await?; 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(); let total = new_photos.len();
if total == 0 { return Ok(()); } if total == 0 {
return Ok(());
}
for (i, photo_path) in new_photos.iter().enumerate() { for (i, photo_path) in new_photos.iter().enumerate() {
let local_path = Path::new(photo_path); 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()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_else(|| format!("photo_{}", i)); .unwrap_or_else(|| format!("photo_{}", i));
match tokio::fs::metadata(photo_path).await { match tokio::fs::metadata(photo_path).await {
Ok(metadata) => { Ok(metadata) => {
let file_size = metadata.len(); 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) => { Ok(session) => {
match crate::uploader::upload_file_chunked(&self.api, local_path, &session).await { match crate::uploader::upload_file_chunked(
&self.api, local_path, &session, "album",
)
.await
{
Ok(_) => { Ok(_) => {
let remote_uri = format!("{}/{}", remote_dcim_uri, file_name); let remote_uri = format!("{}/{}", remote_dcim_uri, file_name);
let hash = crate::utils::quick_hash(local_path, file_size).await.unwrap_or_default(); let hash = crate::utils::quick_hash(local_path, file_size)
if let Err(e) = self.db.add_album_sync_record(photo_path, &remote_uri, &hash).await { .await
.unwrap_or_default();
if let Err(e) = self
.db
.add_album_sync_record(photo_path, &remote_uri, &hash)
.await
{
tracing::warn!("记录同步状态失败: {}", e); tracing::warn!("记录同步状态失败: {}", e);
} }
tracing::info!("照片上传完成 ({}/{}): {}", i + 1, total, file_name); tracing::info!(
"照片上传完成 ({}/{}): {}",
i + 1,
total,
file_name
);
} }
Err(e) => tracing::error!("上传照片失败 {}: {}", file_name, e), 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 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 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 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 { Ok(CloudAlbumCheckResult {
dcim_exists, dcim_exists,
pictures_exists, pictures_exists,
dcim_uri: if dcim_exists { Some(format!("{}/DCIM", base_uri)) } else { None }, dcim_uri,
pictures_uri: if pictures_exists { Some(format!("{}/Pictures", base_uri)) } else { None }, 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<()> { 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, "DCIM").await?;
self.api.create_directory(base_uri, "Pictures").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(()) Ok(())
} }
} }
@@ -7,27 +7,37 @@ use super::SyncEngine;
impl SyncEngine { impl SyncEngine {
/// 持续同步:双事件源驱动 (SSE + 本地文件监听),按 sync_mode 选择事件源 /// 持续同步:双事件源驱动 (SSE + 本地文件监听),按 sync_mode 选择事件源
pub async fn run_continuous(&self) -> Result<()> { pub async fn run_continuous(&self) -> Result<()> {
let event_handler = EventHandler::new( let event_handler = EventHandler::new(self.api.clone(), self.api.client_id().to_string());
self.api.clone(),
self.api.client_id().to_string(),
);
let (local_root, remote_root, sync_mode) = { let (local_root, remote_root, sync_mode) = {
let config = self.config.read().await; 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、FullMirrorWcf 订阅 SSE // 仅 DownloadOnly、FullMirrorWcf、AlbumDownload 订阅 SSE
let mut remote_rx = if matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::Full | SyncMode::MirrorWcf) { let mut remote_rx = if matches!(
sync_mode,
SyncMode::DownloadOnly | SyncMode::Full | SyncMode::MirrorWcf | SyncMode::AlbumDownload
) {
Some(event_handler.subscribe_sse(&remote_root).await?) Some(event_handler.subscribe_sse(&remote_root).await?)
} else { } else {
tracing::info!("仅上传模式: 不订阅 SSE 远程事件"); tracing::info!("仅上传模式: 不订阅 SSE 远程事件");
None None
}; };
// 仅 UploadOnly、FullMirrorWcf 启动本地文件监听 // 仅 UploadOnly、FullMirrorWcf、AlbumUpload 启动本地文件监听
let mut local_rx = if matches!(sync_mode, SyncMode::UploadOnly | SyncMode::Full | SyncMode::MirrorWcf) { let mut local_rx = if matches!(
Some(spawn_local_watcher(&local_root, self.shutdown_token.lock().unwrap().clone())) sync_mode,
SyncMode::UploadOnly | SyncMode::Full | SyncMode::MirrorWcf | SyncMode::AlbumUpload
) {
Some(spawn_local_watcher(
&local_root,
self.shutdown_token.lock().unwrap().clone(),
))
} else { } else {
tracing::info!("仅下载模式: 不启动本地文件监听"); tracing::info!("仅下载模式: 不启动本地文件监听");
None None
@@ -46,9 +56,21 @@ impl SyncEngine {
#[cfg(not(feature = "windows-cfapi"))] #[cfg(not(feature = "windows-cfapi"))]
let _wcf_fetch_rx: Option<()> = None; let _wcf_fetch_rx: Option<()> = None;
let mut debounce = crate::event_handler::EventDebouncer::new( // MirrorFUSE: 取走 FUSE 请求接收端
std::time::Duration::from_millis(500), #[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(); let shutdown_token = self.shutdown_token.lock().unwrap().clone();
loop { 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)) => { _ = tokio::time::sleep(std::time::Duration::from_secs(60)) => {
tracing::trace!("持续同步心跳"); tracing::trace!("持续同步心跳");
@@ -130,9 +175,9 @@ fn spawn_local_watcher(
let watch_root = watch_root.to_path_buf(); let watch_root = watch_root.to_path_buf();
std::thread::spawn(move || { 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::new_debouncer;
use notify_debouncer_full::notify::event::{ModifyKind, RenameMode};
use notify_debouncer_full::notify::{EventKind, RecursiveMode};
let tx = local_tx.clone(); let tx = local_tx.clone();
let shutdown = shutdown_token.clone(); let shutdown = shutdown_token.clone();
@@ -140,19 +185,23 @@ fn spawn_local_watcher(
let mut debouncer = match new_debouncer( let mut debouncer = match new_debouncer(
std::time::Duration::from_millis(500), std::time::Duration::from_millis(500),
None, None,
move |result: notify_debouncer_full::DebounceEventResult| { move |result: notify_debouncer_full::DebounceEventResult| match result {
match result {
Ok(events) => { Ok(events) => {
for event in events { for event in events {
if shutdown.is_cancelled() { return; } if shutdown.is_cancelled() {
return;
}
let kind = event.kind; let kind = event.kind;
let paths = &event.paths; let paths = &event.paths;
let filtered: Vec<_> = paths.iter() let filtered: Vec<_> = paths
.iter()
.filter(|p| !p.extension().map(|e| e == "sync_tmp").unwrap_or(false)) .filter(|p| !p.extension().map(|e| e == "sync_tmp").unwrap_or(false))
.cloned() .cloned()
.collect(); .collect();
if filtered.is_empty() { continue; } if filtered.is_empty() {
continue;
}
match kind { match kind {
EventKind::Create(_) => { EventKind::Create(_) => {
@@ -186,7 +235,6 @@ fn spawn_local_watcher(
tracing::warn!("文件监听去抖错误: {}", e); tracing::warn!("文件监听去抖错误: {}", e);
} }
} }
}
}, },
) { ) {
Ok(d) => d, Ok(d) => d,
+657
View File
@@ -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<Result<Vec<u8>, 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::<Vec<u8>, 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<u8>,
tmp_path: Option<&str>,
_mtime_ms: i64,
overwrite: bool,
reply_tx: tokio::sync::oneshot::Sender<Result<crate::platform::fuse::UploadResult, String>>,
) {
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<Result<(), String>>,
) {
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<Result<(), String>>,
) {
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<Result<(), String>>,
) {
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 适配器已清理");
}
}
}
@@ -23,7 +23,11 @@ impl SyncEngine {
let (local_root, remote_root, sync_mode) = { let (local_root, remote_root, sync_mode) = {
let config = self.config.read().await; 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); tracing::info!("开始初始同步, 模式={:?}", sync_mode);
@@ -47,7 +51,13 @@ impl SyncEngine {
} }
let db_mappings = self.load_all_mappings().await?; 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!( tracing::info!(
"差异计算完成: 上传={}, 下载={}, 删本地={}, 删远程={}, 冲突={}", "差异计算完成: 上传={}, 下载={}, 删本地={}, 删远程={}, 冲突={}",
plan.uploads.len(), plan.uploads.len(),
@@ -83,7 +93,8 @@ impl SyncEngine {
self.db.clone(), self.db.clone(),
self.api.clone(), self.api.clone(),
config.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(); let fetch_rx = adapter.take_fetch_receiver();
*self.wcf_fetch_rx.lock().unwrap() = fetch_rx; *self.wcf_fetch_rx.lock().unwrap() = fetch_rx;
let adapter_arc = std::sync::Arc::new(adapter); let adapter_arc = std::sync::Arc::new(adapter);
@@ -96,9 +107,79 @@ impl SyncEngine {
} }
} }
let result = self.worker_pool.submit( // MirrorFUSE 模式:初始化 FUSE 平台适配器(直接挂载到 local_root
plan, worker_config, WorkerTrigger::InitialSync, conflict_resolver, #[cfg(feature = "linux-fuse")]
).await; 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 { match result {
Ok(summary) => { Ok(summary) => {
+255 -76
View File
@@ -24,28 +24,60 @@ impl SyncEngine {
// 清理过期的 suppress 记录(超过 30 秒) // 清理过期的 suppress 记录(超过 30 秒)
let now = std::time::Instant::now(); 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 构建操作 === // === 第一步:提取 Renamed/Moved 事件,查 DB 构建操作 ===
let mut rename_remote: Vec<RenameAction> = Vec::new(); let mut rename_remote: Vec<RenameAction> = Vec::new();
let mut move_remote: Vec<MoveAction> = Vec::new(); let mut move_remote: Vec<MoveAction> = Vec::new();
let mut handled_old_rels: std::collections::HashSet<String> = std::collections::HashSet::new(); let mut handled_old_rels: std::collections::HashSet<String> =
let mut handled_new_rels: std::collections::HashSet<String> = std::collections::HashSet::new(); std::collections::HashSet::new();
let mut handled_new_rels: std::collections::HashSet<String> =
std::collections::HashSet::new();
for event in &all_events { for event in &all_events {
match event { 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()) { 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 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) { if self.suppress_paths.contains_key(&old_rel)
tracing::trace!("本地重命名被抑制(远程操作导致): {} -> {}", old_rel, new_rel); || self.suppress_paths.contains_key(&new_rel)
{
tracing::trace!(
"本地重命名被抑制(远程操作导致): {} -> {}",
old_rel,
new_rel
);
continue; continue;
} }
let new_name = new_path.file_name() let new_name = new_path
.file_name()
.map(|n| n.to_string_lossy().to_string()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(); .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); tracing::info!("检测到本地重命名: {} -> {}", old_rel, new_rel);
rename_remote.push(RenameAction { rename_remote.push(RenameAction {
old_relative_path: old_rel.clone(), old_relative_path: old_rel.clone(),
@@ -56,27 +88,67 @@ impl SyncEngine {
handled_old_rels.insert(old_rel); handled_old_rels.insert(old_rel);
handled_new_rels.insert(new_rel); handled_new_rels.insert(new_rel);
} else { } 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()) { 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 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) { if self.suppress_paths.contains_key(&old_rel)
tracing::trace!("本地移动被抑制(远程操作导致): {} -> {}", old_rel, new_rel); || self.suppress_paths.contains_key(&new_rel)
{
tracing::trace!(
"本地移动被抑制(远程操作导致): {} -> {}",
old_rel,
new_rel
);
continue; 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 remote_root = { self.config.read().await.remote_root.clone() };
let new_rel_path = std::path::PathBuf::from(&new_rel); 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())) .map(|p| crate::utils::normalize_path(&p.to_string_lossy()))
.unwrap_or_default(); .unwrap_or_default();
let dst_remote_dir_uri = format!("{}/{}", let dst_remote_dir_uri = format!(
"{}/{}",
remote_root.trim_end_matches('/'), remote_root.trim_end_matches('/'),
dst_dir_rel.trim_start_matches('/')); dst_dir_rel.trim_start_matches('/')
);
tracing::info!("检测到本地移动: {} -> {}", old_rel, new_rel); tracing::info!("检测到本地移动: {} -> {}", old_rel, new_rel);
move_remote.push(MoveAction { move_remote.push(MoveAction {
@@ -88,7 +160,11 @@ impl SyncEngine {
handled_old_rels.insert(old_rel); handled_old_rels.insert(old_rel);
handled_new_rels.insert(new_rel); handled_new_rels.insert(new_rel);
} else { } else {
tracing::info!("本地移动但旧路径无DB映射,按新建处理: {} -> {}", old_rel, new_rel); tracing::info!(
"本地移动但旧路径无DB映射,按新建处理: {} -> {}",
old_rel,
new_rel
);
} }
} }
} }
@@ -98,8 +174,10 @@ impl SyncEngine {
} }
// === 第二步:按事件类型分类路径,跳过已识别为 rename/move 的路径 === // === 第二步:按事件类型分类路径,跳过已识别为 rename/move 的路径 ===
let mut create_paths: std::collections::BTreeMap<String, std::path::PathBuf> = std::collections::BTreeMap::new(); let mut create_paths: std::collections::BTreeMap<String, std::path::PathBuf> =
let mut delete_paths: std::collections::BTreeSet<String> = std::collections::BTreeSet::new(); std::collections::BTreeMap::new();
let mut delete_paths: std::collections::BTreeSet<String> =
std::collections::BTreeSet::new();
for event in &all_events { for event in &all_events {
for path in event.paths() { for path in event.paths() {
@@ -107,21 +185,31 @@ impl SyncEngine {
continue; continue;
} }
let file_name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); let file_name = path
if crate::fs_scanner::SKIP_NAMES.iter().any(|s| file_name == *s) .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_") || 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; continue;
} }
let relative = path.strip_prefix(local_root) let relative = path
.strip_prefix(local_root)
.unwrap_or(path) .unwrap_or(path)
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
let relative = crate::utils::normalize_path(&relative); let relative = crate::utils::normalize_path(&relative);
if handled_old_rels.contains(&relative) || handled_new_rels.contains(&relative) if handled_old_rels.contains(&relative)
|| self.suppress_paths.contains_key(&relative) { || handled_new_rels.contains(&relative)
|| self.suppress_paths.contains_key(&relative)
{
continue; continue;
} }
@@ -145,33 +233,54 @@ impl SyncEngine {
// === hash 匹配回退:检测 delete+create 为 rename 的情况 === // === hash 匹配回退:检测 delete+create 为 rename 的情况 ===
// MirrorWcf: 跳过 hash 匹配回退,因为读取占位符文件会被 CFApi 拦截导致 426 超时 // MirrorWcf: 跳过 hash 匹配回退,因为读取占位符文件会被 CFApi 拦截导致 426 超时
if !delete_paths.is_empty() && !create_paths.is_empty() && !matches!(sync_mode, SyncMode::MirrorWcf) { if !delete_paths.is_empty()
let mut matched_deletes: std::collections::HashSet<String> = std::collections::HashSet::new(); && !create_paths.is_empty()
let mut matched_creates: std::collections::HashSet<String> = std::collections::HashSet::new(); && !matches!(sync_mode, SyncMode::MirrorWcf)
{
let mut matched_deletes: std::collections::HashSet<String> =
std::collections::HashSet::new();
let mut matched_creates: std::collections::HashSet<String> =
std::collections::HashSet::new();
for (new_rel, new_path) in &create_paths { for (new_rel, new_path) in &create_paths {
if let Ok(metadata) = tokio::fs::metadata(new_path).await { if let Ok(metadata) = tokio::fs::metadata(new_path).await {
if metadata.is_dir() || metadata.len() == 0 { continue; } if metadata.is_dir() || metadata.len() == 0 {
let new_hash = crate::utils::quick_hash(new_path, metadata.len()).await.unwrap_or_default(); continue;
if new_hash.is_empty() { 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 { for del_rel in &delete_paths {
if matched_deletes.contains(del_rel.as_str()) { continue; } if matched_deletes.contains(del_rel.as_str()) {
if let Ok(Some(mapping)) = self.db.get_file_mapping(&root_id, del_rel).await { 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) { 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()) .map(|n| n.to_string_lossy().to_string())
.unwrap_or_default(); .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())) .map(|p| crate::utils::normalize_path(&p.to_string_lossy()))
.unwrap_or_default(); .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())) .map(|p| crate::utils::normalize_path(&p.to_string_lossy()))
.unwrap_or_default(); .unwrap_or_default();
if old_dir == new_dir { if old_dir == new_dir {
tracing::info!("hash匹配检测到重命名: {} -> {}", del_rel, new_rel); tracing::info!(
"hash匹配检测到重命名: {} -> {}",
del_rel,
new_rel
);
rename_remote.push(RenameAction { rename_remote.push(RenameAction {
old_relative_path: del_rel.clone(), old_relative_path: del_rel.clone(),
new_relative_path: new_rel.clone(), new_relative_path: new_rel.clone(),
@@ -179,11 +288,18 @@ impl SyncEngine {
new_name, new_name,
}); });
} else { } else {
let remote_root = { self.config.read().await.remote_root.clone() }; let remote_root =
let dst_remote_dir_uri = format!("{}/{}", { self.config.read().await.remote_root.clone() };
let dst_remote_dir_uri = format!(
"{}/{}",
remote_root.trim_end_matches('/'), remote_root.trim_end_matches('/'),
new_dir.trim_start_matches('/')); new_dir.trim_start_matches('/')
tracing::info!("hash匹配检测到移动: {} -> {}", del_rel, new_rel); );
tracing::info!(
"hash匹配检测到移动: {} -> {}",
del_rel,
new_rel
);
move_remote.push(MoveAction { move_remote.push(MoveAction {
old_relative_path: del_rel.clone(), old_relative_path: del_rel.clone(),
new_relative_path: new_rel.clone(), new_relative_path: new_rel.clone(),
@@ -212,9 +328,14 @@ impl SyncEngine {
}; };
let worker_config = self.snapshot_worker_config().await; let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone(); let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background( self.worker_pool
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, .submit_background(
).await; plan,
worker_config,
WorkerTrigger::Continuous,
conflict_resolver,
)
.await;
} }
// === 提交移动任务 === // === 提交移动任务 ===
@@ -225,9 +346,14 @@ impl SyncEngine {
}; };
let worker_config = self.snapshot_worker_config().await; let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone(); let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background( self.worker_pool
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, .submit_background(
).await; plan,
worker_config,
WorkerTrigger::Continuous,
conflict_resolver,
)
.await;
} }
// === 提交上传任务 (Create/Modify) === // === 提交上传任务 (Create/Modify) ===
@@ -265,12 +391,21 @@ impl SyncEngine {
let quick_hash = if matches!(sync_mode, SyncMode::MirrorWcf) { let quick_hash = if matches!(sync_mode, SyncMode::MirrorWcf) {
String::new() String::new()
} else { } 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 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; continue;
} }
if mapping.is_placeholder { if mapping.is_placeholder {
@@ -278,7 +413,8 @@ impl SyncEngine {
} }
} }
let mtime_ms = metadata.modified() let mtime_ms = metadata
.modified()
.ok() .ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok()) .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64) .map(|d| d.as_millis() as i64)
@@ -303,10 +439,13 @@ impl SyncEngine {
let scan_dirs = find_top_level_dirs(&dir_paths); 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()) .chain(handled_new_rels.iter())
.collect(); .collect();
let filtered_scan_dirs: Vec<String> = scan_dirs.into_iter().filter(|dir| { let filtered_scan_dirs: Vec<String> = scan_dirs
.into_iter()
.filter(|dir| {
if all_handled.iter().any(|rel| { if all_handled.iter().any(|rel| {
rel.starts_with(dir.as_str()) rel.starts_with(dir.as_str())
&& rel.as_bytes().get(dir.len()) == Some(&b'/') && rel.as_bytes().get(dir.len()) == Some(&b'/')
@@ -316,7 +455,8 @@ impl SyncEngine {
for entry in self.suppress_paths.iter() { for entry in self.suppress_paths.iter() {
let rel = entry.key(); let rel = entry.key();
if rel.starts_with(dir.as_str()) if rel.starts_with(dir.as_str())
&& rel.as_bytes().get(dir.len()) == Some(&b'/') { && rel.as_bytes().get(dir.len()) == Some(&b'/')
{
return false; return false;
} }
if dir.as_str() == rel.as_str() { if dir.as_str() == rel.as_str() {
@@ -324,7 +464,8 @@ impl SyncEngine {
} }
} }
true true
}).collect(); })
.collect();
if !filtered_scan_dirs.is_empty() { if !filtered_scan_dirs.is_empty() {
uploads.retain(|action| { uploads.retain(|action| {
@@ -338,8 +479,10 @@ impl SyncEngine {
if !uploads.is_empty() || !filtered_scan_dirs.is_empty() { if !uploads.is_empty() || !filtered_scan_dirs.is_empty() {
tracing::info!( tracing::info!(
"本地事件收集完成: 上传={}, 目录扫描={:?}, 跳过(未稳定)={}, 跳过(重复上传)={}", "本地事件收集完成: 上传={}, 目录扫描={:?}, 跳过(未稳定)={}, 跳过(重复上传)={}",
uploads.len(), filtered_scan_dirs, uploads.len(),
skipped_unstable, skipped_uploading, filtered_scan_dirs,
skipped_unstable,
skipped_uploading,
); );
let plan = SyncPlan { let plan = SyncPlan {
uploads, uploads,
@@ -348,9 +491,14 @@ impl SyncEngine {
}; };
let worker_config = self.snapshot_worker_config().await; let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone(); let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background( self.worker_pool
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, .submit_background(
).await; plan,
worker_config,
WorkerTrigger::Continuous,
conflict_resolver,
)
.await;
} }
} }
@@ -359,7 +507,9 @@ impl SyncEngine {
// MirrorWcf 模式:仅在 wcf_delete_mode == SyncRemote 时删除远程,否则仅删除本地(保留远程以便重新水合) // MirrorWcf 模式:仅在 wcf_delete_mode == SyncRemote 时删除远程,否则仅删除本地(保留远程以便重新水合)
let wcf_should_delete_remote = matches!(sync_mode, SyncMode::MirrorWcf) let wcf_should_delete_remote = matches!(sync_mode, SyncMode::MirrorWcf)
&& matches!(wcf_delete_mode, WcfDeleteMode::SyncRemote); && 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<SyncAction> = Vec::new(); let mut delete_remote: Vec<SyncAction> = Vec::new();
for relative in &delete_paths { for relative in &delete_paths {
tracing::info!("检测到本地文件删除: {}", relative); tracing::info!("检测到本地文件删除: {}", relative);
@@ -392,9 +542,14 @@ impl SyncEngine {
}; };
let worker_config = self.snapshot_worker_config().await; let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone(); let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background( self.worker_pool
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, .submit_background(
).await; plan,
worker_config,
WorkerTrigger::Continuous,
conflict_resolver,
)
.await;
} }
} }
@@ -405,20 +560,47 @@ impl SyncEngine {
tracing::debug!("仅上传模式: 本地删除仅清理映射,不删除远程: {}", relative); 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 的相对路径对 /// 从两个绝对路径生成相对于 local_root 的相对路径对
fn rel_pair(local_root: &std::path::Path, old_path: &std::path::Path, new_path: &std::path::Path) -> Option<(String, String)> { fn rel_pair(
let old_rel = old_path.strip_prefix(local_root).ok()? local_root: &std::path::Path,
.to_string_lossy().to_string(); old_path: &std::path::Path,
let new_rel = new_path.strip_prefix(local_root).ok()? new_path: &std::path::Path,
.to_string_lossy().to_string(); ) -> Option<(String, String)> {
Some((crate::utils::normalize_path(&old_rel), crate::utils::normalize_path(&new_rel))) 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<String> { fn find_top_level_dirs(dirs: &[String]) -> Vec<String> {
if dirs.is_empty() { return Vec::new(); } if dirs.is_empty() {
return Vec::new();
}
let mut sorted: Vec<&String> = dirs.iter().collect(); let mut sorted: Vec<&String> = dirs.iter().collect();
sorted.sort(); sorted.sort();
@@ -426,8 +608,7 @@ fn find_top_level_dirs(dirs: &[String]) -> Vec<String> {
let mut top_level = Vec::new(); let mut top_level = Vec::new();
for dir in &sorted { for dir in &sorted {
let dominated = top_level.iter().any(|parent: &String| { let dominated = top_level.iter().any(|parent: &String| {
dir.starts_with(parent.as_str()) dir.starts_with(parent.as_str()) && dir.as_bytes().get(parent.len()) == Some(&b'/')
&& dir.as_bytes().get(parent.len()) == Some(&b'/')
}); });
if !dominated { if !dominated {
top_level.retain(|existing: &String| { top_level.retain(|existing: &String| {
@@ -449,14 +630,12 @@ async fn is_file_stable(path: &Path) -> bool {
}; };
let path_display = path.display().to_string(); let path_display = path.display().to_string();
let can_open = tokio::task::spawn_blocking(move || { let can_open = tokio::task::spawn_blocking(move || match std::fs::File::open(&path_display) {
match std::fs::File::open(&path_display) {
Ok(_) => true, Ok(_) => true,
Err(e) => { Err(e) => {
tracing::trace!("文件稳定性检测打开失败: {}", e); tracing::trace!("文件稳定性检测打开失败: {}", e);
false false
} }
}
}) })
.await .await
.unwrap_or(false); .unwrap_or(false);
+99 -26
View File
@@ -1,13 +1,15 @@
mod initial_sync; mod album;
mod continuous_sync; mod continuous_sync;
#[cfg(feature = "linux-fuse")]
mod fuse;
mod initial_sync;
mod local_events; mod local_events;
mod remote_events; mod remote_events;
mod album;
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
mod wcf; mod wcf;
// 非 WCF feature 下的 stub 方法,供 remote_events.rs 编译通过 // 非 WCF/FUSE feature 下的 stub 方法,供 remote_events.rs 编译通过
#[cfg(not(feature = "windows-cfapi"))] #[cfg(not(any(feature = "windows-cfapi", feature = "linux-fuse")))]
impl SyncEngine { impl SyncEngine {
async fn _create_placeholder_for_remote( async fn _create_placeholder_for_remote(
&self, &self,
@@ -16,7 +18,7 @@ impl SyncEngine {
_local_root: &std::path::Path, _local_root: &std::path::Path,
_root_id: &str, _root_id: &str,
) { ) {
// MirrorWcf 模式在非 Windows 平台不可用,此方法不应被调用 // MirrorWcf 模式在非 Windows/Linux-FUSE 平台不可用,此方法不应被调用
} }
} }
@@ -31,9 +33,9 @@ use crate::worker::WorkerPool;
use dashmap::DashMap; use dashmap::DashMap;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::RwLock;
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::sync::RwLock;
use tokio_util::sync::CancellationToken; use tokio_util::sync::CancellationToken;
pub struct SyncEngine { pub struct SyncEngine {
@@ -66,15 +68,35 @@ pub struct SyncEngine {
/// 缓存的本地同步根路径(WCF 清理时同步读取,避免 await) /// 缓存的本地同步根路径(WCF 清理时同步读取,避免 await)
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
cached_local_root: std::sync::Mutex<std::path::PathBuf>, cached_local_root: std::sync::Mutex<std::path::PathBuf>,
/// FUSE 平台适配器(仅 MirrorWcf + linux-fuse 模式下初始化)
#[cfg(feature = "linux-fuse")]
fuse_adapter: std::sync::Mutex<Option<Arc<crate::platform::fuse::FusePlatformAdapter>>>,
/// FUSE 请求接收端(在适配器初始化时提取)
#[cfg(feature = "linux-fuse")]
fuse_request_rx:
std::sync::Mutex<Option<tokio::sync::mpsc::Receiver<crate::platform::fuse::FuseRequest>>>,
/// FUSE 水合缓存:uri → 已下载的完整文件数据
#[cfg(feature = "linux-fuse")]
hydration_cache: Arc<DashMap<String, (Vec<u8>, std::time::Instant)>>,
} }
impl SyncEngine { impl SyncEngine {
pub async fn new(config: SyncConfig) -> Result<Self> { pub async fn new(config: SyncConfig) -> Result<Self> {
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_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()); let conflict = ConflictResolver::new(config.conflict_strategy.clone());
@@ -127,6 +149,12 @@ impl SyncEngine {
hydration_cache: Arc::new(DashMap::new()), hydration_cache: Arc::new(DashMap::new()),
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
cached_local_root: std::sync::Mutex::new(std::path::PathBuf::new()), 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 → 清空本地目录 → 回到初始状态 /// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
pub async fn reset_sync(&self) -> Result<()> { pub async fn reset_sync(&self, delete_local_files: bool) -> Result<()> {
tracing::info!("开始重置同步..."); tracing::info!("开始重置同步... delete_local_files={}", delete_local_files);
// 1. 停止同步 // 1. 停止同步
self.stop().await?; self.stop().await?;
@@ -196,6 +224,12 @@ impl SyncEngine {
self.cleanup_wcf(); self.cleanup_wcf();
} }
// 2b. 清理 FUSE
#[cfg(feature = "linux-fuse")]
{
self.cleanup_fuse();
}
// 3. 终止所有活跃 Worker // 3. 终止所有活跃 Worker
// 2. 终止所有活跃 Worker // 2. 终止所有活跃 Worker
@@ -206,10 +240,15 @@ impl SyncEngine {
tracing::info!("同步数据库已清空"); tracing::info!("同步数据库已清空");
// 4. 清空本地同步目录(保留目录本身,只删内容) // 4. 清空本地同步目录(保留目录本身,只删内容)
if delete_local_files {
let local_root = self.config.read().await.local_root.clone(); let local_root = self.config.read().await.local_root.clone();
if local_root.exists() { if local_root.exists() {
let entries = std::fs::read_dir(&local_root) let entries = std::fs::read_dir(&local_root).map_err(|_| {
.map_err(|_| crate::errors::SyncError::DiskFull { needed: 0, available: 0 })?; crate::errors::SyncError::DiskFull {
needed: 0,
available: 0,
}
})?;
for entry in entries.flatten() { for entry in entries.flatten() {
let path = entry.path(); let path = entry.path();
if path.is_dir() { if path.is_dir() {
@@ -220,6 +259,9 @@ impl SyncEngine {
} }
tracing::info!("本地同步目录已清空: {}", local_root.display()); tracing::info!("本地同步目录已清空: {}", local_root.display());
} }
} else {
tracing::info!("跳过清空本地同步目录");
}
// 5. 清空内存缓存 // 5. 清空内存缓存
self.ensured_dirs.clear(); self.ensured_dirs.clear();
@@ -232,14 +274,30 @@ impl SyncEngine {
Ok(()) Ok(())
} }
pub fn status(&self) -> SyncStatusSnapshot { pub async fn status(&self) -> SyncStatusSnapshot {
let state = self.state.try_read().map(|g| g.clone()).unwrap_or(SyncState::Idle); let state = self
.state
.try_read()
.map(|g| g.clone())
.unwrap_or(SyncState::Idle);
let (synced_files, total_files) = match &state { let (synced_files, total_files) = match &state {
SyncState::InitialSync { progress } => { SyncState::InitialSync { progress } => {
let done = progress.uploaded + progress.downloaded; let done = progress.uploaded + progress.downloaded;
(done, progress.total_to_sync) (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), _ => (0, 0),
}; };
@@ -288,7 +346,11 @@ impl SyncEngine {
} }
tracing::info!( tracing::info!(
"同步配置已更新: 模式={}, 冲突策略={}, WCF删除={}, 并发={}, 带宽限制={:?}", "同步配置已更新: 模式={}, 冲突策略={}, 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(()) Ok(())
} }
@@ -297,7 +359,10 @@ impl SyncEngine {
self.api.update_token(token).await; self.api.update_token(token).await;
} }
pub async fn register_event_sink(&self, sink: crate::frb_generated::StreamSink<crate::api::ffi_types::SyncEventFfi>) { pub async fn register_event_sink(
&self,
sink: crate::frb_generated::StreamSink<crate::api::ffi_types::SyncEventFfi>,
) {
self.event_sink.register(sink).await; self.event_sink.register(sink).await;
} }
@@ -317,6 +382,10 @@ impl SyncEngine {
self.db.query_task_items(filter).await self.db.query_task_items(filter).await
} }
pub async fn get_cum_stats(&self) -> Result<SyncCumStats> {
self.db.get_cum_stats().await
}
pub async fn hydrate_file(&self, local_path: &str) -> Result<()> { pub async fn hydrate_file(&self, local_path: &str) -> Result<()> {
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
{ {
@@ -340,18 +409,18 @@ impl SyncEngine {
}; };
let pool = self.db.read_pool(); let pool = self.db.read_pool();
let result = tokio::task::spawn_blocking(move || -> Result<HashMap<String, FileMapping>> { let result =
tokio::task::spawn_blocking(move || -> Result<HashMap<String, FileMapping>> {
let conn = pool.get()?; let conn = pool.get()?;
let mut stmt = conn.prepare( let mut stmt = conn.prepare(
"SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id,
local_hash, remote_hash, local_mtime, remote_mtime, local_hash, remote_hash, local_mtime, remote_mtime,
local_size, remote_size, sync_status, is_placeholder 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<String, FileMapping> = stmt.query_map( let mappings: HashMap<String, FileMapping> = stmt
rusqlite::params![root_id], .query_map(rusqlite::params![root_id], |row| {
|row| {
let local_path: String = row.get(2)?; let local_path: String = row.get(2)?;
Ok(( Ok((
crate::utils::normalize_path(&local_path), crate::utils::normalize_path(&local_path),
@@ -367,15 +436,19 @@ impl SyncEngine {
remote_mtime: row.get(8)?, remote_mtime: row.get(8)?,
local_size: row.get(9)?, local_size: row.get(9)?,
remote_size: row.get(10)?, remote_size: row.get(10)?,
sync_status: crate::diff::parse_sync_status_from_str(&row.get::<_, String>(11)?), sync_status: crate::diff::parse_sync_status_from_str(
&row.get::<_, String>(11)?,
),
is_placeholder: row.get::<_, i32>(12)? != 0, is_placeholder: row.get::<_, i32>(12)? != 0,
}, },
)) ))
}, })?
)?.filter_map(|r| r.ok()).collect(); .filter_map(|r| r.ok())
.collect();
Ok(mappings) Ok(mappings)
}).await??; })
.await??;
Ok(result) Ok(result)
} }
+175 -44
View File
@@ -11,6 +11,7 @@ impl SyncEngine {
remote_root: &str, remote_root: &str,
) { ) {
// UploadOnly: 忽略所有远程事件 // UploadOnly: 忽略所有远程事件
// AlbumDownload: 忽略删除事件(不删本地照片)
let sync_mode = { let sync_mode = {
let config = self.config.read().await; let config = self.config.read().await;
config.sync_mode.clone() config.sync_mode.clone()
@@ -30,12 +31,21 @@ impl SyncEngine {
&remote.name, &remote.name,
remote.is_dir, 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 { let remote_entry = if remote.size == 0 && !remote.is_dir {
match self.api.get_file_info(&remote.uri).await { match self.api.get_file_info(&remote.uri).await {
Ok(info) => { Ok(info) => {
tracing::debug!("[远程事件] 获取文件详情成功: {} ({}bytes)", relative, info.size); tracing::debug!(
"[远程事件] 获取文件详情成功: {} ({}bytes)",
relative,
info.size
);
info info
} }
Err(e) => { Err(e) => {
@@ -58,8 +68,19 @@ impl SyncEngine {
if is_mirror_wcf { if is_mirror_wcf {
self._create_placeholder_for_remote( self._create_placeholder_for_remote(
&relative, &remote_entry, local_root, &root_id, &relative,
).await; &remote_entry,
local_root,
&root_id,
)
.await;
self._record_wcf_stats(
&relative,
TaskActionType::CreatePlaceholder,
remote_entry.size,
None,
)
.await;
} else { } else {
let plan = SyncPlan { let plan = SyncPlan {
downloads: vec![SyncAction { downloads: vec![SyncAction {
@@ -72,22 +93,42 @@ impl SyncEngine {
}; };
let worker_config = self.snapshot_worker_config().await; let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone(); let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background( self.worker_pool
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, .submit_background(
).await; plan,
worker_config,
WorkerTrigger::Continuous,
conflict_resolver,
)
.await;
} }
} }
RemoteFileEvent::Deleted { uri, name } => { RemoteFileEvent::Deleted { uri, name } => {
let relative = crate::diff::remote_relative_path( // 清理过期抑制条目
remote_root, let now = std::time::Instant::now();
uri, self.suppress_paths
name, .retain(|_, ts| now.duration_since(*ts).as_secs() < 30);
false,
); let relative = crate::diff::remote_relative_path(remote_root, uri, name, false);
tracing::info!("[远程事件] 删除: {}", relative); 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);
self.suppress_paths.remove(&relative);
return;
}
let local_path = local_root.join(&relative); let local_path = local_root.join(&relative);
if local_path.exists() { let existed = local_path.exists();
if existed {
if local_path.is_dir() { if local_path.is_dir() {
let _ = tokio::fs::remove_dir_all(&local_path).await; let _ = tokio::fs::remove_dir_all(&local_path).await;
} else { } else {
@@ -96,15 +137,27 @@ impl SyncEngine {
tracing::info!("[远程事件] 已删除本地文件: {}", relative); tracing::info!("[远程事件] 已删除本地文件: {}", relative);
} }
let _ = self.db.delete_file_mapping(&root_id, &relative).await; 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 } => { RemoteFileEvent::Renamed { old_uri, new_entry } => {
let old_relative = crate::diff::remote_relative_path( let old_relative =
remote_root, crate::diff::remote_relative_path(remote_root, old_uri, &new_entry.name, false);
old_uri,
&new_entry.name,
false,
);
let new_relative = crate::diff::remote_relative_path( let new_relative = crate::diff::remote_relative_path(
remote_root, remote_root,
&new_entry.path, &new_entry.path,
@@ -130,14 +183,30 @@ impl SyncEngine {
}; };
let worker_config = self.snapshot_worker_config().await; let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone(); let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background( self.worker_pool
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, .submit_background(
).await; plan,
worker_config,
WorkerTrigger::Continuous,
conflict_resolver,
)
.await;
} else if is_mirror_wcf { } else if is_mirror_wcf {
let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; let remote_entry = self.get_remote_entry_or_fallback(new_entry).await;
self._create_placeholder_for_remote( self._create_placeholder_for_remote(
&new_relative, &remote_entry, local_root, &root_id, &new_relative,
).await; &remote_entry,
local_root,
&root_id,
)
.await;
self._record_wcf_stats(
&format!("{} -> {}", old_relative, new_relative),
TaskActionType::Rename,
0,
None,
)
.await;
} else { } else {
let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; let remote_entry = self.get_remote_entry_or_fallback(new_entry).await;
let plan = SyncPlan { let plan = SyncPlan {
@@ -151,18 +220,19 @@ impl SyncEngine {
}; };
let worker_config = self.snapshot_worker_config().await; let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone(); let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background( self.worker_pool
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, .submit_background(
).await; plan,
worker_config,
WorkerTrigger::Continuous,
conflict_resolver,
)
.await;
} }
} }
RemoteFileEvent::Moved { old_uri, new_entry } => { RemoteFileEvent::Moved { old_uri, new_entry } => {
let old_relative = crate::diff::remote_relative_path( let old_relative =
remote_root, crate::diff::remote_relative_path(remote_root, old_uri, &new_entry.name, false);
old_uri,
&new_entry.name,
false,
);
let new_relative = crate::diff::remote_relative_path( let new_relative = crate::diff::remote_relative_path(
remote_root, remote_root,
&new_entry.path, &new_entry.path,
@@ -188,14 +258,30 @@ impl SyncEngine {
}; };
let worker_config = self.snapshot_worker_config().await; let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone(); let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background( self.worker_pool
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, .submit_background(
).await; plan,
worker_config,
WorkerTrigger::Continuous,
conflict_resolver,
)
.await;
} else if is_mirror_wcf { } else if is_mirror_wcf {
let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; let remote_entry = self.get_remote_entry_or_fallback(new_entry).await;
self._create_placeholder_for_remote( self._create_placeholder_for_remote(
&new_relative, &remote_entry, local_root, &root_id, &new_relative,
).await; &remote_entry,
local_root,
&root_id,
)
.await;
self._record_wcf_stats(
&format!("{} -> {}", old_relative, new_relative),
TaskActionType::Move,
0,
None,
)
.await;
} else { } else {
let remote_entry = self.get_remote_entry_or_fallback(new_entry).await; let remote_entry = self.get_remote_entry_or_fallback(new_entry).await;
let plan = SyncPlan { let plan = SyncPlan {
@@ -209,16 +295,61 @@ impl SyncEngine {
}; };
let worker_config = self.snapshot_worker_config().await; let worker_config = self.snapshot_worker_config().await;
let conflict_resolver = self.conflict.read().await.clone(); let conflict_resolver = self.conflict.read().await.clone();
self.worker_pool.submit_background( self.worker_pool
plan, worker_config, WorkerTrigger::Continuous, conflict_resolver, .submit_background(
).await; 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<String>,
) {
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 数据回退 /// 获取远程文件详情,失败则使用 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 { if entry.size == 0 && !entry.is_dir {
match self.api.get_file_info(&entry.uri).await { match self.api.get_file_info(&entry.uri).await {
Ok(info) => info, Ok(info) => info,
+128 -30
View File
@@ -18,7 +18,8 @@ impl SyncEngine {
Err(e) => { Err(e) => {
tracing::error!("WCF 水合: FileIdentity 反序列化失败: {}", e); tracing::error!("WCF 水合: FileIdentity 反序列化失败: {}", e);
let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data(
request.connection_key, request.transfer_key, request.connection_key,
request.transfer_key,
); );
return; return;
} }
@@ -31,19 +32,26 @@ impl SyncEngine {
if remote_uri.is_empty() { if remote_uri.is_empty() {
tracing::error!("WCF 水合: FileIdentity 中 uri 为空"); tracing::error!("WCF 水合: FileIdentity 中 uri 为空");
let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data(
request.connection_key, request.transfer_key, request.connection_key,
request.transfer_key,
); );
return; return;
} }
tracing::debug!("WCF 水合请求: uri={}, size={}, offset={}, length={}", tracing::debug!(
remote_uri, remote_size, request.required_offset, request.required_length); "WCF 水合请求: uri={}, size={}, offset={}, length={}",
remote_uri,
remote_size,
request.required_offset,
request.required_length
);
let root_id = match &self.sync_root_id { let root_id = match &self.sync_root_id {
Some(id) => id.clone(), Some(id) => id.clone(),
None => { None => {
let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data( let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data(
request.connection_key, request.transfer_key, request.connection_key,
request.transfer_key,
); );
return; return;
} }
@@ -51,12 +59,13 @@ impl SyncEngine {
// 清理过期缓存(超过 5 分钟) // 清理过期缓存(超过 5 分钟)
let now = std::time::Instant::now(); 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);
// 尝试从缓存获取已下载的数据 // 尝试从缓存获取已下载的数据cache miss 时下载并标记 is_new_download
let data = if let Some(cached) = self.hydration_cache.get(&remote_uri) { let (data, is_new_download) = if let Some(cached) = self.hydration_cache.get(&remote_uri) {
tracing::debug!("WCF 水合缓存命中: {}", remote_uri); tracing::debug!("WCF 水合缓存命中: {}", remote_uri);
cached.0.clone() (cached.0.clone(), false)
} else { } else {
tracing::info!("WCF 水合下载: {} ({}bytes)", remote_uri, remote_size); tracing::info!("WCF 水合下载: {} ({}bytes)", remote_uri, remote_size);
let config = self.snapshot_worker_config().await; let config = self.snapshot_worker_config().await;
@@ -72,27 +81,57 @@ impl SyncEngine {
} }
Err(e) => return Err(e), Err(e) => return Err(e),
}; };
let download_url = urls.into_iter().next() let download_url = urls.into_iter().next().ok_or_else(|| {
.ok_or_else(|| crate::errors::SyncError::Network("获取下载 URL 返回空列表".into()))?; crate::errors::SyncError::Network("获取下载 URL 返回空列表".into())
})?;
let data = crate::downloader::download_to_buffer( let data = crate::downloader::download_to_buffer(
&self.api, &self.api,
&download_url, &download_url,
config.bandwidth_limit, config.bandwidth_limit,
).await?; )
.await?;
Ok::<Vec<u8>, crate::errors::SyncError>(data) Ok::<Vec<u8>, crate::errors::SyncError>(data)
}.await; }
.await;
match download_result { match download_result {
Ok(data) => { Ok(data) => {
self.hydration_cache.insert(remote_uri.clone(), (data.clone(), std::time::Instant::now())); self.hydration_cache.insert(
data remote_uri.clone(),
(data.clone(), std::time::Instant::now()),
);
(data, true)
} }
Err(e) => { Err(e) => {
tracing::error!("WCF 水合下载失败: {}: {}", remote_uri, 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( let _ = crate::platform::wcf::WcfPlatformAdapter::reject_fetch_data(
request.connection_key, request.transfer_key, request.connection_key,
request.transfer_key,
); );
return; return;
} }
@@ -120,25 +159,72 @@ impl SyncEngine {
offset as i64, offset as i64,
) { ) {
Ok(_) => { 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 { // 仅在首次下载(cache miss)时更新映射和记录统计,避免同一文件多次 range 请求重复计数
self.suppress_paths.insert(mapping.local_path.to_string_lossy().into_owned(), std::time::Instant::now()); if is_new_download {
let _ = self.db.upsert_file_mapping(&FileMapping { 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, id: mapping.id,
sync_root_id: mapping.sync_root_id, sync_root_id: mapping.sync_root_id,
local_path: mapping.local_path.clone(), local_path: mapping.local_path.clone(),
remote_uri: mapping.remote_uri.clone(), remote_uri: mapping.remote_uri.clone(),
remote_file_id: mapping.remote_file_id.clone(), remote_file_id: mapping.remote_file_id.clone(),
local_hash: None, local_hash: None,
remote_hash: if remote_hash.is_empty() { mapping.remote_hash.clone() } else { Some(remote_hash.clone()) }, remote_hash: if remote_hash.is_empty() {
mapping.remote_hash.clone()
} else {
Some(remote_hash.clone())
},
local_mtime: mapping.local_mtime, local_mtime: mapping.local_mtime,
remote_mtime: mapping.remote_mtime, remote_mtime: mapping.remote_mtime,
local_size: mapping.local_size, local_size: mapping.local_size,
remote_size: Some(remote_size), remote_size: Some(remote_size),
sync_status: SyncFileStatus::Synced, sync_status: SyncFileStatus::Synced,
is_placeholder: false, is_placeholder: false,
}).await; })
.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) => { Err(e) => {
@@ -169,7 +255,11 @@ impl SyncEngine {
if let Some(adapter) = self.platform_adapter.lock().unwrap().as_ref() { if let Some(adapter) = self.platform_adapter.lock().unwrap().as_ref() {
match adapter.create_placeholder_for_remote( match adapter.create_placeholder_for_remote(
local_path.parent().unwrap_or(local_root), 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.size,
&remote.uri, &remote.uri,
remote.hash.as_deref(), remote.hash.as_deref(),
@@ -181,7 +271,9 @@ impl SyncEngine {
} }
} }
let _ = self.db.upsert_file_mapping(&FileMapping { let _ = self
.db
.upsert_file_mapping(&FileMapping {
id: 0, id: 0,
sync_root_id: root_id.to_string(), sync_root_id: root_id.to_string(),
local_path: std::path::PathBuf::from(relative), local_path: std::path::PathBuf::from(relative),
@@ -195,7 +287,8 @@ impl SyncEngine {
remote_size: Some(remote.size), remote_size: Some(remote.size),
sync_status: SyncFileStatus::Placeholder, sync_status: SyncFileStatus::Placeholder,
is_placeholder: true, is_placeholder: true,
}).await; })
.await;
} }
} }
@@ -241,14 +334,17 @@ impl SyncEngine {
fn list_placeholders_sync(&self, sync_root_id: &str) -> anyhow::Result<Vec<FileMapping>> { fn list_placeholders_sync(&self, sync_root_id: &str) -> anyhow::Result<Vec<FileMapping>> {
let pool = self.db.read_pool(); let pool = self.db.read_pool();
let conn = pool.get().map_err(|e| anyhow::anyhow!("{}", e))?; let conn = pool.get().map_err(|e| anyhow::anyhow!("{}", e))?;
let mut stmt = conn.prepare( let mut stmt = conn
.prepare(
"SELECT id, sync_root_id, local_path, remote_uri, remote_file_id, "SELECT id, sync_root_id, local_path, remote_uri, remote_file_id,
local_hash, remote_hash, local_mtime, remote_mtime, local_hash, remote_hash, local_mtime, remote_mtime,
local_size, remote_size, sync_status, is_placeholder local_size, remote_size, sync_status, is_placeholder
FROM file_mapping WHERE sync_root_id = ?1 AND is_placeholder = 1", 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| { let mappings = stmt
.query_map(rusqlite::params![sync_root_id], |row| {
Ok(FileMapping { Ok(FileMapping {
id: row.get(0)?, id: row.get(0)?,
sync_root_id: row.get(1)?, sync_root_id: row.get(1)?,
@@ -264,8 +360,10 @@ impl SyncEngine {
sync_status: crate::sync_db::parse_sync_status(&row.get::<_, String>(11)?), sync_status: crate::sync_db::parse_sync_status(&row.get::<_, String>(11)?),
is_placeholder: row.get::<_, i32>(12)? != 0, 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) Ok(mappings)
} }
+29 -2
View File
@@ -126,9 +126,15 @@ pub async fn upload_file(
let chunk = &buf[..filled]; let chunk = &buf[..filled];
let mut chunk_retries = 0u32; let mut chunk_retries = 0u32;
loop { loop {
match api.upload_chunk(&session.session_id, index, chunk).await { match api.upload_chunk(&session, index, chunk, local.size, task_id).await {
Ok(_) => break, Ok(_) => break,
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())), Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
// 业务错误,重试无意义,直接失败
Err(e @ SyncError::StoragePolicyDenied(_))
| Err(e @ SyncError::UploadFailed(_))
| Err(e @ SyncError::FileNotFound(_))
| Err(e @ SyncError::PermissionDenied(_))
| Err(e @ SyncError::ObjectExisted) => return Err(e),
Err(e) if chunk_retries < max_retries => { Err(e) if chunk_retries < max_retries => {
chunk_retries += 1; chunk_retries += 1;
let delay = crate::utils::retry_delay_ms(chunk_retries, 1000, 30000); let delay = crate::utils::retry_delay_ms(chunk_retries, 1000, 30000);
@@ -141,6 +147,13 @@ pub async fn upload_file(
index += 1; index += 1;
} }
// 远程存储策略:上传完成后回调 Cloudreve 服务端
if session.is_remote_storage() {
if let Err(e) = api.callback_upload_complete(&session, task_id).await {
tracing::warn!("[{}][{}] 上传完成回调失败: {}", task_id, session.file_name, e);
}
}
// 上传完成后获取远程文件信息 // 上传完成后获取远程文件信息
let remote_uri = file_uri.clone(); let remote_uri = file_uri.clone();
let (remote_file_id, remote_hash) = match api.get_file_info(&remote_uri).await { let (remote_file_id, remote_hash) = match api.get_file_info(&remote_uri).await {
@@ -206,6 +219,11 @@ pub async fn retry_upload_session(
} }
return Err(SyncError::LockConflict { tokens }); return Err(SyncError::LockConflict { tokens });
} }
// 业务错误,重试无意义,直接失败
Err(e @ SyncError::StoragePolicyDenied(_))
| Err(e @ SyncError::UploadFailed(_))
| Err(e @ SyncError::FileNotFound(_))
| Err(e @ SyncError::PermissionDenied(_)) => return Err(e),
Err(e) if attempt <= max_retries => { Err(e) if attempt <= max_retries => {
let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000); let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000);
tracing::warn!("[{}] 创建上传会话失败,{}ms后重试 ({}): {}", task_id, delay, attempt, e); tracing::warn!("[{}] 创建上传会话失败,{}ms后重试 ({}): {}", task_id, delay, attempt, e);
@@ -273,9 +291,11 @@ pub async fn upload_file_chunked(
api: &ApiClient, api: &ApiClient,
local_path: &Path, local_path: &Path,
session: &UploadSession, session: &UploadSession,
task_id: &str,
) -> Result<()> { ) -> Result<()> {
let chunk_size = session.chunk_size as usize; let chunk_size = session.chunk_size as usize;
let file = tokio::fs::File::open(local_path).await?; let file = tokio::fs::File::open(local_path).await?;
let file_size = file.metadata().await.map(|m| m.len()).unwrap_or(0);
let mut reader = tokio::io::BufReader::new(file); let mut reader = tokio::io::BufReader::new(file);
let mut buf = vec![0u8; chunk_size]; let mut buf = vec![0u8; chunk_size];
let mut index: u32 = 0; let mut index: u32 = 0;
@@ -291,9 +311,16 @@ pub async fn upload_file_chunked(
} }
if filled == 0 { break; } if filled == 0 { break; }
api.upload_chunk(&session.session_id, index, &buf[..filled]).await?; api.upload_chunk(session, index, &buf[..filled], file_size, task_id).await?;
index += 1; index += 1;
} }
// 远程存储策略:上传完成后回调 Cloudreve 服务端
if session.is_remote_storage() {
if let Err(e) = api.callback_upload_complete(session, task_id).await {
tracing::warn!("[{}][{}] 上传完成回调失败: {}", task_id, session.file_name, e);
}
}
Ok(()) Ok(())
} }
+6
View File
@@ -26,6 +26,8 @@ pub struct WorkerPool {
file_locks: Arc<FileLockRegistry>, file_locks: Arc<FileLockRegistry>,
ensured_dirs: Arc<DashMap<String, ()>>, ensured_dirs: Arc<DashMap<String, ()>>,
event_sink: Arc<crate::event_sink::EventSink>, event_sink: Arc<crate::event_sink::EventSink>,
/// 抑制路径:上传失败清理远端碎片时,防止 SSE 删除事件误删本地文件
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
shutdown_token: std::sync::Mutex<CancellationToken>, shutdown_token: std::sync::Mutex<CancellationToken>,
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
platform_adapter: std::sync::Mutex<Option<Arc<dyn PlaceholderCreator>>>, platform_adapter: std::sync::Mutex<Option<Arc<dyn PlaceholderCreator>>>,
@@ -67,6 +69,7 @@ impl WorkerPool {
file_locks, file_locks,
ensured_dirs, ensured_dirs,
event_sink, event_sink,
suppress_paths: Arc::new(DashMap::new()),
shutdown_token: std::sync::Mutex::new(shutdown_token), shutdown_token: std::sync::Mutex::new(shutdown_token),
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
platform_adapter: std::sync::Mutex::new(None), platform_adapter: std::sync::Mutex::new(None),
@@ -133,6 +136,7 @@ impl WorkerPool {
self.ensured_dirs.clone(), self.ensured_dirs.clone(),
conflict_resolver, conflict_resolver,
self.event_sink.clone(), self.event_sink.clone(),
self.suppress_paths.clone(),
self.shutdown_token.lock().unwrap().clone(), self.shutdown_token.lock().unwrap().clone(),
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
self.platform_adapter.lock().unwrap().clone(), self.platform_adapter.lock().unwrap().clone(),
@@ -232,6 +236,7 @@ impl WorkerPool {
let file_locks = self.file_locks.clone(); let file_locks = self.file_locks.clone();
let ensured_dirs = self.ensured_dirs.clone(); let ensured_dirs = self.ensured_dirs.clone();
let event_sink = self.event_sink.clone(); let event_sink = self.event_sink.clone();
let suppress_paths = self.suppress_paths.clone();
let shutdown_token = self.shutdown_token.lock().unwrap().clone(); let shutdown_token = self.shutdown_token.lock().unwrap().clone();
let active_workers = self.active_workers.clone(); let active_workers = self.active_workers.clone();
let active_upload_paths = self.active_upload_paths.clone(); let active_upload_paths = self.active_upload_paths.clone();
@@ -255,6 +260,7 @@ impl WorkerPool {
ensured_dirs, ensured_dirs,
conflict_resolver, conflict_resolver,
event_sink, event_sink,
suppress_paths,
shutdown_token, shutdown_token,
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
platform_adapter, platform_adapter,
+157 -22
View File
@@ -37,6 +37,8 @@ pub struct Worker {
ensured_dirs: Arc<DashMap<String, ()>>, ensured_dirs: Arc<DashMap<String, ()>>,
conflict_resolver: ConflictResolver, conflict_resolver: ConflictResolver,
event_sink: Arc<crate::event_sink::EventSink>, event_sink: Arc<crate::event_sink::EventSink>,
/// 抑制路径:上传失败清理远端碎片时,防止 SSE 删除事件误删本地文件
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
shutdown_token: CancellationToken, shutdown_token: CancellationToken,
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
platform_adapter: Option<Arc<dyn PlaceholderCreator>>, platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
@@ -55,6 +57,7 @@ impl Worker {
ensured_dirs: Arc<DashMap<String, ()>>, ensured_dirs: Arc<DashMap<String, ()>>,
conflict_resolver: ConflictResolver, conflict_resolver: ConflictResolver,
event_sink: Arc<crate::event_sink::EventSink>, event_sink: Arc<crate::event_sink::EventSink>,
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
shutdown_token: CancellationToken, shutdown_token: CancellationToken,
#[cfg(feature = "windows-cfapi")] platform_adapter: Option<Arc<dyn PlaceholderCreator>>, #[cfg(feature = "windows-cfapi")] platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
) -> Self { ) -> Self {
@@ -69,12 +72,31 @@ impl Worker {
ensured_dirs, ensured_dirs,
conflict_resolver, conflict_resolver,
event_sink, event_sink,
suppress_paths,
shutdown_token, shutdown_token,
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
platform_adapter, platform_adapter,
} }
} }
/// 发射任务项状态变更事件(供 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 任务(编排器,调用各步骤方法) /// 执行 Worker 任务(编排器,调用各步骤方法)
pub async fn run(mut self) -> Result<SyncSummary> { pub async fn run(mut self) -> Result<SyncSummary> {
let start = Instant::now(); let start = Instant::now();
@@ -109,9 +131,12 @@ impl Worker {
self.step_rename_remote(&mut summary).await; self.step_rename_remote(&mut summary).await;
self.step_move_remote(&mut summary).await; self.step_move_remote(&mut summary).await;
self.step_scan_new_dirs(&mut summary).await; self.step_scan_new_dirs(&mut summary).await;
self.step_resolve_conflicts(&mut summary, &transfer_semaphore).await; self.step_resolve_conflicts(&mut summary, &transfer_semaphore)
self.step_execute_uploads(&mut summary, &transfer_semaphore).await; .await;
self.step_execute_downloads_or_placeholders(&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_delete_local(&mut summary).await;
self.step_rename_local(&mut summary).await; self.step_rename_local(&mut summary).await;
self.step_move_local(&mut summary).await; self.step_move_local(&mut summary).await;
@@ -168,7 +193,10 @@ impl Worker {
/// 1. 创建远程目录结构(UploadOnly / Full / MirrorWcf /// 1. 创建远程目录结构(UploadOnly / Full / MirrorWcf
async fn step_create_remote_dirs(&self) { 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; return;
} }
let tid = &self.task_id; let tid = &self.task_id;
@@ -193,7 +221,10 @@ impl Worker {
/// 2. 创建本地目录结构(DownloadOnly / Full / MirrorWcf /// 2. 创建本地目录结构(DownloadOnly / Full / MirrorWcf
async fn step_create_local_dirs(&self) { 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; return;
} }
let tid = &self.task_id; let tid = &self.task_id;
@@ -208,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) { 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; return;
} }
let tid = &self.task_id; let tid = &self.task_id;
@@ -238,6 +272,8 @@ impl Worker {
); );
summary.renamed += 1; summary.renamed += 1;
let _ = self.db.increment_task_completed(tid).await; 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 new_remote_uri = {
let uri = &rename.remote_uri; let uri = &rename.remote_uri;
let last_slash = uri.trim_end_matches('/').rfind('/').unwrap_or(0); let last_slash = uri.trim_end_matches('/').rfind('/').unwrap_or(0);
@@ -289,7 +325,10 @@ impl Worker {
/// 2.2 执行远程移动(本地触发) /// 2.2 执行远程移动(本地触发)
async fn step_move_remote(&self, summary: &mut SyncSummary) { 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; return;
} }
let tid = &self.task_id; let tid = &self.task_id;
@@ -314,6 +353,8 @@ impl Worker {
); );
summary.moved += 1; summary.moved += 1;
let _ = self.db.increment_task_completed(tid).await; let _ = self.db.increment_task_completed(tid).await;
self.emit_item_updated(tid, &mov.new_relative_path, "move", "completed")
.await;
let _ = self let _ = self
.db .db
.update_file_mapping_path( .update_file_mapping_path(
@@ -417,8 +458,7 @@ impl Worker {
self.plan.mkdirs_remote.push(full_relative.clone()); self.plan.mkdirs_remote.push(full_relative.clone());
} else { } else {
let mut local_entry = entry.clone(); let mut local_entry = entry.clone();
local_entry.relative_path = local_entry.relative_path = std::path::PathBuf::from(&full_relative);
std::path::PathBuf::from(&full_relative);
self.plan.uploads.push(SyncAction { self.plan.uploads.push(SyncAction {
relative_path: full_relative, relative_path: full_relative,
local_entry: Some(local_entry), local_entry: Some(local_entry),
@@ -487,7 +527,11 @@ impl Worker {
} }
/// 3. 处理冲突(串行) /// 3. 处理冲突(串行)
async fn step_resolve_conflicts(&self, summary: &mut SyncSummary, transfer_semaphore: &Arc<Semaphore>) { async fn step_resolve_conflicts(
&self,
summary: &mut SyncSummary,
transfer_semaphore: &Arc<Semaphore>,
) {
let tid = &self.task_id; let tid = &self.task_id;
let root_id = &self.config.sync_root_id; let root_id = &self.config.sync_root_id;
@@ -698,6 +742,13 @@ impl Worker {
}; };
if conflict_ok { if conflict_ok {
let _ = self.db.increment_task_completed(tid).await; let _ = self.db.increment_task_completed(tid).await;
self.emit_item_updated(
tid,
&conflict.relative_path,
"conflict_resolve",
"completed",
)
.await;
} }
let _ = self let _ = self
.db .db
@@ -712,9 +763,16 @@ impl Worker {
} }
} }
/// 4. 并发上传(UploadOnly / Full / MirrorWcf /// 4. 并发上传(UploadOnly / Full / MirrorWcf / AlbumUpload
async fn step_execute_uploads(&self, summary: &mut SyncSummary, transfer_semaphore: &Arc<Semaphore>) { async fn step_execute_uploads(
if matches!(self.config.sync_mode, SyncMode::DownloadOnly) { &self,
summary: &mut SyncSummary,
transfer_semaphore: &Arc<Semaphore>,
) {
if matches!(
self.config.sync_mode,
SyncMode::DownloadOnly | SyncMode::AlbumDownload
) {
return; return;
} }
let tid = &self.task_id; let tid = &self.task_id;
@@ -758,6 +816,8 @@ impl Worker {
Ok(Ok(_)) => { Ok(Ok(_)) => {
summary.uploaded += 1; summary.uploaded += 1;
let _ = self.db.increment_task_completed(tid).await; let _ = self.db.increment_task_completed(tid).await;
self.emit_item_updated(tid, &rel_path, "upload", "completed")
.await;
let _ = self let _ = self
.db .db
.update_task_item_status_by_path( .update_task_item_status_by_path(
@@ -773,6 +833,8 @@ impl Worker {
tracing::error!("[{}] 上传失败: {}: {}", tid, rel_path, e); tracing::error!("[{}] 上传失败: {}: {}", tid, rel_path, e);
summary.failed += 1; summary.failed += 1;
let _ = self.db.increment_task_failed(tid).await; let _ = self.db.increment_task_failed(tid).await;
self.emit_item_updated(tid, &rel_path, "upload", "failed")
.await;
let _ = self let _ = self
.db .db
.update_task_item_status_by_path( .update_task_item_status_by_path(
@@ -783,11 +845,15 @@ impl Worker {
Some(&e.to_string()), Some(&e.to_string()),
) )
.await; .await;
// 清理远端碎片:强制解锁 + 删除
self.cleanup_failed_upload(&rel_path).await;
} }
Err(e) => { Err(e) => {
tracing::error!("[{}] 上传任务异常: {}: {}", tid, rel_path, e); tracing::error!("[{}] 上传任务异常: {}: {}", tid, rel_path, e);
summary.failed += 1; summary.failed += 1;
let _ = self.db.increment_task_failed(tid).await; let _ = self.db.increment_task_failed(tid).await;
self.emit_item_updated(tid, &rel_path, "upload", "failed")
.await;
let _ = self let _ = self
.db .db
.update_task_item_status_by_path( .update_task_item_status_by_path(
@@ -798,13 +864,19 @@ impl Worker {
Some(&e.to_string()), Some(&e.to_string()),
) )
.await; .await;
// 清理远端碎片:强制解锁 + 删除
self.cleanup_failed_upload(&rel_path).await;
} }
} }
} }
} }
/// 5. 并发下载(DownloadOnly / Full)或 创建占位符(MirrorWcf /// 5. 并发下载(DownloadOnly / Full)或 创建占位符(MirrorWcf
async fn step_execute_downloads_or_placeholders(&self, summary: &mut SyncSummary, transfer_semaphore: &Arc<Semaphore>) { async fn step_execute_downloads_or_placeholders(
&self,
summary: &mut SyncSummary,
transfer_semaphore: &Arc<Semaphore>,
) {
let tid = &self.task_id; let tid = &self.task_id;
let root_id = self.config.sync_root_id.clone(); let root_id = self.config.sync_root_id.clone();
@@ -816,13 +888,17 @@ impl Worker {
} }
let relative = &action.relative_path; let relative = &action.relative_path;
let local_path = self.config.local_root.join(relative); 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() { if let Some(parent) = local_path.parent() {
let _ = tokio::fs::create_dir_all(parent).await; let _ = tokio::fs::create_dir_all(parent).await;
} }
if let Some(ref remote) = action.remote_entry { if let Some(ref remote) = action.remote_entry {
if remote.is_dir { if remote.is_dir {
#[cfg(not(feature = "linux-fuse"))]
let _ = tokio::fs::create_dir_all(&local_path).await; let _ = tokio::fs::create_dir_all(&local_path).await;
} else { } else {
#[cfg(feature = "windows-cfapi")] #[cfg(feature = "windows-cfapi")]
@@ -870,7 +946,11 @@ impl Worker {
let _ = tokio::fs::write(&local_path, []).await; 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; let _ = tokio::fs::write(&local_path, []).await;
tracing::warn!( tracing::warn!(
@@ -902,6 +982,8 @@ impl Worker {
} }
let _ = self.db.increment_task_completed(tid).await; let _ = self.db.increment_task_completed(tid).await;
self.emit_item_updated(tid, relative, "create_placeholder", "completed")
.await;
let _ = self let _ = self
.db .db
.update_task_item_status_by_path( .update_task_item_status_by_path(
@@ -913,7 +995,10 @@ impl Worker {
) )
.await; .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<Result<()>>)> = let mut download_handles: Vec<(String, tokio::task::JoinHandle<Result<()>>)> =
Vec::new(); Vec::new();
for action in &self.plan.downloads { for action in &self.plan.downloads {
@@ -951,6 +1036,8 @@ impl Worker {
Ok(Ok(_)) => { Ok(Ok(_)) => {
summary.downloaded += 1; summary.downloaded += 1;
let _ = self.db.increment_task_completed(tid).await; let _ = self.db.increment_task_completed(tid).await;
self.emit_item_updated(tid, &rel_path, "download", "completed")
.await;
let _ = self let _ = self
.db .db
.update_task_item_status_by_path( .update_task_item_status_by_path(
@@ -966,6 +1053,8 @@ impl Worker {
tracing::error!("[{}] 下载失败: {}: {}", tid, rel_path, e); tracing::error!("[{}] 下载失败: {}: {}", tid, rel_path, e);
summary.failed += 1; summary.failed += 1;
let _ = self.db.increment_task_failed(tid).await; let _ = self.db.increment_task_failed(tid).await;
self.emit_item_updated(tid, &rel_path, "download", "failed")
.await;
let _ = self let _ = self
.db .db
.update_task_item_status_by_path( .update_task_item_status_by_path(
@@ -981,6 +1070,8 @@ impl Worker {
tracing::error!("[{}] 下载任务异常: {}: {}", tid, rel_path, e); tracing::error!("[{}] 下载任务异常: {}: {}", tid, rel_path, e);
summary.failed += 1; summary.failed += 1;
let _ = self.db.increment_task_failed(tid).await; let _ = self.db.increment_task_failed(tid).await;
self.emit_item_updated(tid, &rel_path, "download", "failed")
.await;
let _ = self let _ = self
.db .db
.update_task_item_status_by_path( .update_task_item_status_by_path(
@@ -998,8 +1089,12 @@ impl Worker {
} }
/// 6. 删除本地文件(DownloadOnly / Full / MirrorWcf — 远程删除触发的本地删除) /// 6. 删除本地文件(DownloadOnly / Full / MirrorWcf — 远程删除触发的本地删除)
/// AlbumDownload 跳过:远程删除不应删除本地相册照片
async fn step_delete_local(&self, summary: &mut SyncSummary) { 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; return;
} }
let tid = &self.task_id; let tid = &self.task_id;
@@ -1015,6 +1110,13 @@ impl Worker {
Ok(_) => { Ok(_) => {
summary.deleted_local += 1; summary.deleted_local += 1;
let _ = self.db.increment_task_completed(tid).await; 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); tracing::info!("[{}] 删除本地: {}", tid, action.relative_path);
let _ = self let _ = self
.db .db
@@ -1056,7 +1158,10 @@ impl Worker {
/// 6.5 本地重命名(远程触发 → 本地执行 rename) /// 6.5 本地重命名(远程触发 → 本地执行 rename)
async fn step_rename_local(&self, summary: &mut SyncSummary) { 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; return;
} }
let tid = &self.task_id; let tid = &self.task_id;
@@ -1081,6 +1186,8 @@ impl Worker {
Ok(_) => { Ok(_) => {
summary.renamed += 1; summary.renamed += 1;
let _ = self.db.increment_task_completed(tid).await; let _ = self.db.increment_task_completed(tid).await;
self.emit_item_updated(tid, &action.new_relative_path, "rename", "completed")
.await;
tracing::info!( tracing::info!(
"[{}] 本地重命名: {} -> {}", "[{}] 本地重命名: {} -> {}",
tid, tid,
@@ -1133,7 +1240,10 @@ impl Worker {
/// 6.6 本地移动(远程触发 → 本地执行 move) /// 6.6 本地移动(远程触发 → 本地执行 move)
async fn step_move_local(&self, summary: &mut SyncSummary) { 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; return;
} }
let tid = &self.task_id; let tid = &self.task_id;
@@ -1158,6 +1268,8 @@ impl Worker {
Ok(_) => { Ok(_) => {
summary.moved += 1; summary.moved += 1;
let _ = self.db.increment_task_completed(tid).await; let _ = self.db.increment_task_completed(tid).await;
self.emit_item_updated(tid, &action.new_relative_path, "move", "completed")
.await;
tracing::info!( tracing::info!(
"[{}] 本地移动: {} -> {}", "[{}] 本地移动: {} -> {}",
tid, tid,
@@ -1208,7 +1320,23 @@ impl Worker {
} }
} }
/// 7. 删除远程文件(Full 始终删除;MirrorWcf 仅在 WcfDeleteMode::SyncRemote 时删除) // 上传失败后清理远端碎片:删除远端可能已创建的部分文件
// delete_files 内部遇到锁冲突(40073)会自动解锁后重试
// 删除前先插入 suppress_paths,防止 SSE 删除事件误删本地文件
async fn cleanup_failed_upload(&self, relative_path: &str) {
let remote_uri = format!("{}/{}", self.config.remote_root, relative_path);
let tid = &self.task_id;
// 先标记抑制,30s 内 SSE 删除事件不会删本地文件
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),
Err(e) => tracing::debug!("[{}] 上传失败清理-删除失败(可能不存在): {}", tid, e),
}
}
async fn step_delete_remote(&self, summary: &mut SyncSummary) { async fn step_delete_remote(&self, summary: &mut SyncSummary) {
let should_delete = matches!(self.config.sync_mode, SyncMode::Full) let should_delete = matches!(self.config.sync_mode, SyncMode::Full)
|| (matches!(self.config.sync_mode, SyncMode::MirrorWcf) || (matches!(self.config.sync_mode, SyncMode::MirrorWcf)
@@ -1229,8 +1357,15 @@ impl Worker {
match self.api.delete_files(&remote_uris).await { match self.api.delete_files(&remote_uris).await {
Ok(_) => { Ok(_) => {
summary.deleted_remote += remote_uris.len() as u32; 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; 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 { for uri in &remote_uris {
tracing::info!("[{}] 删除远程: {}", tid, uri); tracing::info!("[{}] 删除远程: {}", tid, uri);
+26 -2
View File
@@ -289,6 +289,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.0.2" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -361,6 +377,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.1.1" 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: flutter:
dependency: "direct main" dependency: "direct main"
description: flutter description: flutter
@@ -1177,10 +1201,10 @@ packages:
dependency: "direct main" dependency: "direct main"
description: description:
name: pdfrx name: pdfrx
sha256: "0caa00aca2032cee5755873e88af849448534b95680895bfc57746dc3b4aea0d" sha256: "6b3571565fb412fb7d0a325a76154d0685bd0a0659c3f41ee007f408d48cfd46"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.3.3" version: "2.4.3"
pdfrx_engine: pdfrx_engine:
dependency: transitive dependency: transitive
description: description:
+4 -2
View File
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 1.3.3+4 version: 1.3.6+6
environment: environment:
sdk: ^3.11.4 sdk: ^3.11.4
@@ -56,7 +56,7 @@ dependencies:
# 预览功能 # 预览功能
flutter_cache_manager: ^3.4.1 flutter_cache_manager: ^3.4.1
cached_network_image: ^3.3.1 cached_network_image: ^3.3.1
pdfrx: ^2.2.24 pdfrx: ^2.4.1
media_kit: ^1.2.6 media_kit: ^1.2.6
media_kit_video: ^2.0.1 # For video rendering. media_kit_video: ^2.0.1 # For video rendering.
media_kit_libs_video: ^1.0.7 # Native video dependencies. media_kit_libs_video: ^1.0.7 # Native video dependencies.
@@ -69,6 +69,7 @@ dependencies:
# 下载 # 下载
background_downloader: 9.5.4 background_downloader: 9.5.4
path_provider: ^2.1.1 path_provider: ^2.1.1
external_path: ^2.2.0
permission_handler: ^11.1.0 permission_handler: ^11.1.0
url_launcher: ^6.2.1 url_launcher: ^6.2.1
open_file: ^3.5.11 open_file: ^3.5.11
@@ -109,6 +110,7 @@ dependencies:
freezed_annotation: ^3.1.0 freezed_annotation: ^3.1.0
flutter_rust_bridge: ^2.12.0 flutter_rust_bridge: ^2.12.0
uuid: ^4.5.3 uuid: ^4.5.3
fl_chart: ^1.2.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
+143
View File
@@ -0,0 +1,143 @@
import 'package:cloudreve4_flutter/data/models/user_model.dart';
import 'package:cloudreve4_flutter/services/custom_line_service.dart';
import 'package:cloudreve4_flutter/services/server_service.dart';
import 'package:cloudreve4_flutter/services/storage_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
void main() {
test('selected node json round trip preserves rewrite fields', () {
const node = SelectedCustomLineNode(
id: 'node-1',
name: 'HK 01',
ip: '203.0.113.10',
host: 'node.example.com',
protocol: 'https',
port: 443,
);
final restored = SelectedCustomLineNode.fromJson(node.toJson());
expect(restored.id, 'node-1');
expect(restored.name, 'HK 01');
expect(restored.ip, '203.0.113.10');
expect(restored.host, 'node.example.com');
expect(restored.protocol, 'https');
expect(restored.port, 443);
expect(restored.canRewriteDownloadUrl, isTrue);
});
test('download route defaults to unchanged', () {
const route = CustomLineDownloadRoute(url: 'https://pan.example.com/file');
expect(route.changed, isFalse);
expect(route.headers, isEmpty);
});
test(
'routeDownloadUrl keeps url unchanged and enables host mapping proxy',
() async {
SharedPreferences.setMockInitialValues({});
await ServerService.instance.init();
await ServerService.instance.updateCurrentServerLogin(
user: UserModel(
id: 'user-1',
nickname: 'VIP',
createdAt: DateTime(2026, 1),
group: GroupModel(id: 'vip', name: 'VIP'),
),
);
await CustomLineService.instance.setSelectedNode(
const SelectedCustomLineNode(
id: 'node-1',
name: 'HK 01',
ip: '203.0.113.10',
host: 'node.example.com',
),
);
final route = await CustomLineService.instance.routeDownloadUrl(
'https://storage.example.com:8443/download/file.txt?token=abc',
);
expect(
route.url,
'https://storage.example.com:8443/download/file.txt?token=abc',
);
expect(route.headers, isEmpty);
expect(route.changed, isTrue);
expect(route.originalHost, 'storage.example.com');
expect(route.proxy, isNotNull);
expect(route.proxy!.host, '127.0.0.1');
expect(route.proxy!.port, greaterThan(0));
await CustomLineService.instance.clearSelectedNode();
await StorageService.instance.clear();
},
);
test('routeDownloadUrl stays unchanged for User group', () async {
SharedPreferences.setMockInitialValues({});
await ServerService.instance.init();
await ServerService.instance.updateCurrentServerLogin(
user: UserModel(
id: 'user-2',
nickname: 'Normal',
createdAt: DateTime(2026, 1),
group: GroupModel(id: 'user', name: 'User'),
),
);
await CustomLineService.instance.setSelectedNode(
const SelectedCustomLineNode(
id: 'node-1',
name: 'HK 01',
ip: '203.0.113.10',
host: 'node.example.com',
),
);
final route = await CustomLineService.instance.routeDownloadUrl(
'https://storage.example.com/download/file.txt',
);
expect(route.url, 'https://storage.example.com/download/file.txt');
expect(route.headers, isEmpty);
expect(route.changed, isFalse);
await CustomLineService.instance.clearSelectedNode();
await StorageService.instance.clear();
});
test('routeDownloadUrl stays unchanged after login is cleared', () async {
SharedPreferences.setMockInitialValues({});
await ServerService.instance.init();
await ServerService.instance.updateCurrentServerLogin(
user: UserModel(
id: 'user-3',
nickname: 'VIP',
createdAt: DateTime(2026, 1),
group: GroupModel(id: 'vip', name: 'VIP'),
),
);
await CustomLineService.instance.setSelectedNode(
const SelectedCustomLineNode(
id: 'node-1',
name: 'HK 01',
ip: '203.0.113.10',
host: 'node.example.com',
),
);
await ServerService.instance.clearCurrentServerLogin();
final route = await CustomLineService.instance.routeDownloadUrl(
'https://storage.example.com/download/file.txt',
);
expect(route.url, 'https://storage.example.com/download/file.txt');
expect(route.headers, isEmpty);
expect(route.changed, isFalse);
await CustomLineService.instance.clearSelectedNode();
await StorageService.instance.clear();
});
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:cloudreve4_flutter/services/update_service.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
group('UpdateService', () {
test(
'checkForUpdate releases checking flag when package info fails',
() async {
var calls = 0;
final service = UpdateService.test(
packageInfoProvider: () async {
calls += 1;
throw Exception('package info unavailable');
},
);
expect(await service.checkForUpdate(), isNull);
expect(await service.checkForUpdate(), isNull);
expect(calls, 2);
},
);
});
}