Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
db10873aaf
|
|||
|
e6c60a9d6d
|
|||
|
33509de4e3
|
|||
|
42db3f9683
|
|||
|
eef561796a
|
|||
|
b02daf1448
|
|||
| edcde96051 | |||
|
5ee6ba1e28
|
|||
|
61ad85f6fc
|
|||
|
8dfc22691a
|
|||
| c583c51d80 | |||
|
4d6ca139e5
|
|||
|
81864c99c2
|
|||
|
fb5fd6c9bc
|
|||
|
c208a06af5
|
|||
|
c2db8b38e9
|
|||
|
24d20cbfcb
|
|||
|
12f2c2660e
|
|||
|
98af110531
|
|||
| 886046a72b | |||
| d20f7790e3 |
@@ -12,6 +12,10 @@ permissions:
|
|||||||
contents: write
|
contents: write
|
||||||
releases: write
|
releases: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||||
|
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
android:
|
android:
|
||||||
name: Build Android APK
|
name: Build Android APK
|
||||||
@@ -113,7 +117,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 +144,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 +157,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 +202,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 +210,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 +260,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 +276,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 +364,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" \
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ migrate_working_dir/
|
|||||||
.frontend/
|
.frontend/
|
||||||
|
|
||||||
# custom
|
# custom
|
||||||
|
.claude/
|
||||||
CLAUDE.md
|
CLAUDE.md
|
||||||
claude_project.md
|
claude_project.md
|
||||||
PROJECT_REQUIREMENTS.md
|
PROJECT_REQUIREMENTS.md
|
||||||
@@ -65,8 +66,10 @@ 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/*
|
||||||
|
|
||||||
# The .vscode folder contains launch configuration and tasks you configure in
|
# The .vscode folder contains launch configuration and tasks you configure in
|
||||||
# VS Code which you may wish to be included in version control, so this line
|
# VS Code which you may wish to be included in version control, so this line
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
keystoreProperties.load(FileInputStream(keystorePropertiesFile))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val releaseStoreFile = keystoreProperties.getProperty("storeFile")?.let { rootProject.file(it) }
|
||||||
|
val hasReleaseSigning = listOf(
|
||||||
|
"keyAlias",
|
||||||
|
"keyPassword",
|
||||||
|
"storePassword",
|
||||||
|
).all { !keystoreProperties.getProperty(it).isNullOrBlank() } &&
|
||||||
|
releaseStoreFile?.exists() == true
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.limo.cloudreve4_flutter"
|
namespace = "com.limo.cloudreve4_flutter"
|
||||||
compileSdk = getLocalProperty("flutter.compileSdkVersion", 36)
|
compileSdk = getLocalProperty("flutter.compileSdkVersion", 36)
|
||||||
@@ -38,10 +46,12 @@ android {
|
|||||||
// 2. 配置签名选项
|
// 2. 配置签名选项
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
create("release") {
|
create("release") {
|
||||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
if (hasReleaseSigning) {
|
||||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||||
storePassword = keystoreProperties["storePassword"] as String?
|
storeFile = releaseStoreFile
|
||||||
|
storePassword = keystoreProperties.getProperty("storePassword")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,15 +64,35 @@ android {
|
|||||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
jniLibs.srcDirs("src/main/jniLibs")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
|
||||||
applicationId = "com.limo.cloudreve4_flutter"
|
applicationId = "com.limo.cloudreve4_flutter"
|
||||||
|
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||||
// You can update the following values to match your application needs.
|
// You can update the following values to match your application needs.
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||||
minSdk = getLocalProperty("flutter.minSdkVersion", 24)
|
minSdk = getLocalProperty("flutter.minSdkVersion", 24)
|
||||||
targetSdk = getLocalProperty("flutter.targetSdkVersion", 35)
|
targetSdk = getLocalProperty("flutter.targetSdkVersion", 35)
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
|
|
||||||
|
ndk {
|
||||||
|
// 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
@@ -76,7 +106,7 @@ android {
|
|||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
// 1. 将原来的 getByName("debug") 替换为 getByName("release")
|
// 1. 将原来的 getByName("debug") 替换为 getByName("release")
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName(if (hasReleaseSigning) "release" else "debug")
|
||||||
|
|
||||||
// 2. 建议同时开启混淆,这能大幅减小网盘应用的体积并保护代码
|
// 2. 建议同时开启混淆,这能大幅减小网盘应用的体积并保护代码
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
org.gradle.jvmargs=-Xmx3072m -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||||
|
org.gradle.workers.max=2
|
||||||
|
org.gradle.vfs.watch=false
|
||||||
|
kotlin.compiler.execution.strategy=in-process
|
||||||
|
kotlin.incremental=false
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,8 +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 =
|
||||||
|
'site_announcement_dismissed_fingerprint';
|
||||||
|
|
||||||
|
// 版本更新
|
||||||
|
static const String updatePromptSkipUntil = 'update_prompt_skip_until';
|
||||||
|
static const String updatePromptSkipVersion = 'update_prompt_skip_version';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,12 +55,42 @@ class FileUtils {
|
|||||||
'bmp',
|
'bmp',
|
||||||
'svg',
|
'svg',
|
||||||
'heic',
|
'heic',
|
||||||
|
'heif',
|
||||||
|
'avif',
|
||||||
|
'tif',
|
||||||
|
'tiff',
|
||||||
];
|
];
|
||||||
return imageExtensions.contains(getFileExtension(fileName));
|
return imageExtensions.contains(getFileExtension(fileName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isFlutterRenderableImageFile(String fileName) {
|
||||||
|
const renderableImageExtensions = [
|
||||||
|
'jpg',
|
||||||
|
'jpeg',
|
||||||
|
'png',
|
||||||
|
'gif',
|
||||||
|
'webp',
|
||||||
|
'bmp',
|
||||||
|
];
|
||||||
|
return renderableImageExtensions.contains(getFileExtension(fileName));
|
||||||
|
}
|
||||||
|
|
||||||
static bool isVideoFile(String fileName) {
|
static bool isVideoFile(String fileName) {
|
||||||
const videoExtensions = ['mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv'];
|
const videoExtensions = [
|
||||||
|
'mp4',
|
||||||
|
'webm',
|
||||||
|
'mkv',
|
||||||
|
'avi',
|
||||||
|
'mov',
|
||||||
|
'flv',
|
||||||
|
'wmv',
|
||||||
|
'm4v',
|
||||||
|
'mpg',
|
||||||
|
'mpeg',
|
||||||
|
'3gp',
|
||||||
|
'ts',
|
||||||
|
'm2ts',
|
||||||
|
];
|
||||||
return videoExtensions.contains(getFileExtension(fileName));
|
return videoExtensions.contains(getFileExtension(fileName));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,6 +99,17 @@ class FileUtils {
|
|||||||
return audioExtensions.contains(getFileExtension(fileName));
|
return audioExtensions.contains(getFileExtension(fileName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isPsdFile(String fileName) {
|
||||||
|
const psdExtensions = ['psd', 'psb'];
|
||||||
|
return psdExtensions.contains(getFileExtension(fileName));
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isThumbnailableFile(String fileName) {
|
||||||
|
final ext = getFileExtension(fileName);
|
||||||
|
if (ext.isEmpty || ext == 'svg') return false;
|
||||||
|
return isImageFile(fileName) || isVideoFile(fileName) || isPsdFile(fileName);
|
||||||
|
}
|
||||||
|
|
||||||
static bool isPdfFile(String fileName) {
|
static bool isPdfFile(String fileName) {
|
||||||
return getFileExtension(fileName) == 'pdf';
|
return getFileExtension(fileName) == 'pdf';
|
||||||
}
|
}
|
||||||
@@ -155,13 +196,33 @@ class FileUtils {
|
|||||||
'gif': 'image/gif',
|
'gif': 'image/gif',
|
||||||
'webp': 'image/webp',
|
'webp': 'image/webp',
|
||||||
'svg': 'image/svg+xml',
|
'svg': 'image/svg+xml',
|
||||||
|
'bmp': 'image/bmp',
|
||||||
|
'heic': 'image/heic',
|
||||||
|
'heif': 'image/heif',
|
||||||
|
'avif': 'image/avif',
|
||||||
|
'tif': 'image/tiff',
|
||||||
|
'tiff': 'image/tiff',
|
||||||
|
'psd': 'image/vnd.adobe.photoshop',
|
||||||
|
'psb': 'image/vnd.adobe.photoshop',
|
||||||
'mp4': 'video/mp4',
|
'mp4': 'video/mp4',
|
||||||
'webm': 'video/webm',
|
'webm': 'video/webm',
|
||||||
'mkv': 'video/x-matroska',
|
'mkv': 'video/x-matroska',
|
||||||
'avi': 'video/x-msvideo',
|
'avi': 'video/x-msvideo',
|
||||||
|
'mov': 'video/quicktime',
|
||||||
|
'flv': 'video/x-flv',
|
||||||
|
'wmv': 'video/x-ms-wmv',
|
||||||
|
'm4v': 'video/x-m4v',
|
||||||
|
'mpg': 'video/mpeg',
|
||||||
|
'mpeg': 'video/mpeg',
|
||||||
|
'3gp': 'video/3gpp',
|
||||||
|
'ts': 'video/mp2t',
|
||||||
|
'm2ts': 'video/mp2t',
|
||||||
'mp3': 'audio/mpeg',
|
'mp3': 'audio/mpeg',
|
||||||
'wav': 'audio/wav',
|
'wav': 'audio/wav',
|
||||||
'ogg': 'audio/ogg',
|
'ogg': 'audio/ogg',
|
||||||
|
'flac': 'audio/flac',
|
||||||
|
'aac': 'audio/aac',
|
||||||
|
'm4a': 'audio/mp4',
|
||||||
'pdf': 'application/pdf',
|
'pdf': 'application/pdf',
|
||||||
'txt': 'text/plain',
|
'txt': 'text/plain',
|
||||||
'json': 'application/json',
|
'json': 'application/json',
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import 'dart:ffi';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:ffi/ffi.dart';
|
||||||
|
|
||||||
|
/// 通过 Win32 SetEnvironmentVariableW 设置/清除进程环境变量
|
||||||
|
/// 仅 Windows 有效,其他平台直接返回 false
|
||||||
|
bool winSetEnvVar(String name, String? value) {
|
||||||
|
if (!Platform.isWindows) return false;
|
||||||
|
|
||||||
|
final dylib = DynamicLibrary.open('kernel32.dll');
|
||||||
|
final fn = dylib.lookupFunction<
|
||||||
|
Int32 Function(Pointer<Utf16>, Pointer<Utf16>),
|
||||||
|
int Function(Pointer<Utf16>, Pointer<Utf16>)>(
|
||||||
|
'SetEnvironmentVariableW',
|
||||||
|
);
|
||||||
|
|
||||||
|
final namePtr = name.toNativeUtf16();
|
||||||
|
final valuePtr = value != null ? value.toNativeUtf16() : nullptr;
|
||||||
|
try {
|
||||||
|
return fn(namePtr, valuePtr) != 0;
|
||||||
|
} finally {
|
||||||
|
calloc.free(namePtr);
|
||||||
|
if (value != null) calloc.free(valuePtr);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -1,7 +1,59 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
|
||||||
|
import 'package:cloudreve4_flutter/core/utils/win_env.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:webview_flutter/webview_flutter.dart';
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart' as mobile;
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
// WebView 代理配置(仅 Windows,无认证)
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
class CaptchaProxyConfig {
|
||||||
|
final String host;
|
||||||
|
final int port;
|
||||||
|
|
||||||
|
const CaptchaProxyConfig({required this.host, required this.port});
|
||||||
|
|
||||||
|
String get proxyArg => '--proxy-server=http://$host:$port';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => '$host:$port';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
// WebView2 代理环境变量管理
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
const _envVarName = 'WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS';
|
||||||
|
|
||||||
|
/// 为 WebView2 设置代理环境变量(进程级,不影响其他程序)
|
||||||
|
void _applyWebView2Proxy(CaptchaProxyConfig? proxy) {
|
||||||
|
if (!Platform.isWindows) return;
|
||||||
|
|
||||||
|
if (proxy != null) {
|
||||||
|
final existing = Platform.environment[_envVarName];
|
||||||
|
final newValue = existing != null && existing.isNotEmpty
|
||||||
|
? '$existing ${proxy.proxyArg}'
|
||||||
|
: proxy.proxyArg;
|
||||||
|
winSetEnvVar(_envVarName, newValue);
|
||||||
|
AppLogger.i('WebView2 代理环境变量已设置: $newValue');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 清除 WebView2 代理环境变量
|
||||||
|
void _clearWebView2Proxy() {
|
||||||
|
if (!Platform.isWindows) return;
|
||||||
|
winSetEnvVar(_envVarName, null);
|
||||||
|
AppLogger.i('WebView2 代理环境变量已清除');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
// CaptchaWebConfig
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
|
||||||
class CaptchaWebConfig {
|
class CaptchaWebConfig {
|
||||||
final String type;
|
final String type;
|
||||||
@@ -21,12 +73,20 @@ class CaptchaWebConfig {
|
|||||||
const CaptchaWebConfig.recaptchaV2({
|
const CaptchaWebConfig.recaptchaV2({
|
||||||
required String siteKey,
|
required String siteKey,
|
||||||
String displayName = 'reCAPTCHA V2',
|
String displayName = 'reCAPTCHA V2',
|
||||||
}) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey);
|
}) : this._(
|
||||||
|
type: 'recaptcha',
|
||||||
|
displayName: displayName,
|
||||||
|
siteKey: siteKey,
|
||||||
|
);
|
||||||
|
|
||||||
const CaptchaWebConfig.turnstile({
|
const CaptchaWebConfig.turnstile({
|
||||||
required String siteKey,
|
required String siteKey,
|
||||||
String displayName = 'Cloudflare Turnstile',
|
String displayName = 'Cloudflare Turnstile',
|
||||||
}) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey);
|
}) : this._(
|
||||||
|
type: 'turnstile',
|
||||||
|
displayName: displayName,
|
||||||
|
siteKey: siteKey,
|
||||||
|
);
|
||||||
|
|
||||||
const CaptchaWebConfig.cap({
|
const CaptchaWebConfig.cap({
|
||||||
required String instanceUrl,
|
required String instanceUrl,
|
||||||
@@ -42,14 +102,28 @@ class CaptchaWebConfig {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 桌面端判断 ───────────────────────────────────────
|
||||||
|
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
|
||||||
|
|
||||||
|
// ─── 桌面 User-Agent ──────────────────────────────────
|
||||||
|
const _desktopUserAgent =
|
||||||
|
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||||
|
'(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
// CaptchaChallengePage
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
|
||||||
class CaptchaChallengePage extends StatefulWidget {
|
class CaptchaChallengePage extends StatefulWidget {
|
||||||
final CaptchaWebConfig config;
|
final CaptchaWebConfig config;
|
||||||
final String baseUrl;
|
final String baseUrl;
|
||||||
|
final CaptchaProxyConfig? proxyConfig;
|
||||||
|
|
||||||
const CaptchaChallengePage({
|
const CaptchaChallengePage({
|
||||||
super.key,
|
super.key,
|
||||||
required this.config,
|
required this.config,
|
||||||
required this.baseUrl,
|
required this.baseUrl,
|
||||||
|
this.proxyConfig,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -57,18 +131,40 @@ class CaptchaChallengePage extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||||
late final WebViewController _controller;
|
// ── 移动端 ──
|
||||||
|
mobile.WebViewController? _mobileController;
|
||||||
|
|
||||||
|
// ── 桌面端 ──
|
||||||
|
InAppWebViewController? _desktopController;
|
||||||
|
Key _desktopKey = UniqueKey();
|
||||||
|
|
||||||
|
// ── 共享状态 ──
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
int _progress = 0;
|
int _progress = 0;
|
||||||
String? _errorMessage;
|
String? _errorMessage;
|
||||||
String? _statusText;
|
String? _statusText;
|
||||||
|
bool _disposed = false;
|
||||||
|
|
||||||
|
// ── 是否设置了代理环境变量(用于清理时判断)──
|
||||||
|
bool _proxyEnvSet = false;
|
||||||
|
|
||||||
|
// ── HTML ──
|
||||||
|
late String _currentHtml;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_currentHtml = _buildHtml(widget.config);
|
||||||
|
|
||||||
_controller = WebViewController()
|
// Windows: 在 WebView2 创建前设置代理环境变量
|
||||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
if (_isDesktop && widget.proxyConfig != null && Platform.isWindows) {
|
||||||
|
_applyWebView2Proxy(widget.proxyConfig);
|
||||||
|
_proxyEnvSet = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isDesktop) {
|
||||||
|
_mobileController = mobile.WebViewController()
|
||||||
|
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
|
||||||
..setBackgroundColor(Colors.transparent)
|
..setBackgroundColor(Colors.transparent)
|
||||||
..addJavaScriptChannel(
|
..addJavaScriptChannel(
|
||||||
'CaptchaBridge',
|
'CaptchaBridge',
|
||||||
@@ -77,7 +173,7 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
..setNavigationDelegate(
|
..setNavigationDelegate(
|
||||||
NavigationDelegate(
|
mobile.NavigationDelegate(
|
||||||
onProgress: (progress) {
|
onProgress: (progress) {
|
||||||
if (mounted) setState(() => _progress = progress);
|
if (mounted) setState(() => _progress = progress);
|
||||||
},
|
},
|
||||||
@@ -88,30 +184,45 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
_errorMessage = '${error.errorCode}: ${error.description}'
|
_errorMessage =
|
||||||
.trim();
|
'${error.errorCode}: ${error.description}'.trim();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
)
|
||||||
|
..loadHtmlString(_currentHtml, baseUrl: widget.baseUrl);
|
||||||
_loadCaptcha();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 加载 / 刷新 ────────────────────────────────────
|
||||||
|
|
||||||
Future<void> _loadCaptcha() async {
|
Future<void> _loadCaptcha() async {
|
||||||
|
if (_disposed) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_isLoading = true;
|
_isLoading = true;
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_statusText = null;
|
_statusText = null;
|
||||||
_progress = 0;
|
_progress = 0;
|
||||||
|
_currentHtml = _buildHtml(widget.config);
|
||||||
});
|
});
|
||||||
|
|
||||||
final html = _buildHtml(widget.config);
|
if (!_isDesktop && _mobileController != null) {
|
||||||
await _controller.loadHtmlString(html, baseUrl: widget.baseUrl);
|
await _mobileController!.loadHtmlString(
|
||||||
|
_currentHtml,
|
||||||
|
baseUrl: widget.baseUrl,
|
||||||
|
);
|
||||||
|
} else if (_isDesktop) {
|
||||||
|
setState(() => _desktopKey = UniqueKey());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Bridge 消息处理 ─────────────────────────────────
|
||||||
|
|
||||||
void _handleBridgeMessage(String rawMessage) {
|
void _handleBridgeMessage(String rawMessage) {
|
||||||
|
AppLogger.d(
|
||||||
|
'Bridge 收到消息: ${rawMessage.length > 100 ? "${rawMessage.substring(0, 100)}..." : rawMessage}',
|
||||||
|
);
|
||||||
try {
|
try {
|
||||||
final decoded = jsonDecode(rawMessage);
|
final decoded = jsonDecode(rawMessage);
|
||||||
if (decoded is! Map) return;
|
if (decoded is! Map) return;
|
||||||
@@ -120,8 +231,18 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
|
|
||||||
if (type == 'success') {
|
if (type == 'success') {
|
||||||
final token = decoded['token']?.toString() ?? '';
|
final token = decoded['token']?.toString() ?? '';
|
||||||
if (token.isNotEmpty && mounted) {
|
final jsTs = decoded['_jsTs'];
|
||||||
|
if (jsTs is num) {
|
||||||
|
final delayMs = DateTime.now().millisecondsSinceEpoch - jsTs.toInt();
|
||||||
|
AppLogger.d('Bridge 收到 success, JS→Dart 传输延迟=${delayMs}ms, token长度=${token.length}');
|
||||||
|
} else {
|
||||||
|
AppLogger.d('Bridge 收到 success, token长度=${token.length}');
|
||||||
|
}
|
||||||
|
if (token.isNotEmpty && mounted && !_disposed) {
|
||||||
|
_disposed = true;
|
||||||
|
AppLogger.d('准备 pop 返回登录页');
|
||||||
Navigator.of(context).pop(token);
|
Navigator.of(context).pop(token);
|
||||||
|
AppLogger.d('pop 完成');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -130,7 +251,8 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
final progress = decoded['progress']?.toString();
|
final progress = decoded['progress']?.toString();
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_statusText = progress == null ? '正在验证...' : '正在验证... $progress';
|
_statusText =
|
||||||
|
progress == null ? '正在验证...' : '正在验证... $progress';
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
@@ -145,6 +267,11 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type == 'debug') {
|
||||||
|
AppLogger.d('JS debug: ${decoded['message']}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (type == 'expired') {
|
if (type == 'expired') {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -153,25 +280,131 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// 忽略非 JSON 消息。
|
}
|
||||||
|
|
||||||
|
// ─── WebView 销毁 ───────────────────────────────────
|
||||||
|
|
||||||
|
void _cleanupWebView() {
|
||||||
|
if (_isDesktop) {
|
||||||
|
final ctrl = _desktopController;
|
||||||
|
_desktopController = null;
|
||||||
|
AppLogger.d('开始清理 WebView controller');
|
||||||
|
ctrl?.dispose();
|
||||||
|
AppLogger.d('WebView controller 已 dispose');
|
||||||
|
if (_proxyEnvSet) {
|
||||||
|
_clearWebView2Proxy();
|
||||||
|
_proxyEnvSet = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
AppLogger.d('CaptchaChallengePage dispose 开始');
|
||||||
|
_disposed = true;
|
||||||
|
_cleanupWebView();
|
||||||
|
super.dispose();
|
||||||
|
AppLogger.d('CaptchaChallengePage dispose 完成');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
// HTML 生成
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
|
||||||
String _buildHtml(CaptchaWebConfig config) {
|
String _buildHtml(CaptchaWebConfig config) {
|
||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
case 'turnstile':
|
case 'turnstile':
|
||||||
return _buildTurnstileHtml(config.siteKey!);
|
return _baseHtml(
|
||||||
|
title: 'Cloudflare Turnstile',
|
||||||
|
body: '<div id="widget"></div>',
|
||||||
|
script: '''
|
||||||
|
function onTurnstileLoad() {
|
||||||
|
try {
|
||||||
|
turnstile.render('#widget', {
|
||||||
|
sitekey: '${_js(config.siteKey!)}',
|
||||||
|
callback: function(token) { solved(token); },
|
||||||
|
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
|
||||||
|
'expired-callback': function() { expired(); },
|
||||||
|
'after-interactive-callback': function() {
|
||||||
|
sendBridge({ type: 'debug', message: 'after-interactive fired' });
|
||||||
|
markStatus('正在与 Cloudflare 服务器验证,请稍候...', false);
|
||||||
|
sendBridge({ type: 'progress', progress: '服务器验证中' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
markStatus('请完成人机验证', false);
|
||||||
|
} catch (e) {
|
||||||
|
failed(e && e.message ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
|
||||||
|
<script>
|
||||||
|
''',
|
||||||
|
);
|
||||||
case 'recaptcha':
|
case 'recaptcha':
|
||||||
return _buildRecaptchaHtml(config.siteKey!);
|
return _baseHtml(
|
||||||
|
title: 'reCAPTCHA V2',
|
||||||
|
body: '<div id="widget"></div>',
|
||||||
|
script: '''
|
||||||
|
function onRecaptchaLoad() {
|
||||||
|
try {
|
||||||
|
grecaptcha.render('widget', {
|
||||||
|
sitekey: '${_js(config.siteKey!)}',
|
||||||
|
callback: function(token) { solved(token); },
|
||||||
|
'expired-callback': function() { expired(); },
|
||||||
|
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
|
||||||
|
});
|
||||||
|
markStatus('请完成人机验证', false);
|
||||||
|
} catch (e) {
|
||||||
|
failed(e && e.message ? e.message : String(e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
|
||||||
|
<script>
|
||||||
|
''',
|
||||||
|
);
|
||||||
case 'cap':
|
case 'cap':
|
||||||
return _buildCapHtml(
|
final endpoint = _capEndpoint(config.instanceUrl!, config.siteKey!);
|
||||||
instanceUrl: config.instanceUrl!,
|
final scriptUrl = _capWidgetScript(config.assetServer);
|
||||||
siteKey: config.siteKey!,
|
final safeEndpoint = const HtmlEscape().convert(endpoint);
|
||||||
assetServer: config.assetServer,
|
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
|
||||||
|
|
||||||
|
return _baseHtml(
|
||||||
|
title: 'Cap',
|
||||||
|
body:
|
||||||
|
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
|
||||||
|
script: '''
|
||||||
|
window.CAP_DISABLE_HAPTICS = true;
|
||||||
|
const cap = document.getElementById('cap');
|
||||||
|
if (cap) {
|
||||||
|
cap.addEventListener('solve', function(e) {
|
||||||
|
solved(e.detail && e.detail.token ? e.detail.token : '');
|
||||||
|
});
|
||||||
|
cap.addEventListener('progress', function(e) {
|
||||||
|
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
|
||||||
|
markStatus('正在验证... ' + progress, false);
|
||||||
|
sendBridge({ type: 'progress', progress: progress });
|
||||||
|
});
|
||||||
|
cap.addEventListener('error', function(e) {
|
||||||
|
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
|
||||||
|
failed(message);
|
||||||
|
});
|
||||||
|
markStatus('请完成人机验证', false);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<script type="module" src="$safeScriptUrl"></script>
|
||||||
|
<script>
|
||||||
|
''',
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return _buildErrorHtml('不支持的验证码类型: ${config.type}');
|
return _baseHtml(
|
||||||
|
title: '验证码错误',
|
||||||
|
body:
|
||||||
|
'<div id="widget" class="error">${const HtmlEscape().convert('不支持的验证码类型: ${config.type}')}</div>',
|
||||||
|
script: "failed('不支持的验证码类型');",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,8 +493,14 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
function sendBridge(payload) {
|
function sendBridge(payload) {
|
||||||
|
payload._jsTs = Date.now();
|
||||||
|
var json = JSON.stringify(payload);
|
||||||
try {
|
try {
|
||||||
CaptchaBridge.postMessage(JSON.stringify(payload));
|
if (typeof CaptchaBridge !== 'undefined' && CaptchaBridge.postMessage) {
|
||||||
|
CaptchaBridge.postMessage(json);
|
||||||
|
} else if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) {
|
||||||
|
window.flutter_inappwebview.callHandler('CaptchaBridge', json);
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
function markStatus(text, isError) {
|
function markStatus(text, isError) {
|
||||||
@@ -289,139 +528,9 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
''';
|
''';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _buildTurnstileHtml(String siteKey) {
|
// ═════════════════════════════════════════════════════
|
||||||
return _baseHtml(
|
// Widget 构建
|
||||||
title: 'Cloudflare Turnstile',
|
// ═════════════════════════════════════════════════════
|
||||||
body: '<div id="widget"></div>',
|
|
||||||
script:
|
|
||||||
'''
|
|
||||||
function onTurnstileLoad() {
|
|
||||||
try {
|
|
||||||
turnstile.render('#widget', {
|
|
||||||
sitekey: '${_js(siteKey)}',
|
|
||||||
callback: function(token) { solved(token); },
|
|
||||||
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
|
|
||||||
'expired-callback': function() { expired(); }
|
|
||||||
});
|
|
||||||
markStatus('请完成人机验证', false);
|
|
||||||
} catch (e) {
|
|
||||||
failed(e && e.message ? e.message : String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
|
|
||||||
<script>
|
|
||||||
''',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _buildRecaptchaHtml(String siteKey) {
|
|
||||||
return _baseHtml(
|
|
||||||
title: 'reCAPTCHA V2',
|
|
||||||
body: '<div id="widget"></div>',
|
|
||||||
script:
|
|
||||||
'''
|
|
||||||
function onRecaptchaLoad() {
|
|
||||||
try {
|
|
||||||
grecaptcha.render('widget', {
|
|
||||||
sitekey: '${_js(siteKey)}',
|
|
||||||
callback: function(token) { solved(token); },
|
|
||||||
'expired-callback': function() { expired(); },
|
|
||||||
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
|
|
||||||
});
|
|
||||||
markStatus('请完成人机验证', false);
|
|
||||||
} catch (e) {
|
|
||||||
failed(e && e.message ? e.message : String(e));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
|
|
||||||
<script>
|
|
||||||
''',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _buildCapHtml({
|
|
||||||
required String instanceUrl,
|
|
||||||
required String siteKey,
|
|
||||||
String? assetServer,
|
|
||||||
}) {
|
|
||||||
final endpoint = _capEndpoint(instanceUrl, siteKey);
|
|
||||||
final scriptUrl = _capWidgetScript(assetServer);
|
|
||||||
final safeEndpoint = const HtmlEscape().convert(endpoint);
|
|
||||||
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
|
|
||||||
|
|
||||||
return _baseHtml(
|
|
||||||
title: 'Cap',
|
|
||||||
body:
|
|
||||||
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
|
|
||||||
script:
|
|
||||||
'''
|
|
||||||
window.CAP_DISABLE_HAPTICS = true;
|
|
||||||
const cap = document.getElementById('cap');
|
|
||||||
if (cap) {
|
|
||||||
cap.addEventListener('solve', function(e) {
|
|
||||||
solved(e.detail && e.detail.token ? e.detail.token : '');
|
|
||||||
});
|
|
||||||
cap.addEventListener('progress', function(e) {
|
|
||||||
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
|
|
||||||
markStatus('正在验证... ' + progress, false);
|
|
||||||
sendBridge({ type: 'progress', progress: progress });
|
|
||||||
});
|
|
||||||
cap.addEventListener('error', function(e) {
|
|
||||||
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
|
|
||||||
failed(message);
|
|
||||||
});
|
|
||||||
markStatus('请完成人机验证', false);
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
<script type="module" src="$safeScriptUrl"></script>
|
|
||||||
<script>
|
|
||||||
''',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _buildErrorHtml(String message) {
|
|
||||||
return _baseHtml(
|
|
||||||
title: '验证码错误',
|
|
||||||
body:
|
|
||||||
'<div id="widget" class="error">${const HtmlEscape().convert(message)}</div>',
|
|
||||||
script: 'failed(${jsonEncode(message)});',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _capEndpoint(String instanceUrl, String siteKey) {
|
|
||||||
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
|
||||||
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
|
||||||
return '$trimmedInstance/$trimmedSiteKey/';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _capWidgetScript(String? assetServer) {
|
|
||||||
final asset = assetServer?.trim();
|
|
||||||
if (asset != null && asset.isNotEmpty) {
|
|
||||||
if (asset.startsWith('http://') || asset.startsWith('https://')) {
|
|
||||||
return asset;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asset.toLowerCase() == 'jsdelivr') {
|
|
||||||
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asset.toLowerCase() == 'unpkg') {
|
|
||||||
return 'https://unpkg.com/cap-widget';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
|
||||||
}
|
|
||||||
|
|
||||||
String _js(String input) {
|
|
||||||
return input
|
|
||||||
.replaceAll(r'\\', r'\\\\')
|
|
||||||
.replaceAll("'", r"\\'")
|
|
||||||
.replaceAll('\\n', r'\\n')
|
|
||||||
.replaceAll('\\r', r'\\r');
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -446,20 +555,11 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
WebViewWidget(controller: _controller),
|
_isDesktop ? _buildDesktopWebView() : _buildMobileWebView(),
|
||||||
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
if (_isLoading)
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
if (_errorMessage != null)
|
if (_errorMessage != null)
|
||||||
Align(
|
_buildOverlayBanner(
|
||||||
alignment: Alignment.bottomCenter,
|
|
||||||
child: SafeArea(
|
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
|
||||||
margin: const EdgeInsets.all(12),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.errorContainer,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
child: Text(
|
||||||
_errorMessage!,
|
_errorMessage!,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -467,30 +567,161 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
|||||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
),
|
|
||||||
)
|
)
|
||||||
else if (_statusText != null)
|
else if (_statusText != null)
|
||||||
Align(
|
_buildOverlayBanner(
|
||||||
alignment: Alignment.bottomCenter,
|
child: Text(
|
||||||
child: SafeArea(
|
_statusText!,
|
||||||
child: Container(
|
textAlign: TextAlign.center,
|
||||||
margin: const EdgeInsets.all(12),
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.surfaceContainerHighest
|
|
||||||
.withValues(alpha: 0.92),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(_statusText!, textAlign: TextAlign.center),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── 移动端 WebView ──────────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildMobileWebView() {
|
||||||
|
return mobile.WebViewWidget(controller: _mobileController!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 桌面端 WebView ──────────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildDesktopWebView() {
|
||||||
|
AppLogger.i('WebView 验证码 BaseUrl: ${WebUri(widget.baseUrl)}');
|
||||||
|
|
||||||
|
return InAppWebView(
|
||||||
|
key: _desktopKey,
|
||||||
|
initialSettings: InAppWebViewSettings(
|
||||||
|
javaScriptEnabled: true,
|
||||||
|
domStorageEnabled: true,
|
||||||
|
safeBrowsingEnabled: true,
|
||||||
|
isInspectable: true,
|
||||||
|
cacheMode: CacheMode.LOAD_NO_CACHE,
|
||||||
|
supportMultipleWindows: true,
|
||||||
|
allowUniversalAccessFromFileURLs: true,
|
||||||
|
allowFileAccessFromFileURLs: true,
|
||||||
|
userAgent: _desktopUserAgent,
|
||||||
|
transparentBackground: true,
|
||||||
|
supportZoom: false,
|
||||||
|
useHybridComposition: true,
|
||||||
|
),
|
||||||
|
onWebViewCreated: (controller) {
|
||||||
|
_desktopController = controller;
|
||||||
|
controller.addJavaScriptHandler(
|
||||||
|
handlerName: 'CaptchaBridge',
|
||||||
|
callback: (args) {
|
||||||
|
if (args.isNotEmpty) {
|
||||||
|
_handleBridgeMessage(args[0].toString());
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
controller.loadUrl(
|
||||||
|
urlRequest: URLRequest(
|
||||||
|
url: WebUri("${widget.baseUrl}/virtual_captcha.html"),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
shouldInterceptRequest: (controller, request) async {
|
||||||
|
if (request.url.toString().contains('virtual_captcha.html')) {
|
||||||
|
AppLogger.i('黑魔法 -> 拦截成功,正在注入动态 HTML');
|
||||||
|
return WebResourceResponse(
|
||||||
|
contentType: 'text/html',
|
||||||
|
contentEncoding: 'utf-8',
|
||||||
|
data: Uint8List.fromList(utf8.encode(_currentHtml)),
|
||||||
|
statusCode: 200,
|
||||||
|
reasonPhrase: 'OK',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.url.toString().contains('/h/b/rc') && request.method == 'POST') {
|
||||||
|
AppLogger.w('发现黑魔法后遗症校验请求 (POST),执行强制 404');
|
||||||
|
await Future.delayed(Duration(seconds: 2));
|
||||||
|
return WebResourceResponse(
|
||||||
|
contentType: 'text/plain',
|
||||||
|
statusCode: 404,
|
||||||
|
reasonPhrase: 'Not Found',
|
||||||
|
data: Uint8List(0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
onLoadStart: (controller, url) {},
|
||||||
|
onLoadStop: (controller, url) {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
},
|
||||||
|
onProgressChanged: (controller, progress) {
|
||||||
|
if (mounted) setState(() => _progress = progress);
|
||||||
|
},
|
||||||
|
onReceivedError: (controller, request, error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage = '${error.type}: ${error.description}'.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 通用底部提示条 ──────────────────────────────────
|
||||||
|
|
||||||
|
Widget _buildOverlayBanner({required Widget child, Color? color}) {
|
||||||
|
return Align(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: SafeArea(
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
margin: const EdgeInsets.all(12),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color ??
|
||||||
|
Theme.of(context)
|
||||||
|
.colorScheme
|
||||||
|
.surfaceContainerHighest
|
||||||
|
.withValues(alpha: 0.92),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
// 辅助
|
||||||
|
// ═════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
String _capEndpoint(String instanceUrl, String siteKey) {
|
||||||
|
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||||
|
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||||||
|
return '$trimmedInstance/$trimmedSiteKey/';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _capWidgetScript(String? assetServer) {
|
||||||
|
final asset = assetServer?.trim();
|
||||||
|
if (asset != null && asset.isNotEmpty) {
|
||||||
|
if (asset.startsWith('http://') || asset.startsWith('https://')) {
|
||||||
|
return asset;
|
||||||
|
}
|
||||||
|
if (asset.toLowerCase() == 'jsdelivr') {
|
||||||
|
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||||
|
}
|
||||||
|
if (asset.toLowerCase() == 'unpkg') {
|
||||||
|
return 'https://unpkg.com/cap-widget';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||||
|
}
|
||||||
|
|
||||||
|
String _js(String input) {
|
||||||
|
return input
|
||||||
|
.replaceAll(r'\', r'\\')
|
||||||
|
.replaceAll("'", r"\'")
|
||||||
|
.replaceAll('\n', r'\n')
|
||||||
|
.replaceAll('\r', r'\r');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
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 '../../../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 '../../widgets/file_info_dialog.dart';
|
||||||
|
import '../../widgets/file_operation_dialogs.dart';
|
||||||
|
import '../../widgets/selection_toolbar.dart';
|
||||||
import '../../widgets/thumbnail_image.dart';
|
import '../../widgets/thumbnail_image.dart';
|
||||||
import '../../widgets/toast_helper.dart';
|
import '../../widgets/toast_helper.dart';
|
||||||
|
|
||||||
@@ -52,20 +61,30 @@ class CategoryFilesPage extends StatefulWidget {
|
|||||||
State<CategoryFilesPage> createState() => _CategoryFilesPageState();
|
State<CategoryFilesPage> createState() => _CategoryFilesPageState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
class _CategoryFilesPageState extends State<CategoryFilesPage>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
final _fileService = FileService();
|
final _fileService = FileService();
|
||||||
final _scrollController = ScrollController();
|
final _scrollController = ScrollController();
|
||||||
|
|
||||||
final List<FileModel> _files = [];
|
final List<FileModel> _files = [];
|
||||||
|
final Set<String> _selectedFilePaths = <String>{};
|
||||||
String? _nextPageToken;
|
String? _nextPageToken;
|
||||||
String? _contextHint;
|
String? _contextHint;
|
||||||
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;
|
||||||
|
|
||||||
|
List<FileModel> get _selectedFiles => _files
|
||||||
|
.where((file) => _selectedFilePaths.contains(file.path))
|
||||||
|
.toList(growable: false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_restoreSortOption();
|
||||||
_loadFiles(refresh: true);
|
_loadFiles(refresh: true);
|
||||||
_scrollController.addListener(_onScroll);
|
_scrollController.addListener(_onScroll);
|
||||||
}
|
}
|
||||||
@@ -74,6 +93,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
void didUpdateWidget(covariant CategoryFilesPage oldWidget) {
|
void didUpdateWidget(covariant CategoryFilesPage oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
if (oldWidget.args.category != widget.args.category) {
|
if (oldWidget.args.category != widget.args.category) {
|
||||||
|
_clearSelection();
|
||||||
_loadFiles(refresh: true);
|
_loadFiles(refresh: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -102,6 +122,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
_nextPageToken = null;
|
_nextPageToken = null;
|
||||||
_contextHint = null;
|
_contextHint = null;
|
||||||
_files.clear();
|
_files.clear();
|
||||||
|
_selectedFilePaths.clear();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -114,6 +135,8 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -154,24 +177,200 @@ 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) {
|
||||||
|
HapticFeedback.selectionClick();
|
||||||
|
setState(() {
|
||||||
|
if (_selectedFilePaths.contains(file.path)) {
|
||||||
|
_selectedFilePaths.remove(file.path);
|
||||||
|
} else {
|
||||||
|
_selectedFilePaths.add(file.path);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearSelection() {
|
||||||
|
if (_selectedFilePaths.isEmpty) return;
|
||||||
|
setState(_selectedFilePaths.clear);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _selectAllVisible() {
|
||||||
|
setState(() {
|
||||||
|
_selectedFilePaths
|
||||||
|
..clear()
|
||||||
|
..addAll(_files.map((file) => file.path));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
return PopScope(
|
||||||
|
canPop: !_hasSelection,
|
||||||
|
onPopInvokedWithResult: (didPop, result) {
|
||||||
|
if (!didPop && _hasSelection) {
|
||||||
|
_clearSelection();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: Scaffold(
|
||||||
|
appBar: _buildAppBar(context),
|
||||||
|
body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
|
||||||
|
bottomNavigationBar: _buildSelectionBottomBar(context),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
PreferredSizeWidget _buildAppBar(BuildContext context) {
|
||||||
final args = widget.args;
|
final args = widget.args;
|
||||||
|
|
||||||
return Scaffold(
|
if (_hasSelection) {
|
||||||
appBar: AppBar(
|
return AppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.x),
|
||||||
|
tooltip: '取消选择',
|
||||||
|
onPressed: _clearSelection,
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
title: Text('已选中 ${_selectedFilePaths.length} 个文件'),
|
||||||
|
actions: [
|
||||||
|
TextButton(onPressed: _selectAllVisible, child: const Text('全选')),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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: '刷新',
|
||||||
onPressed: _refresh,
|
onPressed: _refresh,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildSelectionBottomBar(BuildContext context) {
|
||||||
|
final selected = _selectedFiles;
|
||||||
|
final singleSelected = selected.length == 1 ? selected.first : null;
|
||||||
|
|
||||||
|
return AnimatedSize(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 220),
|
||||||
|
switchInCurve: Curves.easeOutCubic,
|
||||||
|
switchOutCurve: Curves.easeInCubic,
|
||||||
|
layoutBuilder: (currentChild, previousChildren) {
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.bottomCenter,
|
||||||
|
children: <Widget>[...previousChildren, ?currentChild],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
transitionBuilder: (child, animation) {
|
||||||
|
final curved = CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
reverseCurve: Curves.easeInCubic,
|
||||||
|
);
|
||||||
|
final slide = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.10),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(curved);
|
||||||
|
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: curved,
|
||||||
|
child: SlideTransition(position: slide, child: child),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: _hasSelection
|
||||||
|
? SelectionToolbar(
|
||||||
|
key: const ValueKey('category-selection-toolbar'),
|
||||||
|
selectionCount: _selectedFilePaths.length,
|
||||||
|
totalCount: _files.length,
|
||||||
|
onCancel: _clearSelection,
|
||||||
|
onSelectAll: _selectAllVisible,
|
||||||
|
onMore: singleSelected == null
|
||||||
|
? null
|
||||||
|
: () => _showSelectionMore(context, singleSelected),
|
||||||
|
onMove: () => FileOperationDialogs.showBatchMoveDialog(
|
||||||
|
context,
|
||||||
|
context.read<FileManagerProvider>(),
|
||||||
|
_selectedFilePaths.toList(),
|
||||||
|
false,
|
||||||
|
),
|
||||||
|
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
|
||||||
|
context,
|
||||||
|
context.read<FileManagerProvider>(),
|
||||||
|
_selectedFilePaths.toList(),
|
||||||
|
true,
|
||||||
|
),
|
||||||
|
onDelete: () => _deleteSelectedFiles(context, selected),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(
|
||||||
|
key: ValueKey('category-selection-toolbar-empty'),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,7 +454,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
horizontalPadding,
|
horizontalPadding,
|
||||||
10,
|
10,
|
||||||
horizontalPadding,
|
horizontalPadding,
|
||||||
24,
|
_hasSelection ? 92 : 24,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
_buildSummaryHeader(context),
|
_buildSummaryHeader(context),
|
||||||
@@ -271,11 +470,26 @@ 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}',
|
||||||
|
),
|
||||||
file: file,
|
file: file,
|
||||||
contextHint: _contextHint,
|
contextHint: _contextHint,
|
||||||
category: widget.args.category,
|
category: widget.args.category,
|
||||||
accentColor: widget.args.color,
|
accentColor: widget.args.color,
|
||||||
onTap: () => _openFile(context, file),
|
isSelected: _selectedFilePaths.contains(
|
||||||
|
file.path,
|
||||||
|
),
|
||||||
|
selectionMode: _hasSelection,
|
||||||
|
onTap: () {
|
||||||
|
if (_hasSelection) {
|
||||||
|
_toggleSelection(file);
|
||||||
|
} else {
|
||||||
|
_openFile(context, file);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () => _toggleSelection(file),
|
||||||
|
onSelect: () => _toggleSelection(file),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -392,6 +606,140 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteSelectedFiles(
|
||||||
|
BuildContext context,
|
||||||
|
List<FileModel> selectedFiles,
|
||||||
|
) async {
|
||||||
|
if (selectedFiles.isEmpty) return;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('删除确认'),
|
||||||
|
content: Text('确定删除这 ${selectedFiles.length} 个文件吗?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
child: const Text('删除'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _fileService.deleteFiles(
|
||||||
|
uris: selectedFiles.map((file) => file.path).toList(),
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
final selectedPaths = selectedFiles.map((file) => file.path).toSet();
|
||||||
|
_files.removeWhere((file) => selectedPaths.contains(file.path));
|
||||||
|
_selectedFilePaths.clear();
|
||||||
|
});
|
||||||
|
ToastHelper.success('删除成功');
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ToastHelper.failure('删除失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _renameFile(BuildContext context, FileModel file) async {
|
||||||
|
final controller = TextEditingController(text: file.name);
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('重命名'),
|
||||||
|
content: TextField(
|
||||||
|
controller: controller,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '新名称',
|
||||||
|
prefixIcon: Icon(LucideIcons.edit3, size: 20),
|
||||||
|
),
|
||||||
|
autofocus: true,
|
||||||
|
onSubmitted: (_) => Navigator.of(dialogContext).pop(true),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final newName = controller.text.trim();
|
||||||
|
if (confirmed != true || newName.isEmpty || newName == file.name) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await _fileService.renameFile(
|
||||||
|
uri: file.path,
|
||||||
|
newName: newName,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (response.isEmpty) {
|
||||||
|
await _refresh();
|
||||||
|
} else {
|
||||||
|
final updatedFile = FileModel.fromJson(response);
|
||||||
|
setState(() {
|
||||||
|
final index = _files.indexWhere((item) => item.path == file.path);
|
||||||
|
if (index != -1) _files[index] = updatedFile;
|
||||||
|
_selectedFilePaths
|
||||||
|
..remove(file.path)
|
||||||
|
..add(updatedFile.path);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ToastHelper.success('重命名成功');
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ToastHelper.failure('重命名失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _showSelectionMore(BuildContext context, FileModel file) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit),
|
||||||
|
title: const Text('重命名'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
_renameFile(context, file);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('查看详情'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
FileInfoPanel.showAsBottomSheet(context, file);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CategoryFileTile extends StatelessWidget {
|
class _CategoryFileTile extends StatelessWidget {
|
||||||
@@ -399,14 +747,23 @@ class _CategoryFileTile extends StatelessWidget {
|
|||||||
final String? contextHint;
|
final String? contextHint;
|
||||||
final String category;
|
final String category;
|
||||||
final Color accentColor;
|
final Color accentColor;
|
||||||
|
final bool isSelected;
|
||||||
|
final bool selectionMode;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback onLongPress;
|
||||||
|
final VoidCallback onSelect;
|
||||||
|
|
||||||
const _CategoryFileTile({
|
const _CategoryFileTile({
|
||||||
|
super.key,
|
||||||
required this.file,
|
required this.file,
|
||||||
required this.contextHint,
|
required this.contextHint,
|
||||||
required this.category,
|
required this.category,
|
||||||
required this.accentColor,
|
required this.accentColor,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.selectionMode,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
|
required this.onLongPress,
|
||||||
|
required this.onSelect,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -419,20 +776,28 @@ class _CategoryFileTile extends StatelessWidget {
|
|||||||
final ext = FileTypeUtils.getExtension(file.name);
|
final ext = FileTypeUtils.getExtension(file.name);
|
||||||
final isPsd = ext == 'psd' || ext == 'psb';
|
final isPsd = ext == 'psd' || ext == 'psb';
|
||||||
|
|
||||||
return Material(
|
final borderColor = isSelected
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.dividerColor.withValues(alpha: 0.12);
|
||||||
|
|
||||||
|
final showSelectionCircle = selectionMode || isSelected;
|
||||||
|
|
||||||
|
return RepaintBoundary(
|
||||||
|
child: AnimatedScale(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
scale: isSelected ? 0.985 : 1.0,
|
||||||
|
child: Material(
|
||||||
color: theme.colorScheme.surfaceContainerLow,
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: DecoratedBox(
|
onLongPress: onLongPress,
|
||||||
decoration: BoxDecoration(
|
child: Stack(
|
||||||
border: Border.all(
|
clipBehavior: Clip.none,
|
||||||
color: theme.dividerColor.withValues(alpha: 0.12),
|
children: [
|
||||||
),
|
Column(
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
AspectRatio(
|
AspectRatio(
|
||||||
@@ -440,11 +805,13 @@ class _CategoryFileTile extends StatelessWidget {
|
|||||||
child: Stack(
|
child: Stack(
|
||||||
fit: StackFit.expand,
|
fit: StackFit.expand,
|
||||||
children: [
|
children: [
|
||||||
ThumbnailImage(
|
RepaintBoundary(
|
||||||
|
child: ThumbnailImage(
|
||||||
file: file,
|
file: file,
|
||||||
contextHint: contextHint,
|
contextHint: contextHint,
|
||||||
borderRadius: 0,
|
borderRadius: 0,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 7,
|
top: 7,
|
||||||
left: 7,
|
left: 7,
|
||||||
@@ -487,6 +854,50 @@ class _CategoryFileTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: borderColor,
|
||||||
|
width: isSelected ? 2.2 : 1,
|
||||||
|
),
|
||||||
|
boxShadow: isSelected
|
||||||
|
? [
|
||||||
|
BoxShadow(
|
||||||
|
color: theme.colorScheme.primary.withValues(
|
||||||
|
alpha: 0.12,
|
||||||
|
),
|
||||||
|
blurRadius: 10,
|
||||||
|
spreadRadius: 0.5,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
: const [],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 7,
|
||||||
|
right: 7,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 130),
|
||||||
|
opacity: showSelectionCircle ? 1 : 0,
|
||||||
|
child: IgnorePointer(
|
||||||
|
ignoring: !showSelectionCircle,
|
||||||
|
child: _SelectionCircle(
|
||||||
|
selected: isSelected,
|
||||||
|
onTap: onSelect,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -524,6 +935,53 @@ class _CategoryFileTile extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _SelectionCircle extends StatelessWidget {
|
||||||
|
final bool selected;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const _SelectionCircle({required this.selected, this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
customBorder: const CircleBorder(),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: selected
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.surface.withValues(alpha: 0.86),
|
||||||
|
border: Border.all(
|
||||||
|
color: selected
|
||||||
|
? colorScheme.primary
|
||||||
|
: colorScheme.outline.withValues(alpha: 0.42),
|
||||||
|
width: 1.4,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.10),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: selected
|
||||||
|
? const Icon(LucideIcons.check, color: Colors.white, size: 16)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TypeBadge extends StatelessWidget {
|
class _TypeBadge extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String label;
|
final String label;
|
||||||
|
|||||||
@@ -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,9 +54,15 @@ 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) {
|
||||||
@@ -66,6 +76,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
} else {
|
} else {
|
||||||
fileManager.setViewType(FileViewType.list);
|
fileManager.setViewType(FileViewType.list);
|
||||||
}
|
}
|
||||||
|
fileManager.restoreSortOption();
|
||||||
if (_isFirstLoad) {
|
if (_isFirstLoad) {
|
||||||
fileManager.loadFiles();
|
fileManager.loadFiles();
|
||||||
_isFirstLoad = false;
|
_isFirstLoad = false;
|
||||||
@@ -99,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 后再打开
|
||||||
@@ -112,6 +186,39 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _showSelectionMore(FileModel file, FileManagerProvider fileManager) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (sheetContext) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.edit),
|
||||||
|
title: const Text('重命名'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
FileOperationDialogs.showRenameDialog(
|
||||||
|
context,
|
||||||
|
fileManager,
|
||||||
|
file,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.info_outline),
|
||||||
|
title: const Text('查看详情'),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(sheetContext).pop();
|
||||||
|
_showFileInfo(file);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ---- FAB 显隐控制 ----
|
// ---- FAB 显隐控制 ----
|
||||||
|
|
||||||
void _hideFab() {
|
void _hideFab() {
|
||||||
@@ -158,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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,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);
|
||||||
},
|
},
|
||||||
@@ -188,67 +305,19 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 循环解码路径段,处理多重 URL 编码(如 %25E4%25B8%25AD → 中文)
|
||||||
String _decodePathSegment(String segment) {
|
String _decodePathSegment(String segment) {
|
||||||
return FileUtils.safeDecodePathSegment(segment);
|
var decoded = segment;
|
||||||
|
for (var i = 0; i < 5; i++) {
|
||||||
|
try {
|
||||||
|
final next = Uri.decodeComponent(decoded);
|
||||||
|
if (next == decoded) break;
|
||||||
|
decoded = next;
|
||||||
|
} catch (_) {
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktopBreadcrumb(
|
|
||||||
BuildContext context,
|
|
||||||
FileManagerProvider fileManager,
|
|
||||||
) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final colorScheme = theme.colorScheme;
|
|
||||||
final pathParts = fileManager.currentPath.split('/');
|
|
||||||
pathParts.removeWhere((part) => part.isEmpty);
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
InkWell(
|
|
||||||
onTap: () => fileManager.currentPath != '/'
|
|
||||||
? fileManager.enterFolder('/')
|
|
||||||
: null,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
|
||||||
child: Icon(LucideIcons.home, size: 18, color: colorScheme.primary),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
for (int i = 0; i < pathParts.length; i++) ...[
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
|
||||||
child: Icon(
|
|
||||||
LucideIcons.chevronRight,
|
|
||||||
size: 14,
|
|
||||||
color: theme.hintColor.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
InkWell(
|
|
||||||
onTap: () {
|
|
||||||
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
|
|
||||||
if (targetPath != fileManager.currentPath) {
|
|
||||||
fileManager.enterFolder(targetPath);
|
|
||||||
}
|
}
|
||||||
},
|
return decoded;
|
||||||
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(
|
Widget _buildMobileBreadcrumb(
|
||||||
@@ -260,9 +329,21 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
final pathParts = fileManager.currentPath.split('/');
|
final pathParts = fileManager.currentPath.split('/');
|
||||||
pathParts.removeWhere((part) => part.isEmpty);
|
pathParts.removeWhere((part) => part.isEmpty);
|
||||||
|
|
||||||
|
// 路径变化后自动滚动到末尾
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (_breadcrumbController.hasClients) {
|
||||||
|
_breadcrumbController.animateTo(
|
||||||
|
_breadcrumbController.position.maxScrollExtent,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 40,
|
height: 40,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
controller: _breadcrumbController,
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
children: [
|
children: [
|
||||||
_buildBreadcrumbChip(
|
_buildBreadcrumbChip(
|
||||||
@@ -361,6 +442,11 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
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
|
||||||
@@ -408,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
|
||||||
@@ -431,6 +522,43 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
||||||
@@ -822,18 +950,34 @@ 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(
|
||||||
itemCount: fileManager.files.length,
|
controller: _scrollController,
|
||||||
|
key: PageStorageKey('files_list_${fileManager.currentPath}'),
|
||||||
|
cacheExtent: 900,
|
||||||
|
keyboardDismissBehavior:
|
||||||
|
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(
|
final isSelected = fileManager.selectedFiles.contains(
|
||||||
file.path,
|
file.path,
|
||||||
@@ -865,6 +1009,9 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
onOpenInBrowser: !file.isFolder
|
onOpenInBrowser: !file.isFolder
|
||||||
? () => _openInBrowser(context, file)
|
? () => _openInBrowser(context, file)
|
||||||
: null,
|
: null,
|
||||||
|
onOpenInCloudreveApp: !file.isFolder
|
||||||
|
? () => _openInCloudreveApp(context, file)
|
||||||
|
: null,
|
||||||
onRename: () => FileOperationDialogs.showRenameDialog(
|
onRename: () => FileOperationDialogs.showRenameDialog(
|
||||||
context,
|
context,
|
||||||
fileManager,
|
fileManager,
|
||||||
@@ -922,12 +1069,19 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
(availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
|
(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}'),
|
||||||
|
cacheExtent: 1100,
|
||||||
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: crossAxisCount,
|
crossAxisCount: crossAxisCount,
|
||||||
@@ -935,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);
|
||||||
|
|
||||||
@@ -965,6 +1122,9 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
onOpenInBrowser: !file.isFolder
|
onOpenInBrowser: !file.isFolder
|
||||||
? () => _openInBrowser(context, file)
|
? () => _openInBrowser(context, file)
|
||||||
: null,
|
: null,
|
||||||
|
onOpenInCloudreveApp: !file.isFolder
|
||||||
|
? () => _openInCloudreveApp(context, file)
|
||||||
|
: null,
|
||||||
onRename: () => FileOperationDialogs.showRenameDialog(
|
onRename: () => FileOperationDialogs.showRenameDialog(
|
||||||
context,
|
context,
|
||||||
fileManager,
|
fileManager,
|
||||||
@@ -997,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;
|
||||||
@@ -1006,14 +1222,15 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
if (fileManager.hasSelection) {
|
if (fileManager.hasSelection) {
|
||||||
return SelectionToolbar(
|
return SelectionToolbar(
|
||||||
selectionCount: fileManager.selectedFiles.length,
|
selectionCount: fileManager.selectedFiles.length,
|
||||||
|
totalCount: fileManager.files.length,
|
||||||
onCancel: () => fileManager.clearSelection(),
|
onCancel: () => fileManager.clearSelection(),
|
||||||
onRename: fileManager.selectedFiles.length == 1
|
onSelectAll: () => fileManager.selectAll(),
|
||||||
? () => FileOperationDialogs.showRenameDialog(
|
onMore: fileManager.selectedFiles.length == 1
|
||||||
context,
|
? () => _showSelectionMore(
|
||||||
fileManager,
|
|
||||||
fileManager.files.firstWhere(
|
fileManager.files.firstWhere(
|
||||||
(f) => f.path == fileManager.selectedFiles.first,
|
(f) => f.path == fileManager.selectedFiles.first,
|
||||||
),
|
),
|
||||||
|
fileManager,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onMove: () => FileOperationDialogs.showBatchMoveDialog(
|
onMove: () => FileOperationDialogs.showBatchMoveDialog(
|
||||||
@@ -1124,4 +1341,10 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openInCloudreveApp(BuildContext context, FileModel file) {
|
||||||
|
Navigator.of(
|
||||||
|
context,
|
||||||
|
).pushNamed(RouteNames.cloudreveFileApp, arguments: {'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()),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,355 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart' as mobile;
|
||||||
|
|
||||||
|
import '../../../data/models/file_model.dart';
|
||||||
|
import '../../../services/api_service.dart';
|
||||||
|
|
||||||
|
bool get _useDesktopWebView =>
|
||||||
|
!kIsWeb && (Platform.isWindows || Platform.isLinux);
|
||||||
|
|
||||||
|
/// 完全交给 Cloudreve 官方 Web 前端处理文件打开。
|
||||||
|
///
|
||||||
|
/// 这里不再自己读取 file_viewers,也不再自己创建 viewerSession。
|
||||||
|
/// Cloudreve 前端本身会根据 `/home?path=...&open=...` 打开对应文件,
|
||||||
|
/// 并使用它自己的文件应用、WOPI、Markdown、表格、压缩包、EPUB 等逻辑。
|
||||||
|
class CloudreveFileAppPage extends StatefulWidget {
|
||||||
|
final FileModel file;
|
||||||
|
final String? preferredAction;
|
||||||
|
|
||||||
|
const CloudreveFileAppPage({
|
||||||
|
super.key,
|
||||||
|
required this.file,
|
||||||
|
this.preferredAction,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CloudreveFileAppPage> createState() => _CloudreveFileAppPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
|
||||||
|
mobile.WebViewController? _mobileController;
|
||||||
|
desktop.InAppWebViewController? _desktopController;
|
||||||
|
|
||||||
|
bool _isPreparing = true;
|
||||||
|
bool _sessionInjected = false;
|
||||||
|
int _progress = 0;
|
||||||
|
String? _error;
|
||||||
|
late Uri _targetUri;
|
||||||
|
late Uri _originUri;
|
||||||
|
String? _sessionStateJson;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
if (!_useDesktopWebView) {
|
||||||
|
_mobileController = mobile.WebViewController()
|
||||||
|
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
|
||||||
|
..setBackgroundColor(Colors.transparent)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
mobile.NavigationDelegate(
|
||||||
|
onProgress: (progress) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _progress = progress);
|
||||||
|
},
|
||||||
|
onPageFinished: (_) => _injectSessionAndOpenIfNeeded(),
|
||||||
|
onWebResourceError: (error) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (error.isForMainFrame == true) {
|
||||||
|
setState(() {
|
||||||
|
_error = '${error.errorCode}: ${error.description}';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_prepareAndOpen();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_desktopController?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _prepareAndOpen() async {
|
||||||
|
setState(() {
|
||||||
|
_isPreparing = true;
|
||||||
|
_error = null;
|
||||||
|
_progress = 0;
|
||||||
|
_sessionInjected = false;
|
||||||
|
if (_useDesktopWebView) {
|
||||||
|
_desktopController = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
_originUri = _buildOriginUri();
|
||||||
|
_targetUri = _buildCloudreveHomeUri();
|
||||||
|
|
||||||
|
_sessionStateJson = await _buildCloudreveFrontendSessionJson();
|
||||||
|
|
||||||
|
// 先打开同源页面,再注入 localStorage。
|
||||||
|
// 这样 Cloudreve 官方前端可以直接复用 App 的 access_token。
|
||||||
|
await _loadOrigin();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isPreparing = false);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isPreparing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _buildOriginUri() {
|
||||||
|
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
|
||||||
|
return Uri(
|
||||||
|
scheme: base.scheme,
|
||||||
|
host: base.host,
|
||||||
|
port: base.hasPort ? base.port : null,
|
||||||
|
path: '/',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri _buildCloudreveHomeUri() {
|
||||||
|
final parent = _parentUri(widget.file.relativePath);
|
||||||
|
final openTarget = widget.file.id.isNotEmpty
|
||||||
|
? widget.file.id
|
||||||
|
: widget.file.relativePath;
|
||||||
|
|
||||||
|
return Uri(
|
||||||
|
scheme: _originUri.scheme,
|
||||||
|
host: _originUri.host,
|
||||||
|
port: _originUri.hasPort ? _originUri.port : null,
|
||||||
|
path: '/home',
|
||||||
|
queryParameters: {
|
||||||
|
'path': parent,
|
||||||
|
'open': openTarget,
|
||||||
|
'size': widget.file.size.toString(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parentUri(String uri) {
|
||||||
|
final normalized = uri.endsWith('/')
|
||||||
|
? uri.substring(0, uri.length - 1)
|
||||||
|
: uri;
|
||||||
|
final index = normalized.lastIndexOf('/');
|
||||||
|
if (index <= 'cloudreve://my'.length) {
|
||||||
|
return 'cloudreve://my';
|
||||||
|
}
|
||||||
|
return normalized.substring(0, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> _buildCloudreveFrontendSessionJson() async {
|
||||||
|
final tokenGetter = ApiService.instance.getTokenCallback;
|
||||||
|
final token = tokenGetter == null ? null : await tokenGetter();
|
||||||
|
if (token == null || token.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? user;
|
||||||
|
try {
|
||||||
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
|
'/user/me',
|
||||||
|
);
|
||||||
|
user = Map<String, dynamic>.from(response);
|
||||||
|
} catch (_) {
|
||||||
|
user = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final userId = user?['id']?.toString() ?? user?['uid']?.toString() ?? 'app';
|
||||||
|
|
||||||
|
final now = DateTime.now().toUtc();
|
||||||
|
final accessExpires = now.add(const Duration(hours: 2)).toIso8601String();
|
||||||
|
final refreshExpires = now.add(const Duration(hours: 2)).toIso8601String();
|
||||||
|
|
||||||
|
final sessionState = {
|
||||||
|
'current': userId,
|
||||||
|
'sessions': {
|
||||||
|
userId: {
|
||||||
|
'user': user ?? {'id': userId},
|
||||||
|
'token': {
|
||||||
|
'access_token': token,
|
||||||
|
// App 侧暂时没有把 refresh token 暴露给这里。
|
||||||
|
// 给 Cloudreve 前端一个短期可用 session;过期后 WebView 内刷新会要求重新登录。
|
||||||
|
'refresh_token': '',
|
||||||
|
'access_expires': accessExpires,
|
||||||
|
'refresh_expires': refreshExpires,
|
||||||
|
},
|
||||||
|
'settings': {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'anonymousSettings': {},
|
||||||
|
};
|
||||||
|
|
||||||
|
return jsonEncode(sessionState);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _injectSessionAndOpenIfNeeded() async {
|
||||||
|
if (_sessionInjected) return;
|
||||||
|
_sessionInjected = true;
|
||||||
|
|
||||||
|
final sessionJson = _sessionStateJson;
|
||||||
|
final target = _targetUri.toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (sessionJson != null) {
|
||||||
|
final script =
|
||||||
|
'''
|
||||||
|
try {
|
||||||
|
localStorage.setItem('cloudreve_session', ${jsonEncode(sessionJson)});
|
||||||
|
} catch (e) {}
|
||||||
|
window.location.replace(${jsonEncode(target)});
|
||||||
|
''';
|
||||||
|
await _runJavaScript(script);
|
||||||
|
} else {
|
||||||
|
await _loadTarget();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _error = e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openTargetAgain() async {
|
||||||
|
_sessionInjected = true;
|
||||||
|
await _loadTarget();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reloadWebView() async {
|
||||||
|
if (_useDesktopWebView) {
|
||||||
|
await _desktopController?.reload();
|
||||||
|
} else {
|
||||||
|
await _mobileController?.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadOrigin() async {
|
||||||
|
if (_useDesktopWebView) {
|
||||||
|
final controller = _desktopController;
|
||||||
|
if (controller == null) return;
|
||||||
|
await _desktopController?.loadUrl(
|
||||||
|
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_originUri)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _mobileController!.loadRequest(_originUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadTarget() async {
|
||||||
|
if (_useDesktopWebView) {
|
||||||
|
await _desktopController?.loadUrl(
|
||||||
|
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_targetUri)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await _mobileController!.loadRequest(_targetUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runJavaScript(String source) async {
|
||||||
|
if (_useDesktopWebView) {
|
||||||
|
await _desktopController?.evaluateJavascript(source: source);
|
||||||
|
} else {
|
||||||
|
await _mobileController!.runJavaScript(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: Text(widget.file.name, overflow: TextOverflow.ellipsis),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: '重新打开',
|
||||||
|
icon: const Icon(Icons.open_in_browser),
|
||||||
|
onPressed: _openTargetAgain,
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '刷新',
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: _reloadWebView,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
bottom: PreferredSize(
|
||||||
|
preferredSize: const Size.fromHeight(3),
|
||||||
|
child: _progress > 0 && _progress < 100
|
||||||
|
? LinearProgressIndicator(value: _progress / 100)
|
||||||
|
: const SizedBox(height: 3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: _buildBody(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBody() {
|
||||||
|
if (_isPreparing) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_error != null) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 56, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(_error!, textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: _prepareAndOpen,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('重试'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_useDesktopWebView) {
|
||||||
|
return desktop.InAppWebView(
|
||||||
|
initialSettings: desktop.InAppWebViewSettings(
|
||||||
|
javaScriptEnabled: true,
|
||||||
|
domStorageEnabled: true,
|
||||||
|
isInspectable: true,
|
||||||
|
cacheMode: desktop.CacheMode.LOAD_DEFAULT,
|
||||||
|
supportMultipleWindows: true,
|
||||||
|
transparentBackground: true,
|
||||||
|
supportZoom: true,
|
||||||
|
useHybridComposition: true,
|
||||||
|
),
|
||||||
|
onWebViewCreated: (controller) async {
|
||||||
|
_desktopController = controller;
|
||||||
|
if (!_isPreparing) {
|
||||||
|
await _loadOrigin();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onProgressChanged: (_, progress) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _progress = progress);
|
||||||
|
},
|
||||||
|
onLoadStop: (_, url) => _injectSessionAndOpenIfNeeded(),
|
||||||
|
onReceivedError: (_, request, error) {
|
||||||
|
if (!mounted || request.isForMainFrame != true) return;
|
||||||
|
setState(() => _error = '${error.type}: ${error.description}');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mobile.WebViewWidget(controller: _mobileController!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
@@ -271,7 +293,9 @@ Widget _buildFAB(bool isDark) {
|
|||||||
|
|
||||||
// 定义统一的按钮背景颜色,增加视觉一致性
|
// 定义统一的按钮背景颜色,增加视觉一致性
|
||||||
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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -47,14 +47,17 @@ class QuickFunctionsSection extends StatelessWidget {
|
|||||||
icon: LucideIcons.refreshCw,
|
icon: LucideIcons.refreshCw,
|
||||||
label: '文件同步',
|
label: '文件同步',
|
||||||
onTap: (ctx) {
|
onTap: (ctx) {
|
||||||
final isMobilePlatform =
|
final nav = ctx.read<NavigationProvider>();
|
||||||
defaultTargetPlatform == TargetPlatform.android ||
|
// 桌面端或 Android 平板(宽屏)有同步 Tab,直接切换;手机端跳转同步详情页
|
||||||
defaultTargetPlatform == TargetPlatform.iOS;
|
final isDesktop =
|
||||||
if (isMobilePlatform) {
|
defaultTargetPlatform != TargetPlatform.android &&
|
||||||
Navigator.of(ctx).pushNamed(RouteNames.syncSettings);
|
defaultTargetPlatform != TargetPlatform.iOS;
|
||||||
return;
|
final isWideScreen = MediaQuery.of(ctx).size.width >= 800;
|
||||||
|
if (isDesktop || isWideScreen) {
|
||||||
|
nav.setIndex(3);
|
||||||
|
} else {
|
||||||
|
Navigator.of(ctx).pushNamed(RouteNames.syncStatus);
|
||||||
}
|
}
|
||||||
ctx.read<NavigationProvider>().setIndex(3);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_QuickFunction(
|
_QuickFunction(
|
||||||
|
|||||||
@@ -930,7 +930,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
|||||||
(Level.error, 'Error — 仅错误'),
|
(Level.error, 'Error — 仅错误'),
|
||||||
(Level.warning, 'Warning — 错误 + 警告'),
|
(Level.warning, 'Warning — 错误 + 警告'),
|
||||||
(Level.info, 'Info — 常规信息'),
|
(Level.info, 'Info — 常规信息'),
|
||||||
(Level.debug, 'Debug — 调试信息(含FFI交互)'),
|
(Level.debug, 'Debug — 调试信息(含FFI)'),
|
||||||
(Level.trace, 'Trace — 全量追踪'),
|
(Level.trace, 'Trace — 全量追踪'),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -950,7 +950,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
|||||||
color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor,
|
color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(e.$2),
|
Flexible(child: Text(e.$2)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
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;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,22 @@
|
|||||||
|
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/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';
|
||||||
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
|
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
|
||||||
|
import 'package:cloudreve4_flutter/services/announcement_service.dart';
|
||||||
|
import 'package:cloudreve4_flutter/services/dialog_queue_service.dart';
|
||||||
|
import 'package:cloudreve4_flutter/services/share_link_service.dart';
|
||||||
|
import 'package:cloudreve4_flutter/presentation/pages/share/share_link_page.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
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';
|
||||||
import '../../../router/app_router.dart';
|
import '../../../router/app_router.dart';
|
||||||
@@ -47,15 +55,22 @@ class AppShell extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _AppShellState extends State<AppShell>
|
class _AppShellState extends State<AppShell>
|
||||||
with GestureHandlerMixin, TickerProviderStateMixin {
|
with GestureHandlerMixin, TickerProviderStateMixin, WidgetsBindingObserver {
|
||||||
final Set<int> _visitedPageIndexes = <int>{0};
|
final Set<int> _visitedPageIndexes = <int>{0};
|
||||||
late AnimationController _syncSpinController;
|
late AnimationController _syncSpinController;
|
||||||
|
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(),
|
||||||
@@ -71,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;
|
||||||
@@ -84,18 +99,160 @@ class _AppShellState extends State<AppShell>
|
|||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
);
|
);
|
||||||
|
WidgetsBinding.instance.addObserver(this);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_showPostLoginAnnouncement();
|
||||||
|
_checkClipboardShareLink();
|
||||||
|
_lastUserId = context.read<AuthProvider>().user?.id;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
_syncSpinController.dispose();
|
_syncSpinController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||||
|
if (state == AppLifecycleState.resumed) {
|
||||||
|
_checkClipboardShareLink();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
final authProvider = context.read<AuthProvider>();
|
||||||
|
if (!authProvider.isAuthenticated) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final service = AnnouncementService.instance;
|
||||||
|
final announcement = await service.getChangedSiteNotice();
|
||||||
|
if (!mounted || announcement == null) return;
|
||||||
|
|
||||||
|
await DialogQueueService.instance.enqueue<void>(() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
await AnnouncementDialog.show(
|
||||||
|
context,
|
||||||
|
title: announcement.title,
|
||||||
|
html: announcement.html,
|
||||||
|
baseUrl: announcement.baseUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
await service.markDismissed(announcement);
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Announcement checks should never block the shell.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _checkClipboardShareLink() async {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 650));
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
final candidate = ShareLinkService.instance.parseShareLink(data?.text);
|
||||||
|
|
||||||
|
if (candidate == null) return;
|
||||||
|
if (_lastClipboardShareId == candidate.id) return;
|
||||||
|
|
||||||
|
_lastClipboardShareId = candidate.id;
|
||||||
|
|
||||||
|
await DialogQueueService.instance.enqueue<void>(() async {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final open = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => AlertDialog(
|
||||||
|
title: const Text('检测到分享链接'),
|
||||||
|
content: Text(
|
||||||
|
'是否打开这个文件分享?\n\n${candidate.url}',
|
||||||
|
maxLines: 5,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(false),
|
||||||
|
child: const Text('忽略'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(true),
|
||||||
|
child: const Text('打开'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (open == true && mounted) {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => ShareLinkPage(candidate: candidate),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (_) {
|
||||||
|
// Clipboard access failures should not affect the shell.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
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,
|
||||||
@@ -120,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);
|
||||||
}
|
}
|
||||||
@@ -132,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);
|
||||||
|
|
||||||
@@ -200,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),
|
||||||
@@ -225,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, _) {
|
||||||
@@ -276,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,
|
||||||
@@ -327,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, _) {
|
||||||
|
|||||||
@@ -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('清空'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,9 +321,13 @@ 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);
|
await FileService().moveFiles(uris: uris, dst: destination, copy: copy);
|
||||||
clearSelection();
|
clearSelection();
|
||||||
|
|
||||||
if (!copy) {
|
if (!copy) {
|
||||||
@@ -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?;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
|
||||||
|
import 'package:webview_flutter/webview_flutter.dart' as mobile;
|
||||||
|
|
||||||
|
bool get _useDesktopWebView =>
|
||||||
|
!kIsWeb && (Platform.isWindows || Platform.isLinux);
|
||||||
|
|
||||||
|
class AnnouncementDialog extends StatefulWidget {
|
||||||
|
final String title;
|
||||||
|
final String html;
|
||||||
|
final String baseUrl;
|
||||||
|
|
||||||
|
const AnnouncementDialog({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.html,
|
||||||
|
required this.baseUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String html,
|
||||||
|
required String baseUrl,
|
||||||
|
}) async {
|
||||||
|
if (html.trim().isEmpty) return;
|
||||||
|
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
barrierDismissible: true,
|
||||||
|
builder: (_) =>
|
||||||
|
AnnouncementDialog(title: title, html: html, baseUrl: baseUrl),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AnnouncementDialog> createState() => _AnnouncementDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnnouncementDialogState extends State<AnnouncementDialog> {
|
||||||
|
mobile.WebViewController? _mobileController;
|
||||||
|
desktop.InAppWebViewController? _desktopController;
|
||||||
|
late final String _html;
|
||||||
|
late final String _baseUrl;
|
||||||
|
bool _loading = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
final origin = Uri.parse(widget.baseUrl).origin;
|
||||||
|
_html = _wrapHtml(widget.html);
|
||||||
|
_baseUrl = '$origin/';
|
||||||
|
|
||||||
|
if (!_useDesktopWebView) {
|
||||||
|
_mobileController = mobile.WebViewController()
|
||||||
|
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
|
||||||
|
..setBackgroundColor(Colors.transparent)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
mobile.NavigationDelegate(
|
||||||
|
onPageFinished: (_) {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..loadHtmlString(_html, baseUrl: _baseUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_desktopController?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _wrapHtml(String body) {
|
||||||
|
final encodedTitle = const HtmlEscape().convert(widget.title);
|
||||||
|
|
||||||
|
return '''
|
||||||
|
<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||||
|
<title>$encodedTitle</title>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: #1f2937;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.65;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
padding: 12px 14px 18px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
img, video {
|
||||||
|
max-width: 100% !important;
|
||||||
|
height: auto !important;
|
||||||
|
border-radius: 12px;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #2563eb;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
fieldset, section, div {
|
||||||
|
max-width: 100% !important;
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
$body
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
''';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
insetPadding: const EdgeInsets.symmetric(horizontal: 18, vertical: 28),
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22)),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: MediaQuery.of(context).size.height * 0.72,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.fromLTRB(18, 14, 8, 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surface,
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: theme.dividerColor.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
widget.title,
|
||||||
|
style: theme.textTheme.titleMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: '关闭',
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
_buildWebView(),
|
||||||
|
if (_loading)
|
||||||
|
const Center(child: CircularProgressIndicator()),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWebView() {
|
||||||
|
if (_useDesktopWebView) {
|
||||||
|
return desktop.InAppWebView(
|
||||||
|
initialData: desktop.InAppWebViewInitialData(
|
||||||
|
data: _html,
|
||||||
|
baseUrl: desktop.WebUri(_baseUrl),
|
||||||
|
),
|
||||||
|
initialSettings: desktop.InAppWebViewSettings(
|
||||||
|
javaScriptEnabled: true,
|
||||||
|
domStorageEnabled: true,
|
||||||
|
transparentBackground: true,
|
||||||
|
supportZoom: false,
|
||||||
|
),
|
||||||
|
onWebViewCreated: (controller) {
|
||||||
|
_desktopController = controller;
|
||||||
|
},
|
||||||
|
onLoadStop: (_, url) {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
},
|
||||||
|
onReceivedError: (_, request, error) {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return mobile.WebViewWidget(controller: _mobileController!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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('/')}',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ class FileGridItem extends StatelessWidget {
|
|||||||
final VoidCallback? onSelect;
|
final VoidCallback? onSelect;
|
||||||
final VoidCallback? onDownload;
|
final VoidCallback? onDownload;
|
||||||
final VoidCallback? onOpenInBrowser;
|
final VoidCallback? onOpenInBrowser;
|
||||||
|
final VoidCallback? onOpenInCloudreveApp;
|
||||||
final VoidCallback? onRename;
|
final VoidCallback? onRename;
|
||||||
final VoidCallback? onMove;
|
final VoidCallback? onMove;
|
||||||
final VoidCallback? onCopy;
|
final VoidCallback? onCopy;
|
||||||
@@ -38,6 +39,7 @@ class FileGridItem extends StatelessWidget {
|
|||||||
this.onSelect,
|
this.onSelect,
|
||||||
this.onDownload,
|
this.onDownload,
|
||||||
this.onOpenInBrowser,
|
this.onOpenInBrowser,
|
||||||
|
this.onOpenInCloudreveApp,
|
||||||
this.onRename,
|
this.onRename,
|
||||||
this.onMove,
|
this.onMove,
|
||||||
this.onCopy,
|
this.onCopy,
|
||||||
@@ -50,7 +52,8 @@ class FileGridItem extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Builder(
|
return RepaintBoundary(
|
||||||
|
child: Builder(
|
||||||
builder: (builderContext) => LayoutBuilder(
|
builder: (builderContext) => LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
|
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
|
||||||
@@ -70,6 +73,7 @@ class FileGridItem extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,6 +83,7 @@ class FileGridItem extends StatelessWidget {
|
|||||||
hasSelect: onSelect != null,
|
hasSelect: onSelect != null,
|
||||||
hasDownload: onDownload != null,
|
hasDownload: onDownload != null,
|
||||||
hasOpenInBrowser: onOpenInBrowser != null,
|
hasOpenInBrowser: onOpenInBrowser != null,
|
||||||
|
hasOpenInCloudreveApp: onOpenInCloudreveApp != null,
|
||||||
hasRename: onRename != null,
|
hasRename: onRename != null,
|
||||||
hasMove: onMove != null,
|
hasMove: onMove != null,
|
||||||
hasCopy: onCopy != null,
|
hasCopy: onCopy != null,
|
||||||
@@ -95,6 +100,8 @@ class FileGridItem extends StatelessWidget {
|
|||||||
onDownload?.call();
|
onDownload?.call();
|
||||||
case FileMenuAction.openInBrowser:
|
case FileMenuAction.openInBrowser:
|
||||||
onOpenInBrowser?.call();
|
onOpenInBrowser?.call();
|
||||||
|
case FileMenuAction.openInCloudreveApp:
|
||||||
|
onOpenInCloudreveApp?.call();
|
||||||
case FileMenuAction.rename:
|
case FileMenuAction.rename:
|
||||||
onRename?.call();
|
onRename?.call();
|
||||||
case FileMenuAction.move:
|
case FileMenuAction.move:
|
||||||
@@ -318,10 +325,8 @@ class _FileGridItemHoverState extends State<_FileGridItemHover> {
|
|||||||
|
|
||||||
Widget _buildIconArea(BuildContext context) {
|
Widget _buildIconArea(BuildContext context) {
|
||||||
final file = widget.file;
|
final file = widget.file;
|
||||||
final ext = FileUtils.getFileExtension(file.name);
|
final isThumbnailable =
|
||||||
final isThumbnailable = !file.isFolder
|
!file.isFolder && FileUtils.isThumbnailableFile(file.name);
|
||||||
&& FileUtils.isImageFile(file.name)
|
|
||||||
&& ext != 'svg';
|
|
||||||
|
|
||||||
if (!isThumbnailable) {
|
if (!isThumbnailable) {
|
||||||
return Center(
|
return Center(
|
||||||
@@ -335,10 +340,52 @@ class _FileGridItemHoverState extends State<_FileGridItemHover> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return ThumbnailImage(
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
ThumbnailImage(
|
||||||
file: file,
|
file: file,
|
||||||
contextHint: widget.contextHint,
|
contextHint: widget.contextHint,
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
|
),
|
||||||
|
if (FileUtils.isVideoFile(file.name))
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 34,
|
||||||
|
height: 34,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.45),
|
||||||
|
borderRadius: BorderRadius.circular(17),
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
LucideIcons.play,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (FileUtils.isPsdFile(file.name))
|
||||||
|
Positioned(
|
||||||
|
left: 6,
|
||||||
|
bottom: 6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withValues(alpha: 0.55),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: const Text(
|
||||||
|
'PSD',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import '../../data/models/file_model.dart';
|
|||||||
import '../../core/utils/date_utils.dart' as date_utils;
|
import '../../core/utils/date_utils.dart' as date_utils;
|
||||||
import '../../core/utils/file_icon_utils.dart';
|
import '../../core/utils/file_icon_utils.dart';
|
||||||
import '../../core/utils/file_type_utils.dart';
|
import '../../core/utils/file_type_utils.dart';
|
||||||
import '../../core/utils/file_utils.dart';
|
|
||||||
import '../../services/file_service.dart';
|
import '../../services/file_service.dart';
|
||||||
import '../../router/app_router.dart';
|
import '../../router/app_router.dart';
|
||||||
import 'toast_helper.dart';
|
import 'toast_helper.dart';
|
||||||
@@ -20,11 +19,58 @@ class FileInfoPanel extends StatefulWidget {
|
|||||||
Scaffold.of(context).openEndDrawer();
|
Scaffold.of(context).openEndDrawer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 以 BottomSheet 方式展示文件详情
|
||||||
|
static void showAsBottomSheet(BuildContext context, FileModel file) {
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (_) => _FileInfoSheet(file: file),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<FileInfoPanel> createState() => _FileInfoPanelState();
|
State<FileInfoPanel> createState() => _FileInfoPanelState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FileInfoPanelState extends State<FileInfoPanel> {
|
class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Drawer(
|
||||||
|
child: FileInfoPanelContent(file: widget.file),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 以 BottomSheet 形式展示的文件详情
|
||||||
|
class _FileInfoSheet extends StatelessWidget {
|
||||||
|
final FileModel file;
|
||||||
|
const _FileInfoSheet({required this.file});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return DraggableScrollableSheet(
|
||||||
|
initialChildSize: 0.7,
|
||||||
|
minChildSize: 0.4,
|
||||||
|
maxChildSize: 0.95,
|
||||||
|
expand: false,
|
||||||
|
builder: (context, scrollController) {
|
||||||
|
return FileInfoPanelContent(file: file);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// FileInfoPanel 的可复用内容区(不含 Drawer 壳)
|
||||||
|
class FileInfoPanelContent extends StatefulWidget {
|
||||||
|
final FileModel file;
|
||||||
|
const FileInfoPanelContent({super.key, required this.file});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FileInfoPanelContent> createState() => _FileInfoPanelContentState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileInfoPanelContentState extends State<FileInfoPanelContent> {
|
||||||
FileInfoModel? _fileInfo;
|
FileInfoModel? _fileInfo;
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
bool _isCalculatingFolder = false;
|
bool _isCalculatingFolder = false;
|
||||||
@@ -88,10 +134,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Drawer(
|
return Column(
|
||||||
child: SafeArea(
|
|
||||||
right: false,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
@@ -102,9 +145,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border(
|
border: Border(
|
||||||
bottom: BorderSide(
|
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)),
|
||||||
color: theme.dividerColor.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -140,8 +181,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
: _buildContent(theme, colorScheme),
|
: _buildContent(theme, colorScheme),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,21 +189,13 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(LucideIcons.alertCircle, size: 48, color: theme.colorScheme.error),
|
||||||
LucideIcons.alertCircle,
|
|
||||||
size: 48,
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text('加载失败', style: theme.textTheme.titleSmall),
|
Text('加载失败', style: theme.textTheme.titleSmall),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Text(
|
child: Text(_error ?? '', style: TextStyle(color: theme.hintColor, fontSize: 12), textAlign: TextAlign.center),
|
||||||
_error ?? '',
|
|
||||||
style: TextStyle(color: theme.hintColor, fontSize: 12),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton.tonal(onPressed: _loadFileInfo, child: const Text('重试')),
|
FilledButton.tonal(onPressed: _loadFileInfo, child: const Text('重试')),
|
||||||
@@ -179,8 +210,10 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
? '文件夹'
|
? '文件夹'
|
||||||
: FileIconUtils.getFileTypeLabel(file.name);
|
: FileIconUtils.getFileTypeLabel(file.name);
|
||||||
final extendedInfo = _fileInfo?.extendedInfo;
|
final extendedInfo = _fileInfo?.extendedInfo;
|
||||||
final versionEntities =
|
final versionEntities = extendedInfo?.entities
|
||||||
extendedInfo?.entities?.where((e) => e.type == 0).toList() ?? [];
|
?.where((e) => e.type == 0)
|
||||||
|
.toList() ??
|
||||||
|
[];
|
||||||
|
|
||||||
return SingleChildScrollView(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
@@ -188,7 +221,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 类型标签
|
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -205,43 +237,23 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
_buildInfoRow(LucideIcons.folderOpen, '位置', Uri.decodeComponent(file.relativePath)),
|
||||||
// 基本信息
|
|
||||||
_buildInfoRow(
|
|
||||||
LucideIcons.folderOpen,
|
|
||||||
'位置',
|
|
||||||
FileUtils.safeDecodePathSegment(file.relativePath),
|
|
||||||
),
|
|
||||||
if (file.isFile)
|
if (file.isFile)
|
||||||
_buildInfoRow(
|
_buildInfoRow(
|
||||||
LucideIcons.hardDrive,
|
LucideIcons.hardDrive,
|
||||||
'大小',
|
'大小',
|
||||||
date_utils.DateUtils.formatFileSize(file.size),
|
date_utils.DateUtils.formatFileSize(file.size),
|
||||||
),
|
),
|
||||||
_buildInfoRow(
|
_buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(file.createdAt)),
|
||||||
LucideIcons.calendarPlus,
|
_buildInfoRow(LucideIcons.calendar, '修改时间', date_utils.DateUtils.formatDateTime(file.updatedAt)),
|
||||||
'创建时间',
|
|
||||||
date_utils.DateUtils.formatDateTime(file.createdAt),
|
|
||||||
),
|
|
||||||
_buildInfoRow(
|
|
||||||
LucideIcons.calendar,
|
|
||||||
'修改时间',
|
|
||||||
date_utils.DateUtils.formatDateTime(file.updatedAt),
|
|
||||||
),
|
|
||||||
if (file.owned != null)
|
if (file.owned != null)
|
||||||
_buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '是' : '否'),
|
_buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '是' : '否'),
|
||||||
|
|
||||||
// 文件扩展信息
|
|
||||||
if (file.isFile && extendedInfo != null) ...[
|
if (file.isFile && extendedInfo != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
|
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text('扩展信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||||
'扩展信息',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildInfoRow(LucideIcons.fingerprint, '文件ID', file.id),
|
_buildInfoRow(LucideIcons.fingerprint, '文件ID', file.id),
|
||||||
if (file.primaryEntity != null)
|
if (file.primaryEntity != null)
|
||||||
@@ -260,7 +272,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// 版本历史
|
|
||||||
if (file.isFile && versionEntities.isNotEmpty) ...[
|
if (file.isFile && versionEntities.isNotEmpty) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
|
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||||
@@ -268,17 +279,11 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
_buildVersionSection(theme, colorScheme, versionEntities),
|
_buildVersionSection(theme, colorScheme, versionEntities),
|
||||||
],
|
],
|
||||||
|
|
||||||
// 文件夹信息
|
|
||||||
if (file.isFolder) ...[
|
if (file.isFolder) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
|
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text('文件夹信息', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||||
'文件夹信息',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildFolderSummary(theme, colorScheme),
|
_buildFolderSummary(theme, colorScheme),
|
||||||
],
|
],
|
||||||
@@ -301,12 +306,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text('版本历史', style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600)),
|
||||||
'版本历史',
|
|
||||||
style: theme.textTheme.titleSmall?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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),
|
||||||
@@ -354,9 +354,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
required bool isPreviewable,
|
required bool isPreviewable,
|
||||||
required FileModel file,
|
required FileModel file,
|
||||||
}) {
|
}) {
|
||||||
final shortId = entity.id.length > 6
|
final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id;
|
||||||
? entity.id.substring(0, 6)
|
|
||||||
: entity.id;
|
|
||||||
final createdBy = entity.createdBy?.nickname ?? '未知';
|
final createdBy = entity.createdBy?.nickname ?? '未知';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
@@ -378,32 +376,24 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
_buildVersionActionButton(
|
_buildVersionActionButton(
|
||||||
icon: LucideIcons.externalLink,
|
icon: LucideIcons.externalLink,
|
||||||
tooltip: '打开',
|
tooltip: '打开',
|
||||||
onPressed: _isVersionLoading
|
onPressed: _isVersionLoading ? null : () => _openVersion(entity),
|
||||||
? null
|
|
||||||
: () => _openVersion(entity),
|
|
||||||
),
|
),
|
||||||
_buildVersionActionButton(
|
_buildVersionActionButton(
|
||||||
icon: LucideIcons.download,
|
icon: LucideIcons.download,
|
||||||
tooltip: '下载',
|
tooltip: '下载',
|
||||||
onPressed: _isVersionLoading
|
onPressed: _isVersionLoading ? null : () => _downloadVersion(entity),
|
||||||
? null
|
|
||||||
: () => _downloadVersion(entity),
|
|
||||||
),
|
),
|
||||||
if (!isCurrent) ...[
|
if (!isCurrent) ...[
|
||||||
_buildVersionActionButton(
|
_buildVersionActionButton(
|
||||||
icon: LucideIcons.pin,
|
icon: LucideIcons.pin,
|
||||||
tooltip: '设为当前版本',
|
tooltip: '设为当前版本',
|
||||||
onPressed: _isVersionLoading
|
onPressed: _isVersionLoading ? null : () => _setCurrentVersion(entity),
|
||||||
? null
|
|
||||||
: () => _setCurrentVersion(entity),
|
|
||||||
),
|
),
|
||||||
_buildVersionActionButton(
|
_buildVersionActionButton(
|
||||||
icon: LucideIcons.trash2,
|
icon: LucideIcons.trash2,
|
||||||
tooltip: '删除',
|
tooltip: '删除',
|
||||||
color: colorScheme.error,
|
color: colorScheme.error,
|
||||||
onPressed: _isVersionLoading
|
onPressed: _isVersionLoading ? null : () => _deleteVersion(entity),
|
||||||
? null
|
|
||||||
: () => _deleteVersion(entity),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
@@ -449,10 +439,8 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
const SizedBox(height: 1),
|
const SizedBox(height: 1),
|
||||||
Text(
|
Text(
|
||||||
'${date_utils.DateUtils.formatDateTime(entity.createdAt)} · $createdBy',
|
'${date_utils.DateUtils.formatDateTime(entity.createdAt)} · $createdBy',
|
||||||
style: const TextStyle(
|
style: const TextStyle(fontSize: 11, color: null)
|
||||||
fontSize: 11,
|
.copyWith(color: theme.hintColor),
|
||||||
color: null,
|
|
||||||
).copyWith(color: theme.hintColor),
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
@@ -497,26 +485,14 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
_buildInfoRow(LucideIcons.hash, 'ID', entity.id),
|
_buildInfoRow(LucideIcons.hash, 'ID', entity.id),
|
||||||
_buildInfoRow(
|
_buildInfoRow(LucideIcons.hardDrive, '大小', date_utils.DateUtils.formatFileSize(entity.size)),
|
||||||
LucideIcons.hardDrive,
|
_buildInfoRow(LucideIcons.calendarPlus, '创建时间', date_utils.DateUtils.formatDateTime(entity.createdAt)),
|
||||||
'大小',
|
|
||||||
date_utils.DateUtils.formatFileSize(entity.size),
|
|
||||||
),
|
|
||||||
_buildInfoRow(
|
|
||||||
LucideIcons.calendarPlus,
|
|
||||||
'创建时间',
|
|
||||||
date_utils.DateUtils.formatDateTime(entity.createdAt),
|
|
||||||
),
|
|
||||||
if (createdBy != null) ...[
|
if (createdBy != null) ...[
|
||||||
_buildInfoRow(LucideIcons.user, '创建者', createdBy.nickname),
|
_buildInfoRow(LucideIcons.user, '创建者', createdBy.nickname),
|
||||||
_buildInfoRow(LucideIcons.fingerprint, '创建者ID', createdBy.id),
|
_buildInfoRow(LucideIcons.fingerprint, '创建者ID', createdBy.id),
|
||||||
],
|
],
|
||||||
if (entity.storagePolicy != null)
|
if (entity.storagePolicy != null)
|
||||||
_buildInfoRow(
|
_buildInfoRow(LucideIcons.server, '存储策略', '${entity.storagePolicy!.name} (${entity.storagePolicy!.type})'),
|
||||||
LucideIcons.server,
|
|
||||||
'存储策略',
|
|
||||||
'${entity.storagePolicy!.name} (${entity.storagePolicy!.type})',
|
|
||||||
),
|
|
||||||
if (entity.encryptedWith != null)
|
if (entity.encryptedWith != null)
|
||||||
_buildInfoRow(LucideIcons.lock, '加密', entity.encryptedWith!),
|
_buildInfoRow(LucideIcons.lock, '加密', entity.encryptedWith!),
|
||||||
],
|
],
|
||||||
@@ -548,8 +524,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 版本操作 ───
|
|
||||||
|
|
||||||
void _openVersion(EntityModel entity) {
|
void _openVersion(EntityModel entity) {
|
||||||
final file = _fileInfo!.file;
|
final file = _fileInfo!.file;
|
||||||
if (!FileTypeUtils.isPreviewable(file.name)) {
|
if (!FileTypeUtils.isPreviewable(file.name)) {
|
||||||
@@ -568,13 +542,9 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
} else if (FileTypeUtils.isAudio(file.name)) {
|
} else if (FileTypeUtils.isAudio(file.name)) {
|
||||||
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: args);
|
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: args);
|
||||||
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
||||||
Navigator.of(
|
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: args);
|
||||||
context,
|
|
||||||
).pushNamed(RouteNames.markdownPreview, arguments: args);
|
|
||||||
} else if (FileTypeUtils.isTextCode(file.name)) {
|
} else if (FileTypeUtils.isTextCode(file.name)) {
|
||||||
Navigator.of(
|
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: args);
|
||||||
context,
|
|
||||||
).pushNamed(RouteNames.documentPreview, arguments: args);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -625,16 +595,12 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
|
|
||||||
Future<void> _deleteVersion(EntityModel entity) async {
|
Future<void> _deleteVersion(EntityModel entity) async {
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final shortId = entity.id.length > 6
|
final shortId = entity.id.length > 6 ? entity.id.substring(0, 6) : entity.id;
|
||||||
? entity.id.substring(0, 6)
|
|
||||||
: entity.id;
|
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDialog<bool>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogContext) => AlertDialog(
|
builder: (dialogContext) => AlertDialog(
|
||||||
title: const Text('删除版本'),
|
title: const Text('删除版本'),
|
||||||
content: Text(
|
content: Text('确定要删除版本 "$shortId" (${date_utils.DateUtils.formatFileSize(entity.size)}) 吗?'),
|
||||||
'确定要删除版本 "$shortId" (${date_utils.DateUtils.formatFileSize(entity.size)}) 吗?',
|
|
||||||
),
|
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||||
@@ -669,8 +635,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── 文件夹摘要 ───
|
|
||||||
|
|
||||||
Widget _buildFolderSummary(ThemeData theme, ColorScheme colorScheme) {
|
Widget _buildFolderSummary(ThemeData theme, ColorScheme colorScheme) {
|
||||||
final summary = _fileInfo?.folderSummary;
|
final summary = _fileInfo?.folderSummary;
|
||||||
|
|
||||||
@@ -679,29 +643,15 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
children: [
|
children: [
|
||||||
_buildInfoRow(LucideIcons.file, '包含文件', '${summary.files}'),
|
_buildInfoRow(LucideIcons.file, '包含文件', '${summary.files}'),
|
||||||
_buildInfoRow(LucideIcons.folder, '包含文件夹', '${summary.folders}'),
|
_buildInfoRow(LucideIcons.folder, '包含文件夹', '${summary.folders}'),
|
||||||
_buildInfoRow(
|
_buildInfoRow(LucideIcons.hardDrive, '总大小', date_utils.DateUtils.formatFileSize(summary.size)),
|
||||||
LucideIcons.hardDrive,
|
|
||||||
'总大小',
|
|
||||||
date_utils.DateUtils.formatFileSize(summary.size),
|
|
||||||
),
|
|
||||||
if (!summary.completed)
|
if (!summary.completed)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 8),
|
padding: const EdgeInsets.only(top: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(LucideIcons.alertCircle, size: 14, color: theme.colorScheme.error),
|
||||||
LucideIcons.alertCircle,
|
|
||||||
size: 14,
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
Text(
|
Text('计算未完成,结果可能不完整', style: TextStyle(fontSize: 12, color: theme.colorScheme.error)),
|
||||||
'计算未完成,结果可能不完整',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -715,12 +665,7 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return _isCalculatingFolder
|
return _isCalculatingFolder
|
||||||
? const Center(
|
? const Center(child: Padding(padding: EdgeInsets.all(16), child: CircularProgressIndicator()))
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.all(16),
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: SizedBox(
|
: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: OutlinedButton.icon(
|
child: OutlinedButton.icon(
|
||||||
@@ -742,13 +687,13 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
|||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
width: 72,
|
width: 72,
|
||||||
child: Text(
|
child: Text(label, style: TextStyle(fontSize: 13, color: theme.hintColor)),
|
||||||
label,
|
|
||||||
style: TextStyle(fontSize: 13, color: theme.hintColor),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SelectableText(value, style: const TextStyle(fontSize: 13)),
|
child: SelectableText(
|
||||||
|
value,
|
||||||
|
style: const TextStyle(fontSize: 13),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class FileListItem extends StatelessWidget {
|
|||||||
final VoidCallback? onSelect;
|
final VoidCallback? onSelect;
|
||||||
final VoidCallback? onDownload;
|
final VoidCallback? onDownload;
|
||||||
final VoidCallback? onOpenInBrowser;
|
final VoidCallback? onOpenInBrowser;
|
||||||
|
final VoidCallback? onOpenInCloudreveApp;
|
||||||
final VoidCallback? onRename;
|
final VoidCallback? onRename;
|
||||||
final VoidCallback? onMove;
|
final VoidCallback? onMove;
|
||||||
final VoidCallback? onCopy;
|
final VoidCallback? onCopy;
|
||||||
@@ -40,6 +41,7 @@ class FileListItem extends StatelessWidget {
|
|||||||
this.onSelect,
|
this.onSelect,
|
||||||
this.onDownload,
|
this.onDownload,
|
||||||
this.onOpenInBrowser,
|
this.onOpenInBrowser,
|
||||||
|
this.onOpenInCloudreveApp,
|
||||||
this.onRename,
|
this.onRename,
|
||||||
this.onMove,
|
this.onMove,
|
||||||
this.onCopy,
|
this.onCopy,
|
||||||
@@ -51,7 +53,8 @@ class FileListItem extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _FileListItemHover(
|
return RepaintBoundary(
|
||||||
|
child: _FileListItemHover(
|
||||||
file: file,
|
file: file,
|
||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
isHighlighted: isHighlighted,
|
isHighlighted: isHighlighted,
|
||||||
@@ -62,6 +65,7 @@ class FileListItem extends StatelessWidget {
|
|||||||
onTap: tapToShowMenu ? null : onTap,
|
onTap: tapToShowMenu ? null : onTap,
|
||||||
onLongPress: () => _showMenu(context),
|
onLongPress: () => _showMenu(context),
|
||||||
onSelect: onSelect,
|
onSelect: onSelect,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,6 +75,7 @@ class FileListItem extends StatelessWidget {
|
|||||||
hasSelect: onSelect != null,
|
hasSelect: onSelect != null,
|
||||||
hasDownload: onDownload != null,
|
hasDownload: onDownload != null,
|
||||||
hasOpenInBrowser: onOpenInBrowser != null,
|
hasOpenInBrowser: onOpenInBrowser != null,
|
||||||
|
hasOpenInCloudreveApp: onOpenInCloudreveApp != null,
|
||||||
hasRename: onRename != null,
|
hasRename: onRename != null,
|
||||||
hasMove: onMove != null,
|
hasMove: onMove != null,
|
||||||
hasCopy: onCopy != null,
|
hasCopy: onCopy != null,
|
||||||
@@ -87,6 +92,8 @@ class FileListItem extends StatelessWidget {
|
|||||||
onDownload?.call();
|
onDownload?.call();
|
||||||
case FileMenuAction.openInBrowser:
|
case FileMenuAction.openInBrowser:
|
||||||
onOpenInBrowser?.call();
|
onOpenInBrowser?.call();
|
||||||
|
case FileMenuAction.openInCloudreveApp:
|
||||||
|
onOpenInCloudreveApp?.call();
|
||||||
case FileMenuAction.rename:
|
case FileMenuAction.rename:
|
||||||
onRename?.call();
|
onRename?.call();
|
||||||
case FileMenuAction.move:
|
case FileMenuAction.move:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ enum FileMenuAction {
|
|||||||
select,
|
select,
|
||||||
download,
|
download,
|
||||||
openInBrowser,
|
openInBrowser,
|
||||||
|
openInCloudreveApp,
|
||||||
rename,
|
rename,
|
||||||
move,
|
move,
|
||||||
copy,
|
copy,
|
||||||
@@ -22,6 +23,7 @@ Future<FileMenuAction?> showFileMenu({
|
|||||||
required bool hasSelect,
|
required bool hasSelect,
|
||||||
required bool hasDownload,
|
required bool hasDownload,
|
||||||
required bool hasOpenInBrowser,
|
required bool hasOpenInBrowser,
|
||||||
|
bool hasOpenInCloudreveApp = false,
|
||||||
required bool hasRename,
|
required bool hasRename,
|
||||||
required bool hasMove,
|
required bool hasMove,
|
||||||
required bool hasCopy,
|
required bool hasCopy,
|
||||||
@@ -87,6 +89,17 @@ Future<FileMenuAction?> showFileMenu({
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (hasOpenInCloudreveApp)
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: FileMenuAction.openInCloudreveApp,
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.web_asset, size: 20),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('在 Cloudreve 中打开'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
if (hasRename)
|
if (hasRename)
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: FileMenuAction.rename,
|
value: FileMenuAction.rename,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -132,7 +132,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
|||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: () => _enterFolder(folder),
|
onTap: () => _enterFolder(folder),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@@ -163,7 +163,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
|
|||||||
@@ -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> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
|
|||||||
/// 选择工具栏组件
|
/// 选择工具栏组件
|
||||||
class SelectionToolbar extends StatelessWidget {
|
class SelectionToolbar extends StatelessWidget {
|
||||||
final int selectionCount;
|
final int selectionCount;
|
||||||
|
final int totalCount;
|
||||||
final VoidCallback onCancel;
|
final VoidCallback onCancel;
|
||||||
final VoidCallback? onRename;
|
final VoidCallback? onSelectAll;
|
||||||
|
final VoidCallback? onMore;
|
||||||
final VoidCallback? onMove;
|
final VoidCallback? onMove;
|
||||||
final VoidCallback? onCopy;
|
final VoidCallback? onCopy;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
@@ -12,8 +14,10 @@ class SelectionToolbar extends StatelessWidget {
|
|||||||
const SelectionToolbar({
|
const SelectionToolbar({
|
||||||
super.key,
|
super.key,
|
||||||
required this.selectionCount,
|
required this.selectionCount,
|
||||||
|
this.totalCount = 0,
|
||||||
required this.onCancel,
|
required this.onCancel,
|
||||||
this.onRename,
|
this.onSelectAll,
|
||||||
|
this.onMore,
|
||||||
this.onMove,
|
this.onMove,
|
||||||
this.onCopy,
|
this.onCopy,
|
||||||
required this.onDelete,
|
required this.onDelete,
|
||||||
@@ -45,11 +49,19 @@ class SelectionToolbar extends StatelessWidget {
|
|||||||
onPressed: onCancel,
|
onPressed: onCancel,
|
||||||
tooltip: '取消选择',
|
tooltip: '取消选择',
|
||||||
),
|
),
|
||||||
if (selectionCount == 1 && onRename != null)
|
if (onSelectAll != null && selectionCount < totalCount)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.edit),
|
icon: const Icon(Icons.select_all),
|
||||||
onPressed: onRename,
|
onPressed: onSelectAll,
|
||||||
tooltip: '重命名',
|
tooltip: '全选',
|
||||||
|
),
|
||||||
|
if (selectionCount == 1 && onMore != null)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(Theme.of(context).platform == TargetPlatform.iOS
|
||||||
|
? Icons.more_horiz
|
||||||
|
: Icons.more_vert),
|
||||||
|
onPressed: onMore,
|
||||||
|
tooltip: '更多',
|
||||||
),
|
),
|
||||||
if (onMove != null)
|
if (onMove != null)
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -15,6 +16,9 @@ import '../presentation/pages/preview/audio_preview_page.dart';
|
|||||||
import '../presentation/pages/preview/document_preview_page.dart';
|
import '../presentation/pages/preview/document_preview_page.dart';
|
||||||
import '../presentation/pages/preview/markdown_preview_page.dart';
|
import '../presentation/pages/preview/markdown_preview_page.dart';
|
||||||
import '../presentation/pages/files/category_files_page.dart';
|
import '../presentation/pages/files/category_files_page.dart';
|
||||||
|
import '../presentation/pages/share/share_link_page.dart';
|
||||||
|
import '../presentation/pages/preview/cloudreve_file_app_page.dart';
|
||||||
|
import '../services/share_link_service.dart';
|
||||||
import '../data/models/file_model.dart';
|
import '../data/models/file_model.dart';
|
||||||
|
|
||||||
/// 路由名称
|
/// 路由名称
|
||||||
@@ -36,7 +40,10 @@ 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 cloudreveFileApp = '/cloudreve-file-app';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用路由
|
/// 应用路由
|
||||||
@@ -226,6 +233,41 @@ class AppRouter {
|
|||||||
builder: (context) => const SyncSettingsPage(),
|
builder: (context) => const SyncSettingsPage(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case RouteNames.syncStatus:
|
||||||
|
return MaterialPageRoute(
|
||||||
|
settings: settings,
|
||||||
|
builder: (context) => const SyncPageAndroid(),
|
||||||
|
);
|
||||||
|
|
||||||
|
case RouteNames.shareLink:
|
||||||
|
final args = settings.arguments;
|
||||||
|
if (args is ShareLinkCandidate) {
|
||||||
|
return MaterialPageRoute(
|
||||||
|
settings: settings,
|
||||||
|
builder: (context) => ShareLinkPage(candidate: args),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return MaterialPageRoute(
|
||||||
|
settings: settings,
|
||||||
|
builder: (context) => const SplashPage(),
|
||||||
|
);
|
||||||
|
|
||||||
|
case RouteNames.cloudreveFileApp:
|
||||||
|
final args = settings.arguments;
|
||||||
|
if (args is Map<String, dynamic>) {
|
||||||
|
return MaterialPageRoute(
|
||||||
|
settings: settings,
|
||||||
|
builder: (context) => CloudreveFileAppPage(
|
||||||
|
file: args['file'] as FileModel,
|
||||||
|
preferredAction: args['preferredAction'] as String?,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return MaterialPageRoute(
|
||||||
|
settings: settings,
|
||||||
|
builder: (context) => const SplashPage(),
|
||||||
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
return MaterialPageRoute(
|
return MaterialPageRoute(
|
||||||
settings: settings,
|
settings: settings,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:crypto/crypto.dart';
|
||||||
|
|
||||||
|
import 'api_service.dart';
|
||||||
|
import 'storage_service.dart';
|
||||||
|
import '../core/constants/storage_keys.dart';
|
||||||
|
|
||||||
|
class SiteAnnouncement {
|
||||||
|
final String title;
|
||||||
|
final String html;
|
||||||
|
final String baseUrl;
|
||||||
|
final String fingerprint;
|
||||||
|
|
||||||
|
const SiteAnnouncement({
|
||||||
|
required this.title,
|
||||||
|
required this.html,
|
||||||
|
required this.baseUrl,
|
||||||
|
required this.fingerprint,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 登录后公告服务。
|
||||||
|
///
|
||||||
|
/// Cloudreve V4 SiteConfig 的公告字段是 site_notice。
|
||||||
|
/// 不读取 custom_html.headless_bottom / sidebar_bottom,那些是页面装饰 HTML。
|
||||||
|
///
|
||||||
|
/// 弹窗策略:
|
||||||
|
/// - 公告为空:不弹;
|
||||||
|
/// - 当前公告内容和用户上次关闭时一致:不弹;
|
||||||
|
/// - 公告内容变化:重新弹。
|
||||||
|
class AnnouncementService {
|
||||||
|
AnnouncementService._();
|
||||||
|
|
||||||
|
static final AnnouncementService instance = AnnouncementService._();
|
||||||
|
|
||||||
|
bool _shownInSession = false;
|
||||||
|
|
||||||
|
/// 兼容旧调用。新的判断以持久化 fingerprint 为准。
|
||||||
|
bool get hasShown => _shownInSession;
|
||||||
|
|
||||||
|
/// 兼容旧调用。新的业务请使用 [markDismissed]。
|
||||||
|
void markShown() {
|
||||||
|
_shownInSession = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SiteAnnouncement?> getSiteNotice() async {
|
||||||
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
|
'/site/config/basic',
|
||||||
|
);
|
||||||
|
|
||||||
|
final data = _asMap(response['data']) ?? response;
|
||||||
|
final notice = data['site_notice']?.toString().trim();
|
||||||
|
|
||||||
|
if (notice == null || notice.isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final baseUrl = ApiService.instance.dio.options.baseUrl;
|
||||||
|
return SiteAnnouncement(
|
||||||
|
title: '公告',
|
||||||
|
html: notice,
|
||||||
|
baseUrl: baseUrl,
|
||||||
|
fingerprint: _fingerprint(baseUrl: baseUrl, html: notice),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 只返回需要弹出的公告。
|
||||||
|
///
|
||||||
|
/// 用户关闭过同一条公告后,后续启动不会重复弹;站点公告内容改变后会再次弹。
|
||||||
|
Future<SiteAnnouncement?> getChangedSiteNotice() async {
|
||||||
|
final notice = await getSiteNotice();
|
||||||
|
if (notice == null) return null;
|
||||||
|
|
||||||
|
if (_shownInSession) return null;
|
||||||
|
|
||||||
|
final dismissedFingerprint = await StorageService.instance.getString(
|
||||||
|
StorageKeys.siteAnnouncementDismissedFingerprint,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dismissedFingerprint == notice.fingerprint) {
|
||||||
|
_shownInSession = true;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return notice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 用户关闭公告后调用。
|
||||||
|
Future<void> markDismissed(SiteAnnouncement announcement) async {
|
||||||
|
_shownInSession = true;
|
||||||
|
await StorageService.instance.setString(
|
||||||
|
StorageKeys.siteAnnouncementDismissedFingerprint,
|
||||||
|
announcement.fingerprint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fingerprint({required String baseUrl, required String html}) {
|
||||||
|
final normalized = '${baseUrl.trim()}\n${html.trim()}';
|
||||||
|
return sha256.convert(utf8.encode(normalized)).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _asMap(dynamic value) {
|
||||||
|
if (value is Map<String, dynamic>) return value;
|
||||||
|
if (value is Map) return Map<String, dynamic>.from(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import '../core/utils/app_logger.dart';
|
|||||||
class ApiResponse<T> {
|
class ApiResponse<T> {
|
||||||
final int code;
|
final int code;
|
||||||
final String message;
|
final String message;
|
||||||
final T? data;
|
final dynamic data;
|
||||||
final String? error;
|
final String? error;
|
||||||
final String? correlationId;
|
final String? correlationId;
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ class ApiResponse<T> {
|
|||||||
return ApiResponse<T>(
|
return ApiResponse<T>(
|
||||||
code: json['code'] as int? ?? 0,
|
code: json['code'] as int? ?? 0,
|
||||||
message: json['msg'] as String? ?? '',
|
message: json['msg'] as String? ?? '',
|
||||||
data: json['data'] as T?,
|
data: json['data'],
|
||||||
error: json['error'] as String?,
|
error: json['error'] as String?,
|
||||||
correlationId: json['correlation_id'] as String?,
|
correlationId: json['correlation_id'] as String?,
|
||||||
);
|
);
|
||||||
@@ -198,6 +198,12 @@ class ApiService {
|
|||||||
AppLogger.d('API Error: ${error.requestOptions.uri} - ${error.message}');
|
AppLogger.d('API Error: ${error.requestOptions.uri} - ${error.message}');
|
||||||
|
|
||||||
// 检查是否是 401 错误(HTTP 401 或 JSON code: 401)
|
// 检查是否是 401 错误(HTTP 401 或 JSON code: 401)
|
||||||
|
final silent404 =
|
||||||
|
error.requestOptions.extra['silent404'] as bool? ?? false;
|
||||||
|
if (silent404 && error.response?.statusCode == 404) {
|
||||||
|
return handler.next(error);
|
||||||
|
}
|
||||||
|
|
||||||
bool is401Error = error.response?.statusCode == 401;
|
bool is401Error = error.response?.statusCode == 401;
|
||||||
if (!is401Error && error.response?.data is Map<String, dynamic>) {
|
if (!is401Error && error.response?.data is Map<String, dynamic>) {
|
||||||
final data = error.response!.data as Map<String, dynamic>;
|
final data = error.response!.data as Map<String, dynamic>;
|
||||||
@@ -320,12 +326,16 @@ class ApiService {
|
|||||||
Map<String, dynamic>? queryParameters,
|
Map<String, dynamic>? queryParameters,
|
||||||
bool noAuth = false,
|
bool noAuth = false,
|
||||||
bool isNoData = false,
|
bool isNoData = false,
|
||||||
|
bool silent404 = false,
|
||||||
Map<String, dynamic>? headers,
|
Map<String, dynamic>? headers,
|
||||||
}) async {
|
}) async {
|
||||||
final response = await _dio.get<T>(
|
final response = await _dio.get<T>(
|
||||||
path,
|
path,
|
||||||
queryParameters: queryParameters,
|
queryParameters: queryParameters,
|
||||||
options: Options(extra: {'noAuth': noAuth}, headers: headers),
|
options: Options(
|
||||||
|
extra: {'noAuth': noAuth, 'silent404': silent404},
|
||||||
|
headers: headers,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
// 如果是分享请求, 则不进入 _parseResponse
|
// 如果是分享请求, 则不进入 _parseResponse
|
||||||
if (isNoData) {
|
if (isNoData) {
|
||||||
@@ -356,9 +366,12 @@ class ApiService {
|
|||||||
AppLogger.d('Response Data: ${response.data}');
|
AppLogger.d('Response Data: ${response.data}');
|
||||||
|
|
||||||
var isActivEmail = 0;
|
var isActivEmail = 0;
|
||||||
if (response.statusCode == 200) {
|
if (response.statusCode == 200 && response.data is Map) {
|
||||||
Map<String, dynamic>? tmp = response.data as Map<String, dynamic>?;
|
final tmp = Map<String, dynamic>.from(response.data as Map);
|
||||||
isActivEmail = tmp?['code'] as int;
|
final code = tmp['code'];
|
||||||
|
if (code is int) {
|
||||||
|
isActivEmail = code;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isNoData || isActivEmail == 203) {
|
if (isNoData || isActivEmail == 203) {
|
||||||
@@ -447,11 +460,18 @@ class ApiService {
|
|||||||
T _parseResponse<T>(Response response) {
|
T _parseResponse<T>(Response response) {
|
||||||
final data = response.data;
|
final data = response.data;
|
||||||
if (data is Map<String, dynamic>) {
|
if (data is Map<String, dynamic>) {
|
||||||
final apiResponse = ApiResponse<T>.fromJson(data);
|
final apiResponse = ApiResponse<dynamic>.fromJson(data);
|
||||||
if (!apiResponse.isSuccess && !apiResponse.isContinue) {
|
if (!apiResponse.isSuccess && !apiResponse.isContinue) {
|
||||||
throw ServerException(apiResponse.message, code: apiResponse.code);
|
throw ServerException(apiResponse.message, code: apiResponse.code);
|
||||||
}
|
}
|
||||||
return apiResponse.data as T;
|
final payload = apiResponse.data;
|
||||||
|
if (payload is T) {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
if (data is T) {
|
||||||
|
return data as T;
|
||||||
|
}
|
||||||
|
return payload as T;
|
||||||
}
|
}
|
||||||
return data as T;
|
return data as T;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../presentation/pages/auth/captcha_challenge_page.dart';
|
import '../presentation/pages/auth/captcha_challenge_page.dart';
|
||||||
@@ -30,6 +32,11 @@ class CaptchaService {
|
|||||||
String? _captchaToken;
|
String? _captchaToken;
|
||||||
bool _isLoadingCaptcha = false;
|
bool _isLoadingCaptcha = false;
|
||||||
|
|
||||||
|
// 代理配置(仅 Windows 桌面端)
|
||||||
|
CaptchaProxyConfig? _proxyConfig;
|
||||||
|
|
||||||
|
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
|
||||||
|
|
||||||
bool get isLoadingCaptcha => _isLoadingCaptcha;
|
bool get isLoadingCaptcha => _isLoadingCaptcha;
|
||||||
String? get captchaImage => _captchaImage;
|
String? get captchaImage => _captchaImage;
|
||||||
String? get captchaTicket => _captchaTicket;
|
String? get captchaTicket => _captchaTicket;
|
||||||
@@ -103,13 +110,15 @@ class CaptchaService {
|
|||||||
Map<String, dynamic> config = <String, dynamic>{};
|
Map<String, dynamic> config = <String, dynamic>{};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
config = await AuthService.instance.getBasicSiteConfig().timeout(
|
config = await AuthService.instance
|
||||||
const Duration(seconds: 10),
|
.getBasicSiteConfig()
|
||||||
);
|
.timeout(const Duration(seconds: 10));
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
|
|
||||||
final captchaType = _normalizeCaptchaType(
|
final captchaType = _normalizeCaptchaType(
|
||||||
(config['captcha_type'] ?? config['captchaType'] ?? config['captcha'])
|
(config['captcha_type'] ??
|
||||||
|
config['captchaType'] ??
|
||||||
|
config['captcha'])
|
||||||
?.toString(),
|
?.toString(),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -151,8 +160,7 @@ class CaptchaService {
|
|||||||
'capAssetServer',
|
'capAssetServer',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
final isExternalCaptcha =
|
final isExternalCaptcha = captchaType == 'turnstile' ||
|
||||||
captchaType == 'turnstile' ||
|
|
||||||
captchaType == 'recaptcha' ||
|
captchaType == 'recaptcha' ||
|
||||||
captchaType == 'cap';
|
captchaType == 'cap';
|
||||||
|
|
||||||
@@ -199,7 +207,7 @@ class CaptchaService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 跳转到 Web 验证码页面
|
/// 跳转到 Web 验证码页面
|
||||||
Future<void> openCaptchaChallenge(BuildContext context) async {
|
Future<void> openCaptchaChallenge(BuildContext context, {VoidCallback? onVerified}) async {
|
||||||
final server = ServerService.instance.currentServer;
|
final server = ServerService.instance.currentServer;
|
||||||
final config = captchaWebConfig;
|
final config = captchaWebConfig;
|
||||||
|
|
||||||
@@ -210,14 +218,18 @@ class CaptchaService {
|
|||||||
|
|
||||||
final token = await Navigator.of(context).push<String>(
|
final token = await Navigator.of(context).push<String>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) =>
|
builder: (_) => CaptchaChallengePage(
|
||||||
CaptchaChallengePage(config: config, baseUrl: server.baseUrl),
|
config: config,
|
||||||
|
baseUrl: server.baseUrl,
|
||||||
|
proxyConfig: _isDesktop ? _proxyConfig : null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (token != null && token.isNotEmpty) {
|
if (token != null && token.isNotEmpty) {
|
||||||
_captchaToken = token;
|
_captchaToken = token;
|
||||||
ToastHelper.success('人机验证完成');
|
ToastHelper.success('人机验证完成');
|
||||||
|
onVerified?.call();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -241,16 +253,21 @@ class CaptchaService {
|
|||||||
Map<String, String> getCaptchaParams() {
|
Map<String, String> getCaptchaParams() {
|
||||||
if (isWebCaptcha) {
|
if (isWebCaptcha) {
|
||||||
if (_captchaToken == null || _captchaToken!.isEmpty) return {};
|
if (_captchaToken == null || _captchaToken!.isEmpty) return {};
|
||||||
return {'captcha': _captchaToken!, 'ticket': _captchaToken!};
|
return {
|
||||||
|
'captcha': _captchaToken!,
|
||||||
|
'ticket': _captchaToken!,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
final userInput = captchaController.text.trim();
|
final userInput = captchaController.text.trim();
|
||||||
if (userInput.isEmpty &&
|
if (userInput.isEmpty && (_captchaTicket == null || _captchaTicket!.isEmpty)) {
|
||||||
(_captchaTicket == null || _captchaTicket!.isEmpty)) {
|
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {'captcha': userInput, 'ticket': _captchaTicket ?? ''};
|
return {
|
||||||
|
'captcha': userInput,
|
||||||
|
'ticket': _captchaTicket ?? '',
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Web 验证码是否已通过
|
/// Web 验证码是否已通过
|
||||||
@@ -263,13 +280,20 @@ class CaptchaService {
|
|||||||
final config = captchaWebConfig;
|
final config = captchaWebConfig;
|
||||||
final displayName = config?.displayName ?? '人机验证';
|
final displayName = config?.displayName ?? '人机验证';
|
||||||
|
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setState) {
|
||||||
|
final hasProxy = _proxyConfig != null;
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: _isLoadingCaptcha
|
onPressed: _isLoadingCaptcha ? null : () async {
|
||||||
? null
|
await openCaptchaChallenge(context);
|
||||||
: () => openCaptchaChallenge(context),
|
setState(() {});
|
||||||
|
},
|
||||||
|
onLongPress: _isDesktop && Platform.isWindows
|
||||||
|
? () => _showProxyDialog(context, setState)
|
||||||
|
: null,
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
_captchaToken == null
|
_captchaToken == null
|
||||||
? Icons.verified_user_outlined
|
? Icons.verified_user_outlined
|
||||||
@@ -283,11 +307,18 @@ class CaptchaService {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'当前验证码类型:$displayName',
|
_isDesktop && Platform.isWindows
|
||||||
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
|
? '当前验证码类型:$displayName${hasProxy ? ' (代理: $_proxyConfig)' : ''}\n网络问题验证失败可长按上方按钮可以设置代理(仅windows)'
|
||||||
|
: '当前验证码类型:$displayName',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).hintColor,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget captchaPreview;
|
Widget captchaPreview;
|
||||||
@@ -392,4 +423,80 @@ class CaptchaService {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Windows 桌面端长按弹出代理设置对话框
|
||||||
|
void _showProxyDialog(BuildContext context, StateSetter setState) {
|
||||||
|
final hostCtrl = TextEditingController(text: _proxyConfig?.host ?? '');
|
||||||
|
final portCtrl = TextEditingController(text: _proxyConfig?.port.toString() ?? '');
|
||||||
|
|
||||||
|
showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: const Text('WebView 代理设置'),
|
||||||
|
content: SizedBox(
|
||||||
|
width: 320,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'仅支持无认证代理(HTTP/SOCKS5)',
|
||||||
|
style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextFormField(
|
||||||
|
controller: hostCtrl,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '代理地址',
|
||||||
|
hintText: '127.0.0.1',
|
||||||
|
prefixIcon: Icon(Icons.dns_outlined),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextFormField(
|
||||||
|
controller: portCtrl,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: '端口',
|
||||||
|
hintText: '7890',
|
||||||
|
prefixIcon: Icon(Icons.numbers),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
_proxyConfig = null;
|
||||||
|
setState(() {});
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
ToastHelper.success('已清除代理配置');
|
||||||
|
},
|
||||||
|
child: const Text('清除'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(ctx).pop(),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () {
|
||||||
|
final host = hostCtrl.text.trim();
|
||||||
|
final port = int.tryParse(portCtrl.text.trim()) ?? 0;
|
||||||
|
if (host.isEmpty || port <= 0) {
|
||||||
|
ToastHelper.failure('请输入有效的代理地址和端口');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_proxyConfig = CaptchaProxyConfig(host: host, port: port);
|
||||||
|
setState(() {});
|
||||||
|
Navigator.of(ctx).pop();
|
||||||
|
ToastHelper.success('代理已设置: $host:$port');
|
||||||
|
},
|
||||||
|
child: const Text('确定'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 全局弹窗队列。
|
||||||
|
///
|
||||||
|
/// 用于登录后自动弹出的公告、更新、剪贴板分享链接等场景,保证同一时间
|
||||||
|
/// 只有一个弹窗/引导流程在前台显示。前一个流程结束后,后一个流程才会执行。
|
||||||
|
class DialogQueueService {
|
||||||
|
DialogQueueService._();
|
||||||
|
|
||||||
|
static final DialogQueueService instance = DialogQueueService._();
|
||||||
|
|
||||||
|
Future<void> _tail = Future<void>.value();
|
||||||
|
|
||||||
|
Future<T?> enqueue<T>(Future<T?> Function() task) {
|
||||||
|
final completer = Completer<T?>();
|
||||||
|
|
||||||
|
_tail = _tail.catchError((_) {}).then((_) async {
|
||||||
|
try {
|
||||||
|
final result = await task();
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.complete(result);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
if (!completer.isCompleted) {
|
||||||
|
completer.completeError(error, stackTrace);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return completer.future;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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];
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'api_service.dart';
|
||||||
|
import '../core/utils/file_type_utils.dart';
|
||||||
|
import '../core/utils/file_utils.dart';
|
||||||
|
import '../data/models/file_model.dart';
|
||||||
|
|
||||||
|
class FileAppViewer {
|
||||||
|
final String id;
|
||||||
|
final String type;
|
||||||
|
final String displayName;
|
||||||
|
final List<String> exts;
|
||||||
|
final String? icon;
|
||||||
|
final int maxSize;
|
||||||
|
final String? url;
|
||||||
|
|
||||||
|
const FileAppViewer({
|
||||||
|
required this.id,
|
||||||
|
required this.type,
|
||||||
|
required this.displayName,
|
||||||
|
required this.exts,
|
||||||
|
this.icon,
|
||||||
|
this.maxSize = 0,
|
||||||
|
this.url,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory FileAppViewer.fromJson(Map<String, dynamic> json) {
|
||||||
|
return FileAppViewer(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
type: json['type']?.toString() ?? '',
|
||||||
|
displayName: json['display_name']?.toString() ??
|
||||||
|
json['displayName']?.toString() ??
|
||||||
|
json['name']?.toString() ??
|
||||||
|
'文件应用',
|
||||||
|
exts: _parseExts(json['exts'] ?? json['extensions'] ?? json['ext']),
|
||||||
|
icon: json['icon']?.toString(),
|
||||||
|
maxSize: (json['max_size'] as num?)?.toInt() ??
|
||||||
|
(json['maxSize'] as num?)?.toInt() ??
|
||||||
|
0,
|
||||||
|
url: json['url']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _parseExts(dynamic raw) {
|
||||||
|
if (raw is List) {
|
||||||
|
return raw
|
||||||
|
.map((e) => e.toString().toLowerCase().replaceAll('.', '').trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw is String) {
|
||||||
|
return raw
|
||||||
|
.split(',')
|
||||||
|
.map((e) => e.toLowerCase().replaceAll('.', '').trim())
|
||||||
|
.where((e) => e.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
bool supports(FileModel file) {
|
||||||
|
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
|
||||||
|
if (ext.isEmpty || !exts.contains(ext)) return false;
|
||||||
|
if (maxSize > 0 && file.size > maxSize) return false;
|
||||||
|
return id.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isWopi => type.toLowerCase().contains('wopi');
|
||||||
|
}
|
||||||
|
|
||||||
|
class FileAppSession {
|
||||||
|
final FileAppViewer viewer;
|
||||||
|
final String wopiSrc;
|
||||||
|
final String? accessToken;
|
||||||
|
final int? expires;
|
||||||
|
|
||||||
|
const FileAppSession({
|
||||||
|
required this.viewer,
|
||||||
|
required this.wopiSrc,
|
||||||
|
this.accessToken,
|
||||||
|
this.expires,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get hasAccessToken => accessToken != null && accessToken!.isNotEmpty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cloudreve V4 文件应用服务。
|
||||||
|
///
|
||||||
|
/// 注意:部分 Cloudreve V4 站点没有 `/site/config`,只有 `/site/config/basic`。
|
||||||
|
/// 所以这里优先请求 `/site/config/basic`,如果没有 file_viewers 再尝试
|
||||||
|
/// `/site/config`,并且对 404 做静默降级,避免 WebView 打开器直接崩溃。
|
||||||
|
class FileAppService {
|
||||||
|
FileAppService._();
|
||||||
|
|
||||||
|
static final FileAppService instance = FileAppService._();
|
||||||
|
|
||||||
|
List<FileAppViewer>? _cachedViewers;
|
||||||
|
DateTime? _cachedAt;
|
||||||
|
|
||||||
|
Future<List<FileAppViewer>> getViewers({bool forceRefresh = false}) async {
|
||||||
|
final cached = _cachedViewers;
|
||||||
|
final cachedAt = _cachedAt;
|
||||||
|
if (!forceRefresh &&
|
||||||
|
cached != null &&
|
||||||
|
cachedAt != null &&
|
||||||
|
DateTime.now().difference(cachedAt).inMinutes < 10) {
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
final viewers = await _loadViewersFromSiteConfig();
|
||||||
|
|
||||||
|
_cachedViewers = viewers;
|
||||||
|
_cachedAt = DateTime.now();
|
||||||
|
|
||||||
|
return viewers;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<FileAppViewer>> _loadViewersFromSiteConfig() async {
|
||||||
|
// 当前项目登录验证码就是从 /site/config/basic 取的;
|
||||||
|
// /site/config 在部分 Cloudreve 部署中不存在,会返回 404。
|
||||||
|
const endpoints = <String>[
|
||||||
|
'/site/config/basic',
|
||||||
|
'/site/config',
|
||||||
|
];
|
||||||
|
|
||||||
|
for (final endpoint in endpoints) {
|
||||||
|
final root = await _tryGetSiteConfig(endpoint);
|
||||||
|
if (root == null) continue;
|
||||||
|
|
||||||
|
final viewers = _extractViewers(root);
|
||||||
|
if (viewers.isNotEmpty) {
|
||||||
|
return viewers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, dynamic>?> _tryGetSiteConfig(String endpoint) async {
|
||||||
|
try {
|
||||||
|
final response = await ApiService.instance.dio.get<dynamic>(
|
||||||
|
endpoint,
|
||||||
|
options: Options(
|
||||||
|
extra: {'noAuth': true},
|
||||||
|
// 404 也作为普通响应返回,由这里自己处理,避免进入全局错误拦截器。
|
||||||
|
validateStatus: (status) => status != null && status >= 200 && status < 500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response.statusCode == 404) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final raw = response.data;
|
||||||
|
final map = _asMap(raw);
|
||||||
|
if (map == null) return null;
|
||||||
|
|
||||||
|
final code = map['code'];
|
||||||
|
if (code is int && code != 0 && code != 203) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return _asMap(map['data']) ?? map;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FileAppViewer?> findViewerForFile(
|
||||||
|
FileModel file, {
|
||||||
|
bool forceRefresh = false,
|
||||||
|
}) async {
|
||||||
|
final viewers = await getViewers(forceRefresh: forceRefresh);
|
||||||
|
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
|
||||||
|
|
||||||
|
final candidates = viewers.where((viewer) => viewer.supports(file)).toList();
|
||||||
|
if (candidates.isEmpty) return null;
|
||||||
|
|
||||||
|
candidates.sort((a, b) {
|
||||||
|
final aw = a.isWopi ? 0 : 1;
|
||||||
|
final bw = b.isWopi ? 0 : 1;
|
||||||
|
if (aw != bw) return aw.compareTo(bw);
|
||||||
|
|
||||||
|
final aExact = a.exts.contains(ext) ? 0 : 1;
|
||||||
|
final bExact = b.exts.contains(ext) ? 0 : 1;
|
||||||
|
return aExact.compareTo(bExact);
|
||||||
|
});
|
||||||
|
|
||||||
|
return candidates.first;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FileAppSession> createViewerSession({
|
||||||
|
required FileModel file,
|
||||||
|
required FileAppViewer viewer,
|
||||||
|
String preferredAction = 'view',
|
||||||
|
}) async {
|
||||||
|
final uri = FileUtils.toCloudreveUri(file.relativePath);
|
||||||
|
final data = <String, dynamic>{
|
||||||
|
'uri': uri,
|
||||||
|
'viewer_id': viewer.id,
|
||||||
|
'preferred_action': preferredAction,
|
||||||
|
'parent_uri': _parentUri(uri),
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await ApiService.instance.put<Map<String, dynamic>>(
|
||||||
|
'/file/viewerSession',
|
||||||
|
data: data,
|
||||||
|
);
|
||||||
|
|
||||||
|
final root = _asMap(response['data']) ?? response;
|
||||||
|
final session = _asMap(root['session']);
|
||||||
|
final wopiSrc = root['wopi_src']?.toString() ??
|
||||||
|
root['wopiSrc']?.toString() ??
|
||||||
|
root['url']?.toString() ??
|
||||||
|
'';
|
||||||
|
|
||||||
|
if (wopiSrc.isEmpty) {
|
||||||
|
throw Exception('文件应用没有返回可打开的 URL');
|
||||||
|
}
|
||||||
|
|
||||||
|
return FileAppSession(
|
||||||
|
viewer: viewer,
|
||||||
|
wopiSrc: _absoluteUrl(wopiSrc),
|
||||||
|
accessToken: session?['access_token']?.toString() ??
|
||||||
|
session?['accessToken']?.toString(),
|
||||||
|
expires: (session?['expires'] as num?)?.toInt(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<FileAppSession> openFile({
|
||||||
|
required FileModel file,
|
||||||
|
String preferredAction = 'view',
|
||||||
|
}) async {
|
||||||
|
final viewer = await findViewerForFile(file);
|
||||||
|
if (viewer == null) {
|
||||||
|
throw Exception('没有找到支持 ${FileTypeUtils.getExtension(file.name)} 的 Cloudreve 文件应用');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await createViewerSession(
|
||||||
|
file: file,
|
||||||
|
viewer: viewer,
|
||||||
|
preferredAction: preferredAction,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (preferredAction != 'view') {
|
||||||
|
return createViewerSession(
|
||||||
|
file: file,
|
||||||
|
viewer: viewer,
|
||||||
|
preferredAction: 'view',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<FileAppViewer> _extractViewers(Map<String, dynamic> root) {
|
||||||
|
final raw = _findValueByKey(root, const [
|
||||||
|
'file_viewers',
|
||||||
|
'fileViewers',
|
||||||
|
'file_viewer',
|
||||||
|
'viewers',
|
||||||
|
]);
|
||||||
|
|
||||||
|
dynamic viewerList = raw;
|
||||||
|
|
||||||
|
final rawMap = _asMap(raw);
|
||||||
|
if (rawMap != null) {
|
||||||
|
viewerList = rawMap['viewers'] ?? rawMap['items'] ?? rawMap['data'];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (viewerList is! List) return const [];
|
||||||
|
|
||||||
|
return viewerList
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) => FileAppViewer.fromJson(Map<String, dynamic>.from(item)))
|
||||||
|
.where((viewer) => viewer.id.isNotEmpty && viewer.exts.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _findValueByKey(dynamic value, List<String> keys) {
|
||||||
|
if (value is Map) {
|
||||||
|
for (final key in keys) {
|
||||||
|
if (value.containsKey(key)) {
|
||||||
|
return value[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final child in value.values) {
|
||||||
|
final found = _findValueByKey(child, keys);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
} else if (value is List) {
|
||||||
|
for (final child in value) {
|
||||||
|
final found = _findValueByKey(child, keys);
|
||||||
|
if (found != null) return found;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _asMap(dynamic value) {
|
||||||
|
if (value is Map<String, dynamic>) return value;
|
||||||
|
if (value is Map) return Map<String, dynamic>.from(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parentUri(String uri) {
|
||||||
|
final normalized = uri.endsWith('/') ? uri.substring(0, uri.length - 1) : uri;
|
||||||
|
final index = normalized.lastIndexOf('/');
|
||||||
|
if (index <= 'cloudreve://my'.length) {
|
||||||
|
return 'cloudreve://my';
|
||||||
|
}
|
||||||
|
return normalized.substring(0, index);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _absoluteUrl(String url) {
|
||||||
|
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
|
||||||
|
final origin = Uri(
|
||||||
|
scheme: base.scheme,
|
||||||
|
host: base.host,
|
||||||
|
port: base.hasPort ? base.port : null,
|
||||||
|
).toString();
|
||||||
|
|
||||||
|
if (url.startsWith('/')) {
|
||||||
|
return '$origin$url';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '$origin/$url';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -0,0 +1,906 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
|
||||||
|
import 'api_service.dart';
|
||||||
|
import '../core/utils/file_utils.dart';
|
||||||
|
|
||||||
|
class ShareLinkCandidate {
|
||||||
|
final String id;
|
||||||
|
final String url;
|
||||||
|
final String? password;
|
||||||
|
|
||||||
|
const ShareLinkCandidate({
|
||||||
|
required this.id,
|
||||||
|
required this.url,
|
||||||
|
this.password,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShareLinkInfo {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final int visited;
|
||||||
|
final bool expired;
|
||||||
|
final bool unlocked;
|
||||||
|
final bool isPrivate;
|
||||||
|
final int sourceType;
|
||||||
|
final String? sourceUri;
|
||||||
|
final int size;
|
||||||
|
final String? url;
|
||||||
|
final String? ownerName;
|
||||||
|
final String? ownerId;
|
||||||
|
final String? ownerAvatar;
|
||||||
|
final String? contextHint;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? expires;
|
||||||
|
|
||||||
|
const ShareLinkInfo({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.visited,
|
||||||
|
required this.expired,
|
||||||
|
required this.unlocked,
|
||||||
|
required this.isPrivate,
|
||||||
|
required this.sourceType,
|
||||||
|
this.sourceUri,
|
||||||
|
this.size = 0,
|
||||||
|
this.url,
|
||||||
|
this.ownerName,
|
||||||
|
this.ownerId,
|
||||||
|
this.ownerAvatar,
|
||||||
|
this.contextHint,
|
||||||
|
this.createdAt,
|
||||||
|
this.expires,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isFolder => sourceType == 1;
|
||||||
|
bool get isFile => !isFolder;
|
||||||
|
|
||||||
|
factory ShareLinkInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
final owner = _asMap(json['owner']);
|
||||||
|
final source = _asMap(json['source']);
|
||||||
|
final file = _asMap(json['file']);
|
||||||
|
final object = _asMap(json['object']);
|
||||||
|
final entity = _asMap(json['entity']);
|
||||||
|
final rawSource = json['source'];
|
||||||
|
|
||||||
|
final ownerId = (owner?['id'] ??
|
||||||
|
json['owner_id'] ??
|
||||||
|
json['ownerId'] ??
|
||||||
|
source?['owner_id'] ??
|
||||||
|
file?['owner_id'])
|
||||||
|
?.toString();
|
||||||
|
|
||||||
|
final name = (json['name'] ??
|
||||||
|
file?['name'] ??
|
||||||
|
source?['name'] ??
|
||||||
|
object?['name'] ??
|
||||||
|
'分享文件')
|
||||||
|
.toString();
|
||||||
|
|
||||||
|
final parsedSourceUri = (json['source_uri'] ??
|
||||||
|
json['sourceUri'] ??
|
||||||
|
json['uri'] ??
|
||||||
|
json['path'] ??
|
||||||
|
(rawSource is String ? rawSource : null) ??
|
||||||
|
source?['uri'] ??
|
||||||
|
source?['source_uri'] ??
|
||||||
|
source?['path'] ??
|
||||||
|
source?['url'] ??
|
||||||
|
file?['uri'] ??
|
||||||
|
file?['source_uri'] ??
|
||||||
|
file?['path'] ??
|
||||||
|
object?['uri'] ??
|
||||||
|
object?['source_uri'] ??
|
||||||
|
object?['path'] ??
|
||||||
|
entity?['uri'] ??
|
||||||
|
entity?['source_uri'] ??
|
||||||
|
entity?['path'])
|
||||||
|
?.toString();
|
||||||
|
|
||||||
|
final resolvedSourceUri = _looksLikeCloudreveUri(parsedSourceUri)
|
||||||
|
? parsedSourceUri
|
||||||
|
: _fallbackSourceUri(ownerId: ownerId, name: name);
|
||||||
|
|
||||||
|
final size = _asInt(json['size'] ??
|
||||||
|
json['source_size'] ??
|
||||||
|
json['sourceSize'] ??
|
||||||
|
source?['size'] ??
|
||||||
|
file?['size'] ??
|
||||||
|
object?['size'] ??
|
||||||
|
entity?['size']);
|
||||||
|
|
||||||
|
return ShareLinkInfo(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
name: name,
|
||||||
|
size: size,
|
||||||
|
visited: _asInt(json['visited']),
|
||||||
|
expired: json['expired'] as bool? ?? false,
|
||||||
|
unlocked: json['unlocked'] as bool? ?? false,
|
||||||
|
isPrivate: json['is_private'] as bool? ?? false,
|
||||||
|
sourceType: _asInt(json['source_type'] ??
|
||||||
|
source?['type'] ??
|
||||||
|
file?['type'] ??
|
||||||
|
object?['type']),
|
||||||
|
sourceUri: resolvedSourceUri,
|
||||||
|
url: json['url']?.toString(),
|
||||||
|
ownerName: owner?['nickname']?.toString() ??
|
||||||
|
owner?['name']?.toString() ??
|
||||||
|
json['owner_name']?.toString(),
|
||||||
|
ownerId: ownerId,
|
||||||
|
ownerAvatar: owner?['avatar']?.toString(),
|
||||||
|
contextHint: json['context_hint']?.toString() ??
|
||||||
|
json['contextHint']?.toString() ??
|
||||||
|
json['context']?.toString(),
|
||||||
|
createdAt: _parseDate(json['created_at']),
|
||||||
|
expires: _parseDate(json['expires']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool _looksLikeCloudreveUri(String? value) {
|
||||||
|
if (value == null || value.trim().isEmpty) return false;
|
||||||
|
return value.trim().startsWith('cloudreve://');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _fallbackSourceUri({
|
||||||
|
required String? ownerId,
|
||||||
|
required String name,
|
||||||
|
}) {
|
||||||
|
// 不再用 owner@my 兜底。公开分享上下文不是当前账号的 my 文件系统,
|
||||||
|
// 用 owner@my 会导致 /file/url 返回 40081 或空 urls。
|
||||||
|
// 真正的兜底由 ShareLinkService.getShareInfo 根据分享 ID 构造
|
||||||
|
// cloudreve://<shareId>@share/ 完成。
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||||
|
if (value is Map<String, dynamic>) return value;
|
||||||
|
if (value is Map) return Map<String, dynamic>.from(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _asInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic value) {
|
||||||
|
final text = value?.toString();
|
||||||
|
if (text == null || text.isEmpty) return null;
|
||||||
|
return DateTime.tryParse(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShareLinkFile {
|
||||||
|
final int type;
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final int size;
|
||||||
|
final String path;
|
||||||
|
final DateTime? createdAt;
|
||||||
|
final DateTime? updatedAt;
|
||||||
|
final String? primaryEntity;
|
||||||
|
final String? capability;
|
||||||
|
final String? contextHint;
|
||||||
|
final Map<String, dynamic>? metadata;
|
||||||
|
|
||||||
|
const ShareLinkFile({
|
||||||
|
required this.type,
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.size,
|
||||||
|
required this.path,
|
||||||
|
this.createdAt,
|
||||||
|
this.updatedAt,
|
||||||
|
this.primaryEntity,
|
||||||
|
this.capability,
|
||||||
|
this.contextHint,
|
||||||
|
this.metadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isFolder => type == 1;
|
||||||
|
bool get isFile => !isFolder;
|
||||||
|
|
||||||
|
factory ShareLinkFile.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ShareLinkFile(
|
||||||
|
type: _asInt(json['type']),
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
name: json['name']?.toString() ?? '未命名文件',
|
||||||
|
size: _asInt(json['size']),
|
||||||
|
path: json['path']?.toString() ?? '',
|
||||||
|
createdAt: _parseDate(json['created_at']),
|
||||||
|
updatedAt: _parseDate(json['updated_at']),
|
||||||
|
primaryEntity: json['primary_entity']?.toString() ??
|
||||||
|
json['entity']?.toString(),
|
||||||
|
capability: json['capability']?.toString(),
|
||||||
|
contextHint: json['context_hint']?.toString(),
|
||||||
|
metadata: _asMap(json['metadata']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static int _asInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value) ?? 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
static DateTime? _parseDate(dynamic value) {
|
||||||
|
final text = value?.toString();
|
||||||
|
if (text == null || text.isEmpty) return null;
|
||||||
|
return DateTime.tryParse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||||
|
if (value is Map<String, dynamic>) return value;
|
||||||
|
if (value is Map) return Map<String, dynamic>.from(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShareLinkFileListResult {
|
||||||
|
final List<ShareLinkFile> files;
|
||||||
|
final ShareLinkFile? parent;
|
||||||
|
final String? contextHint;
|
||||||
|
final bool hasMore;
|
||||||
|
final String? nextPageToken;
|
||||||
|
|
||||||
|
const ShareLinkFileListResult({
|
||||||
|
required this.files,
|
||||||
|
this.parent,
|
||||||
|
this.contextHint,
|
||||||
|
this.hasMore = false,
|
||||||
|
this.nextPageToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ShareLinkFileListResult.fromJson(Map<String, dynamic> json) {
|
||||||
|
final rawFiles = json['files'];
|
||||||
|
final rawParent = json['parent'];
|
||||||
|
final pagination = ShareLinkService._asMap(json['pagination']);
|
||||||
|
|
||||||
|
return ShareLinkFileListResult(
|
||||||
|
files: rawFiles is List
|
||||||
|
? rawFiles
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((item) => ShareLinkFile.fromJson(Map<String, dynamic>.from(item)))
|
||||||
|
.toList()
|
||||||
|
: const [],
|
||||||
|
parent: rawParent is Map
|
||||||
|
? ShareLinkFile.fromJson(Map<String, dynamic>.from(rawParent))
|
||||||
|
: null,
|
||||||
|
contextHint: json['context_hint']?.toString(),
|
||||||
|
hasMore: pagination?['next_token'] != null,
|
||||||
|
nextPageToken: pagination?['next_token']?.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShareDownloadUrlResult {
|
||||||
|
final String url;
|
||||||
|
final DateTime? expires;
|
||||||
|
|
||||||
|
const ShareDownloadUrlResult({
|
||||||
|
required this.url,
|
||||||
|
this.expires,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ShareLinkService {
|
||||||
|
ShareLinkService._();
|
||||||
|
|
||||||
|
static final ShareLinkService instance = ShareLinkService._();
|
||||||
|
|
||||||
|
/// 兼容当前站点固定域名,也兼容用户自定义 Cloudreve 域名的 /s/{id}/{password?} 分享链接。
|
||||||
|
static final RegExp _shareLinkRegExp = RegExp(
|
||||||
|
r'https?://[^\s]+?/s/([A-Za-z0-9_-]+)(?:/([A-Za-z0-9_-]+))?',
|
||||||
|
caseSensitive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
ShareLinkCandidate? parseShareLink(String? text) {
|
||||||
|
if (text == null || text.trim().isEmpty) return null;
|
||||||
|
|
||||||
|
final match = _shareLinkRegExp.firstMatch(text.trim());
|
||||||
|
if (match == null) return null;
|
||||||
|
|
||||||
|
final id = match.group(1);
|
||||||
|
if (id == null || id.isEmpty) return null;
|
||||||
|
|
||||||
|
final password = match.group(2);
|
||||||
|
final url = match.group(0)!;
|
||||||
|
|
||||||
|
return ShareLinkCandidate(
|
||||||
|
id: id,
|
||||||
|
url: url,
|
||||||
|
password: password == null || password.isEmpty ? null : password,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ShareLinkInfo> getShareInfo(
|
||||||
|
ShareLinkCandidate candidate, {
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
final resolvedPassword = password ?? candidate.password;
|
||||||
|
final query = <String, dynamic>{
|
||||||
|
'count_views': true,
|
||||||
|
'owner_extended': true,
|
||||||
|
if (resolvedPassword != null && resolvedPassword.isNotEmpty)
|
||||||
|
'password': resolvedPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||||
|
'/share/info/${candidate.id}',
|
||||||
|
queryParameters: query,
|
||||||
|
options: Options(
|
||||||
|
extra: const {'noAuth': true},
|
||||||
|
headers: const {
|
||||||
|
'X-Cr-Context-Hint': 'share',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final body = response.data ?? <String, dynamic>{};
|
||||||
|
final data = _asMap(body['data']) ?? body;
|
||||||
|
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||||
|
final sourceType = ShareLinkInfo._asInt(data['source_type']);
|
||||||
|
|
||||||
|
// 公开分享页面访问文件时,必须使用 Cloudreve 官方 share 文件系统 URI,
|
||||||
|
// 即 cloudreve://{shareId}[:password]@share[/...]。
|
||||||
|
// /share/info 返回的 source_uri 往往是所有者 my 文件系统里的真实路径,
|
||||||
|
// 匿名/noAuth 场景拿它直接请求 /file/url 会被服务端按普通文件解析,
|
||||||
|
// 进而返回 40081 Entity not exist。
|
||||||
|
final accessUri = shareRootUri(
|
||||||
|
shareId: candidate.id,
|
||||||
|
password: resolvedPassword,
|
||||||
|
trailingSlash: sourceType != 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ShareLinkInfo.fromJson(<String, dynamic>{
|
||||||
|
...data,
|
||||||
|
'source_uri': accessUri,
|
||||||
|
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
|
||||||
|
headerContext != null &&
|
||||||
|
headerContext.isNotEmpty)
|
||||||
|
'context_hint': headerContext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
String? ownerAvatarUrl(ShareLinkInfo info) {
|
||||||
|
final ownerId = info.ownerId;
|
||||||
|
if (ownerId == null || ownerId.isEmpty) return null;
|
||||||
|
|
||||||
|
final avatar = info.ownerAvatar;
|
||||||
|
if (avatar != null && avatar.startsWith(RegExp(r'https?://'))) {
|
||||||
|
return avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
final base = ApiService.instance.dio.options.baseUrl.replaceFirst(RegExp(r'/+$'), '');
|
||||||
|
return '$base/user/avatar/${Uri.encodeComponent(ownerId)}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按分享的 source_uri 读取目录文件。
|
||||||
|
///
|
||||||
|
/// Cloudreve V4 的 /file 接口支持 JWT Optional,并通过 X-Cr-Context-Hint
|
||||||
|
/// 绑定分享上下文。首次读取时用 `share`,服务端返回 context_hint 后,后续下载
|
||||||
|
/// 和进入子目录都继续携带该 context_hint。
|
||||||
|
Future<ShareLinkFileListResult> listSharedFiles({
|
||||||
|
required String uri,
|
||||||
|
String? contextHint,
|
||||||
|
int page = 0,
|
||||||
|
int pageSize = 100,
|
||||||
|
String? nextPageToken,
|
||||||
|
}) async {
|
||||||
|
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||||
|
'/file',
|
||||||
|
queryParameters: <String, dynamic>{
|
||||||
|
'uri': uri,
|
||||||
|
'page': page,
|
||||||
|
'page_size': pageSize,
|
||||||
|
if (nextPageToken != null && nextPageToken.isNotEmpty)
|
||||||
|
'next_page_token': nextPageToken,
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
extra: const {'noAuth': true},
|
||||||
|
headers: <String, dynamic>{
|
||||||
|
'X-Cr-Context-Hint': _firstContextHint(contextHint),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final body = response.data ?? <String, dynamic>{};
|
||||||
|
final data = _asMap(body['data']) ?? body;
|
||||||
|
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||||
|
return ShareLinkFileListResult.fromJson(<String, dynamic>{
|
||||||
|
...data,
|
||||||
|
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
|
||||||
|
headerContext != null &&
|
||||||
|
headerContext.isNotEmpty)
|
||||||
|
'context_hint': headerContext,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 读取分享上下文中文件详情,主要用于拿到 primary_entity。
|
||||||
|
Future<ShareLinkFile> getSharedFileInfo({
|
||||||
|
required String uri,
|
||||||
|
String? contextHint,
|
||||||
|
String? shareId,
|
||||||
|
String? password,
|
||||||
|
}) async {
|
||||||
|
Object? lastError;
|
||||||
|
|
||||||
|
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||||
|
try {
|
||||||
|
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||||
|
'/file/info',
|
||||||
|
queryParameters: <String, dynamic>{
|
||||||
|
'uri': uri,
|
||||||
|
'extended': true,
|
||||||
|
},
|
||||||
|
options: Options(
|
||||||
|
extra: const {'noAuth': true},
|
||||||
|
headers: <String, dynamic>{
|
||||||
|
'X-Cr-Context-Hint': hint,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final body = response.data ?? <String, dynamic>{};
|
||||||
|
final data = _asMap(body['data']) ?? body;
|
||||||
|
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||||
|
return ShareLinkFile.fromJson(<String, dynamic>{
|
||||||
|
...data,
|
||||||
|
'context_hint': data['context_hint'] ?? headerContext ?? hint,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? Exception('文件详情读取失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
ShareLinkFile fileFromShareInfo(ShareLinkInfo info) {
|
||||||
|
final sourceUri = info.sourceUri ??
|
||||||
|
ShareLinkInfo._fallbackSourceUri(
|
||||||
|
ownerId: info.ownerId,
|
||||||
|
name: info.name,
|
||||||
|
) ??
|
||||||
|
'';
|
||||||
|
return ShareLinkFile(
|
||||||
|
type: info.sourceType,
|
||||||
|
id: info.id,
|
||||||
|
name: info.name,
|
||||||
|
size: info.size,
|
||||||
|
path: sourceUri,
|
||||||
|
createdAt: info.createdAt,
|
||||||
|
updatedAt: info.createdAt,
|
||||||
|
contextHint: info.contextHint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取分享上下文中的下载 URL。
|
||||||
|
///
|
||||||
|
/// 按 Cloudreve V4 官方 `/file/url` 方式创建临时下载链接:
|
||||||
|
/// body 只提交 `uris`,文件夹批量下载时提交 `archive: true`;
|
||||||
|
/// 分享访问走 JWT Optional,因此这里使用 noAuth,避免当前登录用户的
|
||||||
|
/// Authorization 影响分享上下文。
|
||||||
|
Future<ShareDownloadUrlResult> createShareDownloadUrl({
|
||||||
|
required String uri,
|
||||||
|
String? contextHint,
|
||||||
|
String? shareId,
|
||||||
|
String? password,
|
||||||
|
String? entity,
|
||||||
|
String? fileName,
|
||||||
|
bool archive = false,
|
||||||
|
}) async {
|
||||||
|
Object? lastError;
|
||||||
|
|
||||||
|
// Cloudreve V4 官方接口:POST /file/url
|
||||||
|
// Header: X-Cr-Context-Hint
|
||||||
|
// Body: { "uris": [...], "archive": true? }
|
||||||
|
//
|
||||||
|
// 分享下载必须不带当前账号 token;否则服务端可能按当前账号解析 URI,
|
||||||
|
// 出现 Entity not exist (40081)。
|
||||||
|
for (final candidateUri in _uriCandidates(
|
||||||
|
uri,
|
||||||
|
shareId: shareId,
|
||||||
|
password: password,
|
||||||
|
fileName: fileName,
|
||||||
|
)) {
|
||||||
|
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||||
|
try {
|
||||||
|
return await _createShareDownloadUrlOnce(
|
||||||
|
uri: candidateUri,
|
||||||
|
contextHint: hint,
|
||||||
|
archive: archive,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? Exception('服务端没有返回可用的下载链接');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ShareDownloadUrlResult> _createShareDownloadUrlOnce({
|
||||||
|
required String uri,
|
||||||
|
required String contextHint,
|
||||||
|
bool archive = false,
|
||||||
|
}) async {
|
||||||
|
final body = <String, dynamic>{
|
||||||
|
'uris': <String>[uri],
|
||||||
|
'download': true,
|
||||||
|
if (archive) 'archive': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
final response = await ApiService.instance.dio.post<Map<String, dynamic>>(
|
||||||
|
'/file/url',
|
||||||
|
data: body,
|
||||||
|
options: Options(
|
||||||
|
extra: const {'noAuth': true},
|
||||||
|
headers: <String, dynamic>{
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Cr-Context-Hint': contextHint,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final raw = response.data ?? <String, dynamic>{};
|
||||||
|
final data = _asMap(raw['data']) ?? raw;
|
||||||
|
final url = _extractDownloadUrl(data) ?? _extractDownloadUrl(raw);
|
||||||
|
if (url != null && url.isNotEmpty) {
|
||||||
|
return ShareDownloadUrlResult(
|
||||||
|
url: url,
|
||||||
|
expires: ShareLinkInfo._parseDate(data['expires'] ?? raw['expires']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw Exception('服务端没有返回可用的下载链接');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将分享文件转存到当前用户网盘。
|
||||||
|
///
|
||||||
|
/// Cloudreve V4 使用 /file/move,同一接口可移动/复制文件;传入 copy=true
|
||||||
|
/// 时即为转存/复制到目标目录。
|
||||||
|
Future<void> saveSharedFiles({
|
||||||
|
required List<String> uris,
|
||||||
|
required String destination,
|
||||||
|
String? contextHint,
|
||||||
|
String? shareId,
|
||||||
|
String? password,
|
||||||
|
String? fileName,
|
||||||
|
}) async {
|
||||||
|
final dst = FileUtils.toCloudreveUri(destination);
|
||||||
|
Object? lastError;
|
||||||
|
|
||||||
|
final uriCandidateSets = uris
|
||||||
|
.map((uri) => _uriCandidates(
|
||||||
|
uri,
|
||||||
|
shareId: shareId,
|
||||||
|
password: password,
|
||||||
|
// 单文件分享的 source_uri 是 cloudreve://{id}@share 根。
|
||||||
|
// /file/move 不允许复制 share 根目录,因此需要优先尝试
|
||||||
|
// cloudreve://{id}@share/{fileName},并过滤掉根 URI。
|
||||||
|
fileName: uris.length == 1 ? fileName : null,
|
||||||
|
includeRootFallback: false,
|
||||||
|
)
|
||||||
|
.where((candidate) => !isShareRootUri(candidate, shareId: shareId))
|
||||||
|
.toList())
|
||||||
|
.where((candidates) => candidates.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (uriCandidateSets.isEmpty) {
|
||||||
|
throw Exception('不能直接转存分享根目录,请选择具体文件或进入目录后再转存');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||||
|
for (final candidateUris in _cartesianUriCandidates(uriCandidateSets)) {
|
||||||
|
if (candidateUris.isEmpty) continue;
|
||||||
|
try {
|
||||||
|
await ApiService.instance.post<void>(
|
||||||
|
'/file/move',
|
||||||
|
data: <String, dynamic>{
|
||||||
|
'uris': candidateUris,
|
||||||
|
'dst': dst,
|
||||||
|
'copy': true,
|
||||||
|
},
|
||||||
|
headers: <String, dynamic>{
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Cr-Context-Hint': hint,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} catch (e) {
|
||||||
|
lastError = e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?? Exception('转存失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _uriCandidates(
|
||||||
|
String uri, {
|
||||||
|
String? shareId,
|
||||||
|
String? password,
|
||||||
|
String? fileName,
|
||||||
|
bool includeRootFallback = true,
|
||||||
|
}) {
|
||||||
|
final values = <String>[];
|
||||||
|
|
||||||
|
void add(String? value) {
|
||||||
|
final text = value?.trim();
|
||||||
|
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||||
|
values.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final shareUriCandidates = _shareScopedUriCandidates(
|
||||||
|
uri,
|
||||||
|
shareId: shareId,
|
||||||
|
password: password,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 官方页面进入的是 cloudreve://{shareId}@share 文件系统;
|
||||||
|
// 如果 /file 返回了 cloudreve://my/... 或 owner@my/...,
|
||||||
|
// 转存/下载时也要先折算成 share 文件系统里的相对路径。
|
||||||
|
for (final item in shareUriCandidates) {
|
||||||
|
add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = fileName?.trim();
|
||||||
|
final id = shareId?.trim();
|
||||||
|
if (id != null && id.isNotEmpty && name != null && name.isNotEmpty) {
|
||||||
|
final root = shareRootUri(
|
||||||
|
shareId: id,
|
||||||
|
password: password,
|
||||||
|
trailingSlash: false,
|
||||||
|
);
|
||||||
|
add('$root/${Uri.encodeComponent(name)}');
|
||||||
|
add('$root/${_encodeCloudrevePath(name)}');
|
||||||
|
}
|
||||||
|
|
||||||
|
add(_withSharePassword(uri, shareId: shareId, password: password));
|
||||||
|
add(uri);
|
||||||
|
|
||||||
|
if (uri.startsWith('cloudreve://')) {
|
||||||
|
final withPassword = _withSharePassword(uri, shareId: shareId, password: password);
|
||||||
|
if (withPassword != uri) add(withPassword);
|
||||||
|
|
||||||
|
final slash = uri.indexOf('/', 'cloudreve://'.length);
|
||||||
|
if (slash > 0) {
|
||||||
|
final prefix = uri.substring(0, slash + 1);
|
||||||
|
final path = slash < uri.length - 1 ? uri.substring(slash + 1) : '';
|
||||||
|
if (path.isNotEmpty) {
|
||||||
|
final encodedPath = _encodeCloudrevePath(path);
|
||||||
|
add(_withSharePassword('$prefix$encodedPath', shareId: shareId, password: password));
|
||||||
|
add('$prefix$encodedPath');
|
||||||
|
}
|
||||||
|
if (!uri.endsWith('/')) {
|
||||||
|
add(_withSharePassword('$uri/', shareId: shareId, password: password));
|
||||||
|
add('$uri/');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (includeRootFallback && shareId != null && shareId.trim().isNotEmpty) {
|
||||||
|
add(shareRootUri(shareId: shareId, password: password, trailingSlash: true));
|
||||||
|
add(shareRootUri(shareId: shareId, password: password, trailingSlash: false));
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _shareScopedUriCandidates(
|
||||||
|
String uri, {
|
||||||
|
String? shareId,
|
||||||
|
String? password,
|
||||||
|
}) {
|
||||||
|
final id = shareId?.trim();
|
||||||
|
if (id == null || id.isEmpty || !uri.startsWith('cloudreve://')) {
|
||||||
|
return const <String>[];
|
||||||
|
}
|
||||||
|
|
||||||
|
final values = <String>[];
|
||||||
|
void add(String? value) {
|
||||||
|
final text = value?.trim();
|
||||||
|
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||||
|
values.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final authorityStart = 'cloudreve://'.length;
|
||||||
|
final slash = uri.indexOf('/', authorityStart);
|
||||||
|
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
|
||||||
|
final rawPath = slash >= 0 && slash < uri.length - 1 ? uri.substring(slash + 1) : '';
|
||||||
|
final decodedPath = rawPath
|
||||||
|
.split('/')
|
||||||
|
.where((segment) => segment.isNotEmpty)
|
||||||
|
.map((segment) => Uri.decodeComponent(segment))
|
||||||
|
.join('/');
|
||||||
|
|
||||||
|
final isShareAuthority = authority.endsWith('@share');
|
||||||
|
if (isShareAuthority) {
|
||||||
|
add(_withSharePassword(uri, shareId: id, password: password));
|
||||||
|
add(uri);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (decodedPath.isEmpty) return values;
|
||||||
|
|
||||||
|
final pathParts = decodedPath.split('/').where((e) => e.isNotEmpty).toList();
|
||||||
|
final root = shareRootUri(shareId: id, password: password, trailingSlash: false);
|
||||||
|
|
||||||
|
// 完整路径候选:cloudreve://id@share/folder/file.ext
|
||||||
|
add('$root/${_encodeCloudrevePath(decodedPath)}');
|
||||||
|
|
||||||
|
// 单文件分享或列表返回 owner 的 my 路径时,官方 share 根目录下通常就是文件本身,
|
||||||
|
// 因此还要尝试 basename 候选:cloudreve://id@share/file.ext
|
||||||
|
if (pathParts.isNotEmpty) {
|
||||||
|
add('$root/${Uri.encodeComponent(pathParts.last)}');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果原 path 形如 Folder/Sub/File,分享根可能是 Folder,子项在 share 中应为 Sub/File。
|
||||||
|
if (pathParts.length > 1) {
|
||||||
|
final withoutFirst = pathParts.skip(1).join('/');
|
||||||
|
add('$root/${_encodeCloudrevePath(withoutFirst)}');
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _encodeCloudrevePath(String path) {
|
||||||
|
return path
|
||||||
|
.split('/')
|
||||||
|
.where((segment) => segment.isNotEmpty)
|
||||||
|
.map((segment) => Uri.encodeComponent(Uri.decodeComponent(segment)))
|
||||||
|
.join('/');
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _withSharePassword(String uri, {String? shareId, String? password}) {
|
||||||
|
final pw = password?.trim();
|
||||||
|
final id = shareId?.trim();
|
||||||
|
if (pw == null || pw.isEmpty || id == null || id.isEmpty) return uri;
|
||||||
|
if (!uri.startsWith('cloudreve://')) return uri;
|
||||||
|
|
||||||
|
final authorityStart = 'cloudreve://'.length;
|
||||||
|
final slash = uri.indexOf('/', authorityStart);
|
||||||
|
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
|
||||||
|
final rest = slash >= 0 ? uri.substring(slash) : '';
|
||||||
|
if (!authority.endsWith('@share')) return uri;
|
||||||
|
if (authority.contains(':')) return uri;
|
||||||
|
|
||||||
|
final encodedId = Uri.encodeComponent(id);
|
||||||
|
final encodedPw = Uri.encodeComponent(pw);
|
||||||
|
if (authority != '$encodedId@share' && authority != '$id@share') return uri;
|
||||||
|
return 'cloudreve://$encodedId:$encodedPw@share$rest';
|
||||||
|
}
|
||||||
|
|
||||||
|
static String shareRootUri({
|
||||||
|
required String shareId,
|
||||||
|
String? password,
|
||||||
|
bool trailingSlash = true,
|
||||||
|
}) {
|
||||||
|
final id = Uri.encodeComponent(shareId.trim());
|
||||||
|
final pw = password?.trim();
|
||||||
|
final userInfo = pw == null || pw.isEmpty
|
||||||
|
? id
|
||||||
|
: '$id:${Uri.encodeComponent(pw)}';
|
||||||
|
return 'cloudreve://$userInfo@share${trailingSlash ? '/' : ''}';
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool isShareRootUri(String uri, {String? shareId}) {
|
||||||
|
final text = uri.trim();
|
||||||
|
const prefix = 'cloudreve://';
|
||||||
|
if (!text.startsWith(prefix)) return false;
|
||||||
|
|
||||||
|
final authorityStart = prefix.length;
|
||||||
|
final slash = text.indexOf('/', authorityStart);
|
||||||
|
final authority = slash >= 0
|
||||||
|
? text.substring(authorityStart, slash)
|
||||||
|
: text.substring(authorityStart);
|
||||||
|
if (!authority.endsWith('@share')) return false;
|
||||||
|
|
||||||
|
final path = slash >= 0 ? text.substring(slash) : '';
|
||||||
|
if (path.isNotEmpty && path != '/') return false;
|
||||||
|
|
||||||
|
final expectedId = shareId?.trim();
|
||||||
|
if (expectedId == null || expectedId.isEmpty) return true;
|
||||||
|
|
||||||
|
final userInfo = authority.substring(0, authority.length - '@share'.length);
|
||||||
|
final rawId = userInfo.split(':').first;
|
||||||
|
return Uri.decodeComponent(rawId) == expectedId || rawId == expectedId;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static String? _extractDownloadUrl(Map<String, dynamic> data) {
|
||||||
|
final direct = data['url'] ??
|
||||||
|
data['download_url'] ??
|
||||||
|
data['downloadUrl'] ??
|
||||||
|
data['src'] ??
|
||||||
|
data['href'];
|
||||||
|
if (direct is String && direct.trim().isNotEmpty) return direct.trim();
|
||||||
|
|
||||||
|
final rawUrls = data['urls'];
|
||||||
|
if (rawUrls is String && rawUrls.trim().isNotEmpty) return rawUrls.trim();
|
||||||
|
if (rawUrls is List) {
|
||||||
|
for (final item in rawUrls) {
|
||||||
|
if (item is String && item.trim().isNotEmpty) return item.trim();
|
||||||
|
final map = _asMap(item);
|
||||||
|
if (map == null) continue;
|
||||||
|
final url = _extractDownloadUrl(map);
|
||||||
|
if (url != null && url.isNotEmpty) return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rawUrls is Map) {
|
||||||
|
for (final item in rawUrls.values) {
|
||||||
|
if (item is String && item.trim().isNotEmpty) return item.trim();
|
||||||
|
final map = _asMap(item);
|
||||||
|
if (map == null) continue;
|
||||||
|
final url = _extractDownloadUrl(map);
|
||||||
|
if (url != null && url.isNotEmpty) return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<List<String>> _cartesianUriCandidates(List<List<String>> sets) {
|
||||||
|
if (sets.isEmpty) return const [[]];
|
||||||
|
|
||||||
|
var result = <List<String>>[const []];
|
||||||
|
for (final set in sets) {
|
||||||
|
final next = <List<String>>[];
|
||||||
|
for (final prefix in result) {
|
||||||
|
for (final item in set) {
|
||||||
|
next.add(<String>[...prefix, item]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = next;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String _firstContextHint(String? contextHint) {
|
||||||
|
final value = contextHint?.trim();
|
||||||
|
return value == null || value.isEmpty ? 'share' : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static List<String> _contextHintCandidates(String? contextHint, {String? shareId}) {
|
||||||
|
final values = <String>[];
|
||||||
|
void add(String? value) {
|
||||||
|
final text = value?.trim();
|
||||||
|
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||||
|
values.add(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
add(contextHint);
|
||||||
|
add(shareId);
|
||||||
|
if (shareId != null && shareId.trim().isNotEmpty) {
|
||||||
|
add('share:${shareId.trim()}');
|
||||||
|
add('share_${shareId.trim()}');
|
||||||
|
}
|
||||||
|
add('share');
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
static String? _headerValue(Headers headers, String name) {
|
||||||
|
final direct = headers.value(name) ?? headers.value(name.toLowerCase());
|
||||||
|
if (direct != null && direct.isNotEmpty) return direct;
|
||||||
|
|
||||||
|
final lower = name.toLowerCase();
|
||||||
|
for (final entry in headers.map.entries) {
|
||||||
|
if (entry.key.toLowerCase() == lower && entry.value.isNotEmpty) {
|
||||||
|
return entry.value.first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||||
|
if (value is Map<String, dynamic>) return value;
|
||||||
|
if (value is Map) return Map<String, dynamic>.from(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+139
-22
@@ -1,7 +1,54 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'api_service.dart';
|
import 'api_service.dart';
|
||||||
|
import '../core/exceptions/app_exception.dart';
|
||||||
import '../data/models/share_model.dart';
|
import '../data/models/share_model.dart';
|
||||||
import '../core/utils/app_logger.dart';
|
import '../core/utils/app_logger.dart';
|
||||||
|
|
||||||
|
/// 分享授权对象类型。
|
||||||
|
enum SharePrincipalType { user, group }
|
||||||
|
|
||||||
|
/// 分享弹窗中搜索并加入的用户/用户组。
|
||||||
|
class SharePrincipal {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final String? email;
|
||||||
|
final String? groupName;
|
||||||
|
final SharePrincipalType type;
|
||||||
|
|
||||||
|
const SharePrincipal({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
this.email,
|
||||||
|
this.groupName,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory SharePrincipal.userFromJson(Map<String, dynamic> json) {
|
||||||
|
final group = _asMap(json['group']);
|
||||||
|
return SharePrincipal(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
name: (json['nickname'] ?? json['email'] ?? json['id'] ?? '用户').toString(),
|
||||||
|
email: json['email']?.toString(),
|
||||||
|
groupName: group?['name']?.toString(),
|
||||||
|
type: SharePrincipalType.user,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory SharePrincipal.groupFromJson(Map<String, dynamic> json) {
|
||||||
|
return SharePrincipal(
|
||||||
|
id: json['id']?.toString() ?? '',
|
||||||
|
name: (json['name'] ?? json['id'] ?? '用户组').toString(),
|
||||||
|
type: SharePrincipalType.group,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||||
|
if (value is Map<String, dynamic>) return value;
|
||||||
|
if (value is Map) return Map<String, dynamic>.from(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 分享服务
|
/// 分享服务
|
||||||
class ShareService {
|
class ShareService {
|
||||||
/// 将文件系统路径转换为 cloudreve URI 格式
|
/// 将文件系统路径转换为 cloudreve URI 格式
|
||||||
@@ -17,33 +64,98 @@ class ShareService {
|
|||||||
return 'cloudreve://my/$cleanPath';
|
return 'cloudreve://my/$cleanPath';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 创建分享链接
|
/// 创建分享链接。
|
||||||
|
///
|
||||||
|
/// Cloudreve V4 接口为 PUT /share,核心字段包括 permissions、uri、
|
||||||
|
/// is_private、share_view、expire、price、password、show_readme。
|
||||||
Future<String> createShare({
|
Future<String> createShare({
|
||||||
required String uri,
|
required String uri,
|
||||||
|
Map<String, dynamic>? permissions,
|
||||||
bool? isPrivate,
|
bool? isPrivate,
|
||||||
bool? shareView,
|
bool? shareView,
|
||||||
int? expire,
|
int? expire,
|
||||||
|
int? downloads,
|
||||||
int? price,
|
int? price,
|
||||||
String? password,
|
String? password,
|
||||||
bool? showReadme,
|
bool? showReadme,
|
||||||
}) async {
|
}) async {
|
||||||
final data = <String, dynamic>{
|
final data = <String, dynamic>{
|
||||||
'permissions': {'anonymous': 'BQ==', 'everyone': 'AQ=='},
|
'permissions': permissions ?? {'anonymous': 'BQ==', 'everyone': 'AQ=='},
|
||||||
'uri': _toCloudreveUri(uri),
|
'uri': _toCloudreveUri(uri),
|
||||||
'is_private': ?isPrivate,
|
|
||||||
'share_view': ?shareView,
|
|
||||||
'expire': ?expire,
|
|
||||||
'price': ?price,
|
|
||||||
'password': ?password,
|
|
||||||
'show_readme': ?showReadme,
|
|
||||||
};
|
};
|
||||||
// 当请求的接口为创建分享时, 逻辑上不适合走到 _parseResponse -> ApiResponse.fromJson 直接返回结果即可
|
|
||||||
|
if (isPrivate != null) data['is_private'] = isPrivate;
|
||||||
|
if (shareView != null) data['share_view'] = shareView;
|
||||||
|
if (expire != null) data['expire'] = expire;
|
||||||
|
if (downloads != null) data['downloads'] = downloads;
|
||||||
|
if (price != null) data['price'] = price;
|
||||||
|
if (password != null && password.isNotEmpty) data['password'] = password;
|
||||||
|
if (showReadme != null) data['show_readme'] = showReadme;
|
||||||
|
|
||||||
final response = await ApiService.instance.put<Map<String, dynamic>>(
|
final response = await ApiService.instance.put<Map<String, dynamic>>(
|
||||||
'/share',
|
'/share',
|
||||||
data: data,
|
data: data,
|
||||||
isNoData: true,
|
isNoData: true,
|
||||||
);
|
);
|
||||||
return response['data'] as String;
|
final raw = response['data'];
|
||||||
|
return raw?.toString() ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 搜索用户,用于分享权限显式授权。
|
||||||
|
Future<List<SharePrincipal>> searchUsers(String keyword) async {
|
||||||
|
final trimmed = keyword.trim();
|
||||||
|
if (trimmed.isEmpty) return const [];
|
||||||
|
|
||||||
|
final response = await ApiService.instance.get<dynamic>(
|
||||||
|
'/user/search',
|
||||||
|
queryParameters: {'keyword': trimmed},
|
||||||
|
);
|
||||||
|
|
||||||
|
return _extractList(response)
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => SharePrincipal.userFromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.where((e) => e.id.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 列出用户组,用于分享权限显式授权。
|
||||||
|
///
|
||||||
|
/// Cloudreve Pro 才支持 /group/list,非 Pro 或无权限时返回 null。
|
||||||
|
/// 调用方据此决定是否提示用户。
|
||||||
|
Future<List<SharePrincipal>?> listGroups() async {
|
||||||
|
try {
|
||||||
|
final response = await ApiService.instance.get<dynamic>(
|
||||||
|
'/group/list',
|
||||||
|
silent404: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
return _extractList(response)
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => SharePrincipal.groupFromJson(Map<String, dynamic>.from(e)))
|
||||||
|
.where((e) => e.id.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
} on DioException catch (e) {
|
||||||
|
if (e.response?.statusCode == 404) return null;
|
||||||
|
rethrow;
|
||||||
|
} on AppException catch (e) {
|
||||||
|
if (e.code == 404) return null;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<dynamic> _extractList(dynamic value) {
|
||||||
|
if (value is List) return value;
|
||||||
|
if (value is Map) {
|
||||||
|
final data = value['data'];
|
||||||
|
if (data is List) return data;
|
||||||
|
final items = value['items'];
|
||||||
|
if (items is List) return items;
|
||||||
|
final groups = value['groups'];
|
||||||
|
if (groups is List) return groups;
|
||||||
|
final users = value['users'];
|
||||||
|
if (users is List) return users;
|
||||||
|
}
|
||||||
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 获取我的分享列表
|
/// 获取我的分享列表
|
||||||
@@ -55,11 +167,11 @@ class ShareService {
|
|||||||
}) async {
|
}) async {
|
||||||
final queryParams = <String, dynamic>{
|
final queryParams = <String, dynamic>{
|
||||||
'page_size': pageSize,
|
'page_size': pageSize,
|
||||||
'order_by': ?orderBy,
|
|
||||||
'order_direction': ?orderDirection,
|
|
||||||
'next_page_token': ?nextPageToken,
|
|
||||||
};
|
};
|
||||||
// 请求方法为get, claude 写成post, fixed
|
if (orderBy != null) queryParams['order_by'] = orderBy;
|
||||||
|
if (orderDirection != null) queryParams['order_direction'] = orderDirection;
|
||||||
|
if (nextPageToken != null) queryParams['next_page_token'] = nextPageToken;
|
||||||
|
|
||||||
return await ApiService.instance.get<Map<String, dynamic>>(
|
return await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
'/share',
|
'/share',
|
||||||
queryParameters: queryParams,
|
queryParameters: queryParams,
|
||||||
@@ -79,13 +191,10 @@ class ShareService {
|
|||||||
if (ownerExtended != null) {
|
if (ownerExtended != null) {
|
||||||
queryParams['owner_extended'] = ownerExtended.toString();
|
queryParams['owner_extended'] = ownerExtended.toString();
|
||||||
}
|
}
|
||||||
// 获取分享详情是 GET 请求
|
|
||||||
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||||
'/share/info/$id',
|
'/share/info/$id',
|
||||||
queryParameters: queryParams,
|
queryParameters: queryParams,
|
||||||
);
|
);
|
||||||
// 获取分享详情返回的 response 已经经过 _parseResponse -> ApiResponse.fromJson 处理, 不需要再通过 ['data'] 获取数据
|
|
||||||
// return ShareModel.fromJson(response['data'] as Map<String, dynamic>);
|
|
||||||
return ShareModel.fromJson(response);
|
return ShareModel.fromJson(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,19 +202,26 @@ class ShareService {
|
|||||||
Future<String> editShare({
|
Future<String> editShare({
|
||||||
required String id,
|
required String id,
|
||||||
required String uri,
|
required String uri,
|
||||||
|
Map<String, dynamic>? permissions,
|
||||||
bool? isPrivate,
|
bool? isPrivate,
|
||||||
String? password,
|
String? password,
|
||||||
bool? shareView,
|
bool? shareView,
|
||||||
int? downloads,
|
int? downloads,
|
||||||
int? expire,
|
int? expire,
|
||||||
|
int? price,
|
||||||
|
bool? showReadme,
|
||||||
}) async {
|
}) async {
|
||||||
final data = <String, dynamic>{
|
final data = <String, dynamic>{
|
||||||
'uri': uri,
|
'uri': uri,
|
||||||
'is_private': ?isPrivate,
|
|
||||||
'share_view': ?shareView,
|
|
||||||
'downloads': ?downloads,
|
|
||||||
'expire': ?expire,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (permissions != null) data['permissions'] = permissions;
|
||||||
|
if (isPrivate != null) data['is_private'] = isPrivate;
|
||||||
|
if (shareView != null) data['share_view'] = shareView;
|
||||||
|
if (downloads != null) data['downloads'] = downloads;
|
||||||
|
if (expire != null) data['expire'] = expire;
|
||||||
|
if (price != null) data['price'] = price;
|
||||||
|
if (showReadme != null) data['show_readme'] = showReadme;
|
||||||
if (password != null && password.isNotEmpty) {
|
if (password != null && password.isNotEmpty) {
|
||||||
data['password'] = password;
|
data['password'] = password;
|
||||||
}
|
}
|
||||||
@@ -116,7 +232,8 @@ class ShareService {
|
|||||||
data: data,
|
data: data,
|
||||||
isNoData: true,
|
isNoData: true,
|
||||||
);
|
);
|
||||||
return response['data'] as String;
|
final raw = response['data'];
|
||||||
|
return raw?.toString() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除分享
|
/// 删除分享
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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..."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <desktop_drop/desktop_drop_plugin.h>
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
#include <file_selector_linux/file_selector_plugin.h>
|
#include <file_selector_linux/file_selector_plugin.h>
|
||||||
#include <flutter_acrylic/flutter_acrylic_plugin.h>
|
#include <flutter_acrylic/flutter_acrylic_plugin.h>
|
||||||
|
#include <flutter_inappwebview_linux/flutter_inappwebview_linux_plugin.h>
|
||||||
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
#include <media_kit_libs_linux/media_kit_libs_linux_plugin.h>
|
||||||
#include <media_kit_video/media_kit_video_plugin.h>
|
#include <media_kit_video/media_kit_video_plugin.h>
|
||||||
#include <open_file_linux/open_file_linux_plugin.h>
|
#include <open_file_linux/open_file_linux_plugin.h>
|
||||||
@@ -27,6 +28,9 @@ void fl_register_plugins(FlPluginRegistry* registry) {
|
|||||||
g_autoptr(FlPluginRegistrar) flutter_acrylic_registrar =
|
g_autoptr(FlPluginRegistrar) flutter_acrylic_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin");
|
||||||
flutter_acrylic_plugin_register_with_registrar(flutter_acrylic_registrar);
|
flutter_acrylic_plugin_register_with_registrar(flutter_acrylic_registrar);
|
||||||
|
g_autoptr(FlPluginRegistrar) flutter_inappwebview_linux_registrar =
|
||||||
|
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterInappwebviewLinuxPlugin");
|
||||||
|
flutter_inappwebview_linux_plugin_register_with_registrar(flutter_inappwebview_linux_registrar);
|
||||||
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
|
g_autoptr(FlPluginRegistrar) media_kit_libs_linux_registrar =
|
||||||
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
|
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
|
||||||
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
|
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
desktop_drop
|
desktop_drop
|
||||||
file_selector_linux
|
file_selector_linux
|
||||||
flutter_acrylic
|
flutter_acrylic
|
||||||
|
flutter_inappwebview_linux
|
||||||
media_kit_libs_linux
|
media_kit_libs_linux
|
||||||
media_kit_video
|
media_kit_video
|
||||||
open_file_linux
|
open_file_linux
|
||||||
|
|||||||
Generated
+65
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
// 远程存储策略:直接上传到外部 URL(OneDrive/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,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、Full 和 MirrorWcf)
|
// 本地有,远程无 → 上传(UploadOnly、Full、MirrorWcf、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、Full 和 MirrorWcf)
|
// 远程有,本地无 → 下载(DownloadOnly、Full、MirrorWcf、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、Full 和 MirrorWcf)
|
// 远程目录结构(UploadOnly、Full、MirrorWcf、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(),
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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())
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 + hydration(create_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)?;
|
||||||
|
|||||||
@@ -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、Full 和 MirrorWcf 订阅 SSE
|
// 仅 DownloadOnly、Full、MirrorWcf、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、Full 和 MirrorWcf 启动本地文件监听
|
// 仅 UploadOnly、Full、MirrorWcf、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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user