Compare commits
16 Commits
17673b2862
...
v1.3.5
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b94f40def | |||
| edcde96051 | |||
|
5ee6ba1e28
|
|||
|
61ad85f6fc
|
|||
|
8dfc22691a
|
|||
| c583c51d80 | |||
|
4d6ca139e5
|
|||
|
81864c99c2
|
|||
|
fb5fd6c9bc
|
|||
|
c208a06af5
|
|||
|
c2db8b38e9
|
|||
|
24d20cbfcb
|
|||
|
12f2c2660e
|
|||
|
98af110531
|
|||
| 886046a72b | |||
| d20f7790e3 |
@@ -191,7 +191,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 +199,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 +249,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 +265,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 +353,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
|
||||||
@@ -67,6 +68,7 @@ native/logs
|
|||||||
sync_refactory.md
|
sync_refactory.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
|
||||||
|
|||||||
@@ -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,25 @@ 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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
@@ -76,7 +96,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
|
||||||
|
|||||||
@@ -35,4 +35,12 @@ class StorageKeys {
|
|||||||
|
|
||||||
// 日志级别
|
// 日志级别
|
||||||
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';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
@@ -34,22 +94,36 @@ class CaptchaWebConfig {
|
|||||||
String? assetServer,
|
String? assetServer,
|
||||||
String displayName = 'Cap',
|
String displayName = 'Cap',
|
||||||
}) : this._(
|
}) : this._(
|
||||||
type: 'cap',
|
type: 'cap',
|
||||||
displayName: displayName,
|
displayName: displayName,
|
||||||
instanceUrl: instanceUrl,
|
instanceUrl: instanceUrl,
|
||||||
siteKey: siteKey,
|
siteKey: siteKey,
|
||||||
assetServer: assetServer,
|
assetServer: assetServer,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 桌面端判断 ───────────────────────────────────────
|
||||||
|
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,61 +131,98 @@ 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) {
|
||||||
..setBackgroundColor(Colors.transparent)
|
_applyWebView2Proxy(widget.proxyConfig);
|
||||||
..addJavaScriptChannel(
|
_proxyEnvSet = true;
|
||||||
'CaptchaBridge',
|
}
|
||||||
onMessageReceived: (message) {
|
|
||||||
_handleBridgeMessage(message.message);
|
|
||||||
},
|
|
||||||
)
|
|
||||||
..setNavigationDelegate(
|
|
||||||
NavigationDelegate(
|
|
||||||
onProgress: (progress) {
|
|
||||||
if (mounted) setState(() => _progress = progress);
|
|
||||||
},
|
|
||||||
onPageFinished: (_) {
|
|
||||||
if (mounted) setState(() => _isLoading = false);
|
|
||||||
},
|
|
||||||
onWebResourceError: (error) {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() {
|
|
||||||
_isLoading = false;
|
|
||||||
_errorMessage = '${error.errorCode}: ${error.description}'
|
|
||||||
.trim();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
_loadCaptcha();
|
if (!_isDesktop) {
|
||||||
|
_mobileController = mobile.WebViewController()
|
||||||
|
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
|
||||||
|
..setBackgroundColor(Colors.transparent)
|
||||||
|
..addJavaScriptChannel(
|
||||||
|
'CaptchaBridge',
|
||||||
|
onMessageReceived: (message) {
|
||||||
|
_handleBridgeMessage(message.message);
|
||||||
|
},
|
||||||
|
)
|
||||||
|
..setNavigationDelegate(
|
||||||
|
mobile.NavigationDelegate(
|
||||||
|
onProgress: (progress) {
|
||||||
|
if (mounted) setState(() => _progress = progress);
|
||||||
|
},
|
||||||
|
onPageFinished: (_) {
|
||||||
|
if (mounted) setState(() => _isLoading = false);
|
||||||
|
},
|
||||||
|
onWebResourceError: (error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
_errorMessage =
|
||||||
|
'${error.errorCode}: ${error.description}'.trim();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
..loadHtmlString(_currentHtml, baseUrl: widget.baseUrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── 加载 / 刷新 ────────────────────────────────────
|
||||||
|
|
||||||
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,51 +555,173 @@ 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: Text(
|
||||||
child: SafeArea(
|
_errorMessage!,
|
||||||
child: Container(
|
textAlign: TextAlign.center,
|
||||||
width: double.infinity,
|
style: TextStyle(
|
||||||
margin: const EdgeInsets.all(12),
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).colorScheme.errorContainer,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
_errorMessage!,
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: TextStyle(
|
|
||||||
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,17 @@
|
|||||||
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/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 '../../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';
|
||||||
|
|
||||||
@@ -46,23 +52,34 @@ class CategoryFilesPageArgs {
|
|||||||
class CategoryFilesPage extends StatefulWidget {
|
class CategoryFilesPage extends StatefulWidget {
|
||||||
final CategoryFilesPageArgs args;
|
final CategoryFilesPageArgs args;
|
||||||
|
|
||||||
const CategoryFilesPage({super.key, required this.args});
|
const CategoryFilesPage({
|
||||||
|
super.key,
|
||||||
|
required this.args,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
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;
|
||||||
|
|
||||||
|
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();
|
||||||
@@ -74,6 +91,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 +120,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
_nextPageToken = null;
|
_nextPageToken = null;
|
||||||
_contextHint = null;
|
_contextHint = null;
|
||||||
_files.clear();
|
_files.clear();
|
||||||
|
_selectedFilePaths.clear();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -118,8 +137,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
final filesData = response['files'] as List<dynamic>? ?? const [];
|
final filesData = response['files'] as List<dynamic>? ?? const [];
|
||||||
final pagination =
|
final pagination = response['pagination'] as Map<String, dynamic>? ?? const {};
|
||||||
response['pagination'] as Map<String, dynamic>? ?? const {};
|
|
||||||
final newFiles = filesData
|
final newFiles = filesData
|
||||||
.map((item) => FileModel.fromJson(item as Map<String, dynamic>))
|
.map((item) => FileModel.fromJson(item as Map<String, dynamic>))
|
||||||
.where((file) => file.isFile)
|
.where((file) => file.isFile)
|
||||||
@@ -134,9 +152,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
..addAll(newFiles);
|
..addAll(newFiles);
|
||||||
} else {
|
} else {
|
||||||
final existingIds = _files.map((e) => e.id).toSet();
|
final existingIds = _files.map((e) => e.id).toSet();
|
||||||
_files.addAll(
|
_files.addAll(newFiles.where((file) => !existingIds.contains(file.id)));
|
||||||
newFiles.where((file) => !existingIds.contains(file.id)),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_nextPageToken = pagination['next_token'] as String?;
|
_nextPageToken = pagination['next_token'] as String?;
|
||||||
@@ -156,22 +172,149 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
|
|
||||||
Future<void> _refresh() => _loadFiles(refresh: true);
|
Future<void> _refresh() => _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(
|
||||||
title: Text(args.title),
|
automaticallyImplyLeading: false,
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(LucideIcons.x),
|
||||||
|
tooltip: '取消选择',
|
||||||
|
onPressed: _clearSelection,
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
title: Text('已选中 ${_selectedFilePaths.length} 个文件'),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
TextButton(
|
||||||
icon: const Icon(LucideIcons.refreshCw),
|
onPressed: _selectAllVisible,
|
||||||
tooltip: '刷新',
|
child: const Text('全选'),
|
||||||
onPressed: _refresh,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return AppBar(
|
||||||
|
title: Text(args.title),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(LucideIcons.refreshCw),
|
||||||
|
tooltip: '刷新',
|
||||||
|
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)),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,7 +359,11 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
children: [
|
children: [
|
||||||
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
|
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
|
||||||
Icon(widget.args.icon, size: 52, color: widget.args.color),
|
Icon(
|
||||||
|
widget.args.icon,
|
||||||
|
size: 52,
|
||||||
|
color: widget.args.color,
|
||||||
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Center(
|
Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -236,7 +383,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
final horizontalPadding = width >= 720 ? 16.0 : 10.0;
|
final horizontalPadding = width >= 720 ? 16.0 : 10.0;
|
||||||
final columnWidth =
|
final columnWidth =
|
||||||
(width - horizontalPadding * 2 - spacing * (columnCount - 1)) /
|
(width - horizontalPadding * 2 - spacing * (columnCount - 1)) /
|
||||||
columnCount;
|
columnCount;
|
||||||
|
|
||||||
final columns = List.generate(columnCount, (_) => <FileModel>[]);
|
final columns = List.generate(columnCount, (_) => <FileModel>[]);
|
||||||
final heights = List.generate(columnCount, (_) => 0.0);
|
final heights = List.generate(columnCount, (_) => 0.0);
|
||||||
@@ -244,8 +391,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
for (final file in _files) {
|
for (final file in _files) {
|
||||||
final targetIndex = _indexOfMin(heights);
|
final targetIndex = _indexOfMin(heights);
|
||||||
columns[targetIndex].add(file);
|
columns[targetIndex].add(file);
|
||||||
heights[targetIndex] +=
|
heights[targetIndex] += _estimatedTileHeight(file, columnWidth) + spacing;
|
||||||
_estimatedTileHeight(file, columnWidth) + spacing;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
@@ -255,7 +401,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
horizontalPadding,
|
horizontalPadding,
|
||||||
10,
|
10,
|
||||||
horizontalPadding,
|
horizontalPadding,
|
||||||
24,
|
_hasSelection ? 92 : 24,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
_buildSummaryHeader(context),
|
_buildSummaryHeader(context),
|
||||||
@@ -271,11 +417,22 @@ 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),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -379,19 +536,147 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
|||||||
} else if (FileTypeUtils.isAudio(file.name)) {
|
} else if (FileTypeUtils.isAudio(file.name)) {
|
||||||
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
|
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
|
||||||
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
||||||
Navigator.of(
|
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
|
||||||
context,
|
|
||||||
).pushNamed(RouteNames.markdownPreview, arguments: file);
|
|
||||||
} else if (FileTypeUtils.isTextCode(file.name)) {
|
} else if (FileTypeUtils.isTextCode(file.name)) {
|
||||||
Navigator.of(
|
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
|
||||||
context,
|
|
||||||
).pushNamed(RouteNames.documentPreview, arguments: file);
|
|
||||||
} else {
|
} else {
|
||||||
ToastHelper.info(
|
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
|
||||||
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 +684,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,73 +713,128 @@ 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
|
||||||
color: theme.colorScheme.surfaceContainerLow,
|
? theme.colorScheme.primary
|
||||||
borderRadius: BorderRadius.circular(14),
|
: theme.dividerColor.withValues(alpha: 0.12);
|
||||||
clipBehavior: Clip.antiAlias,
|
|
||||||
child: InkWell(
|
final showSelectionCircle = selectionMode || isSelected;
|
||||||
onTap: onTap,
|
|
||||||
child: DecoratedBox(
|
return RepaintBoundary(
|
||||||
decoration: BoxDecoration(
|
child: AnimatedScale(
|
||||||
border: Border.all(
|
duration: const Duration(milliseconds: 150),
|
||||||
color: theme.dividerColor.withValues(alpha: 0.12),
|
curve: Curves.easeOutCubic,
|
||||||
|
scale: isSelected ? 0.985 : 1.0,
|
||||||
|
child: Material(
|
||||||
|
color: theme.colorScheme.surfaceContainerLow,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: isMedia || isPsd ? 1 : 1.45,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
RepaintBoundary(
|
||||||
|
child: ThumbnailImage(
|
||||||
|
file: file,
|
||||||
|
contextHint: contextHint,
|
||||||
|
borderRadius: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 7,
|
||||||
|
left: 7,
|
||||||
|
child: _TypeBadge(
|
||||||
|
icon: _badgeIcon(),
|
||||||
|
label: _badgeLabel(ext),
|
||||||
|
color: accentColor,
|
||||||
|
compact: isMedia,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (category == 'video')
|
||||||
|
const Center(
|
||||||
|
child: _PlayOverlay(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
file.name,
|
||||||
|
maxLines: isAudio || isDocument ? 2 : 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: theme.hintColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
|
||||||
AspectRatio(
|
|
||||||
aspectRatio: isMedia || isPsd ? 1 : 1.45,
|
|
||||||
child: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
ThumbnailImage(
|
|
||||||
file: file,
|
|
||||||
contextHint: contextHint,
|
|
||||||
borderRadius: 0,
|
|
||||||
),
|
|
||||||
Positioned(
|
|
||||||
top: 7,
|
|
||||||
left: 7,
|
|
||||||
child: _TypeBadge(
|
|
||||||
icon: _badgeIcon(),
|
|
||||||
label: _badgeLabel(ext),
|
|
||||||
color: accentColor,
|
|
||||||
compact: isMedia,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (category == 'video')
|
|
||||||
const Center(child: _PlayOverlay()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
file.name,
|
|
||||||
maxLines: isAudio || isDocument ? 2 : 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.bodyMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
|
||||||
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
style: theme.textTheme.bodySmall?.copyWith(
|
|
||||||
color: theme.hintColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -524,6 +873,60 @@ 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;
|
||||||
@@ -545,7 +948,10 @@ class _TypeBadge extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(9),
|
borderRadius: BorderRadius.circular(9),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: compact ? 6 : 7,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -580,7 +986,11 @@ class _PlayOverlay extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.all(10),
|
padding: EdgeInsets.all(10),
|
||||||
child: Icon(LucideIcons.play, color: Colors.white, size: 22),
|
child: Icon(
|
||||||
|
LucideIcons.play,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 22,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,10 +56,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
|
|
||||||
Future.delayed(const Duration(milliseconds: 100), () {
|
Future.delayed(const Duration(milliseconds: 100), () {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
final fileManager = Provider.of<FileManagerProvider>(
|
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||||
context,
|
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
final screenWidth = MediaQuery.of(context).size.width;
|
||||||
if (screenWidth >= 1000) {
|
if (screenWidth >= 1000) {
|
||||||
fileManager.setViewType(FileViewType.grid);
|
fileManager.setViewType(FileViewType.grid);
|
||||||
@@ -70,10 +67,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
fileManager.loadFiles();
|
fileManager.loadFiles();
|
||||||
_isFirstLoad = false;
|
_isFirstLoad = false;
|
||||||
}
|
}
|
||||||
final downloadManager = Provider.of<DownloadManagerProvider>(
|
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
|
||||||
context,
|
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
downloadManager.initialize();
|
downloadManager.initialize();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -81,13 +75,8 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
// 上传完成 → 自动刷新当前目录文件列表
|
// 上传完成 → 自动刷新当前目录文件列表
|
||||||
UploadService.instance.onUploadCompleted = (targetPath, fileName) {
|
UploadService.instance.onUploadCompleted = (targetPath, fileName) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final fileManager = Provider.of<FileManagerProvider>(
|
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||||
context,
|
final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath);
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
final normalizedCurrent = FileUtils.toCloudreveUri(
|
|
||||||
fileManager.currentPath,
|
|
||||||
);
|
|
||||||
if (targetPath == normalizedCurrent) {
|
if (targetPath == normalizedCurrent) {
|
||||||
final fileUri = targetPath.endsWith('/')
|
final fileUri = targetPath.endsWith('/')
|
||||||
? '$targetPath$fileName'
|
? '$targetPath$fileName'
|
||||||
@@ -112,6 +101,38 @@ 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() {
|
||||||
@@ -188,14 +209,22 @@ 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDesktopBreadcrumb(
|
Widget _buildDesktopBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
|
||||||
BuildContext context,
|
|
||||||
FileManagerProvider fileManager,
|
|
||||||
) {
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final pathParts = fileManager.currentPath.split('/');
|
final pathParts = fileManager.currentPath.split('/');
|
||||||
@@ -205,9 +234,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => fileManager.currentPath != '/'
|
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
|
||||||
? fileManager.enterFolder('/')
|
|
||||||
: null,
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||||
@@ -217,18 +244,12 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
for (int i = 0; i < pathParts.length; i++) ...[
|
for (int i = 0; i < pathParts.length; i++) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
child: Icon(
|
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)),
|
||||||
LucideIcons.chevronRight,
|
|
||||||
size: 14,
|
|
||||||
color: theme.hintColor.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
|
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
|
||||||
if (targetPath != fileManager.currentPath) {
|
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
|
||||||
fileManager.enterFolder(targetPath);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(6),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -236,12 +257,8 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
child: Text(
|
child: Text(
|
||||||
_decodePathSegment(pathParts[i]),
|
_decodePathSegment(pathParts[i]),
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: i == pathParts.length - 1
|
color: i == pathParts.length - 1 ? colorScheme.onSurface : colorScheme.primary,
|
||||||
? colorScheme.onSurface
|
fontWeight: i == pathParts.length - 1 ? FontWeight.w600 : FontWeight.normal,
|
||||||
: colorScheme.primary,
|
|
||||||
fontWeight: i == pathParts.length - 1
|
|
||||||
? FontWeight.w600
|
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -251,10 +268,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildMobileBreadcrumb(
|
Widget _buildMobileBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
|
||||||
BuildContext context,
|
|
||||||
FileManagerProvider fileManager,
|
|
||||||
) {
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final pathParts = fileManager.currentPath.split('/');
|
final pathParts = fileManager.currentPath.split('/');
|
||||||
@@ -263,6 +277,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 40,
|
height: 40,
|
||||||
child: ListView(
|
child: ListView(
|
||||||
|
key: const PageStorageKey('mobile_breadcrumb'),
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
children: [
|
children: [
|
||||||
_buildBreadcrumbChip(
|
_buildBreadcrumbChip(
|
||||||
@@ -270,18 +285,12 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
label: '文件',
|
label: '文件',
|
||||||
icon: LucideIcons.home,
|
icon: LucideIcons.home,
|
||||||
color: colorScheme.primary,
|
color: colorScheme.primary,
|
||||||
onTap: () => fileManager.currentPath != '/'
|
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
|
||||||
? fileManager.enterFolder('/')
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
for (int i = 0; i < pathParts.length; i++) ...[
|
for (int i = 0; i < pathParts.length; i++) ...[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
child: Icon(
|
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)),
|
||||||
LucideIcons.chevronRight,
|
|
||||||
size: 14,
|
|
||||||
color: theme.hintColor.withValues(alpha: 0.5),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
_buildBreadcrumbChip(
|
_buildBreadcrumbChip(
|
||||||
context,
|
context,
|
||||||
@@ -291,9 +300,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
isLast: i == pathParts.length - 1,
|
isLast: i == pathParts.length - 1,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
|
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
|
||||||
if (targetPath != fileManager.currentPath) {
|
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
|
||||||
fileManager.enterFolder(targetPath);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -317,9 +324,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
height: 28,
|
height: 28,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isLast
|
color: isLast ? color.withValues(alpha: 0.15) : color.withValues(alpha: 0.06),
|
||||||
? color.withValues(alpha: 0.15)
|
|
||||||
: color.withValues(alpha: 0.06),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -353,9 +358,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
Consumer<FileManagerProvider>(
|
Consumer<FileManagerProvider>(
|
||||||
builder: (context, fileManager, child) {
|
builder: (context, fileManager, child) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: Icon(
|
icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh),
|
||||||
fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh,
|
|
||||||
),
|
|
||||||
onPressed: () => fileManager.refreshFiles(),
|
onPressed: () => fileManager.refreshFiles(),
|
||||||
tooltip: '刷新',
|
tooltip: '刷新',
|
||||||
);
|
);
|
||||||
@@ -375,19 +378,14 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
: FileViewType.list,
|
: FileViewType.list,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
tooltip: fileManager.viewType == FileViewType.list
|
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
|
||||||
? '网格视图'
|
|
||||||
: '列表视图',
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
final fileManager = Provider.of<FileManagerProvider>(
|
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||||
context,
|
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
FileOperationDialogs.showCreateDialog(context, fileManager);
|
FileOperationDialogs.showCreateDialog(context, fileManager);
|
||||||
},
|
},
|
||||||
tooltip: '新建',
|
tooltip: '新建',
|
||||||
@@ -399,8 +397,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.cloud_download),
|
icon: const Icon(Icons.cloud_download),
|
||||||
onPressed: () =>
|
onPressed: () => Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
|
||||||
Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
|
|
||||||
tooltip: '下载',
|
tooltip: '下载',
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
@@ -422,9 +419,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
: FileViewType.list,
|
: FileViewType.list,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
tooltip: fileManager.viewType == FileViewType.list
|
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
|
||||||
? '网格视图'
|
|
||||||
: '列表视图',
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -476,16 +471,8 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
final fileManager = Provider.of<FileManagerProvider>(
|
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||||
context,
|
_onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager));
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
_onFabSubAction(
|
|
||||||
() => FileOperationDialogs.showCreateDialog(
|
|
||||||
context,
|
|
||||||
fileManager,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_buildFabSubItem(
|
_buildFabSubItem(
|
||||||
@@ -495,10 +482,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
label: '离线下载',
|
label: '离线下载',
|
||||||
isDark: isDark,
|
isDark: isDark,
|
||||||
colorScheme: colorScheme,
|
colorScheme: colorScheme,
|
||||||
onTap: () => _onFabSubAction(
|
onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)),
|
||||||
() =>
|
|
||||||
Navigator.of(context).pushNamed(RouteNames.remoteDownload),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Consumer<FileManagerProvider>(
|
Consumer<FileManagerProvider>(
|
||||||
builder: (context, fileManager, _) {
|
builder: (context, fileManager, _) {
|
||||||
@@ -584,10 +568,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||||
horizontal: 12,
|
|
||||||
vertical: 7,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isDark
|
color: isDark
|
||||||
? Colors.white.withValues(alpha: 0.12)
|
? Colors.white.withValues(alpha: 0.12)
|
||||||
@@ -668,8 +649,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
final isDesktop = MediaQuery.of(context).size.width >= 1000;
|
final isDesktop = MediaQuery.of(context).size.width >= 1000;
|
||||||
final child = _buildFileList(context);
|
final child = _buildFileList(context);
|
||||||
|
|
||||||
if (!isDesktop ||
|
if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
|
||||||
!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
|
|
||||||
return child;
|
return child;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,19 +673,13 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
strokeAlign: BorderSide.strokeAlignOutside,
|
strokeAlign: BorderSide.strokeAlignOutside,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
color: Theme.of(
|
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
|
||||||
context,
|
|
||||||
).colorScheme.surface.withValues(alpha: 0.85),
|
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(LucideIcons.upload, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||||
LucideIcons.upload,
|
|
||||||
size: 48,
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
Text(
|
Text(
|
||||||
'释放文件以上传到当前目录',
|
'释放文件以上传到当前目录',
|
||||||
@@ -735,14 +709,8 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
}
|
}
|
||||||
if (files.isEmpty) return;
|
if (files.isEmpty) return;
|
||||||
|
|
||||||
final uploadManager = Provider.of<UploadManagerProvider>(
|
final uploadManager = Provider.of<UploadManagerProvider>(context, listen: false);
|
||||||
context,
|
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
final fileManager = Provider.of<FileManagerProvider>(
|
|
||||||
context,
|
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
uploadManager.markShouldShowDialog();
|
uploadManager.markShouldShowDialog();
|
||||||
uploadManager.startUpload(files, fileManager.currentPath);
|
uploadManager.startUpload(files, fileManager.currentPath);
|
||||||
ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
|
ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
|
||||||
@@ -772,19 +740,12 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildErrorView(
|
Widget _buildErrorView(BuildContext context, FileManagerProvider fileManager) {
|
||||||
BuildContext context,
|
|
||||||
FileManagerProvider fileManager,
|
|
||||||
) {
|
|
||||||
return Center(
|
return Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.error_outline, size: 64, color: Theme.of(context).colorScheme.error),
|
||||||
Icons.error_outline,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(context).colorScheme.error,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
fileManager.errorMessage!,
|
fileManager.errorMessage!,
|
||||||
@@ -832,12 +793,13 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
child: NotificationListener<ScrollNotification>(
|
child: NotificationListener<ScrollNotification>(
|
||||||
onNotification: _onScrollNotification,
|
onNotification: _onScrollNotification,
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
|
key: PageStorageKey('files_list_${fileManager.currentPath}'),
|
||||||
|
cacheExtent: 900,
|
||||||
|
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||||
itemCount: fileManager.files.length,
|
itemCount: fileManager.files.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final file = fileManager.files[index];
|
final file = fileManager.files[index];
|
||||||
final isSelected = fileManager.selectedFiles.contains(
|
final isSelected = fileManager.selectedFiles.contains(file.path);
|
||||||
file.path,
|
|
||||||
);
|
|
||||||
|
|
||||||
return FileListItem(
|
return FileListItem(
|
||||||
key: ValueKey('file_${file.id}'),
|
key: ValueKey('file_${file.id}'),
|
||||||
@@ -859,37 +821,14 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSelect: () => fileManager.toggleSelection(file.path),
|
onSelect: () => fileManager.toggleSelection(file.path),
|
||||||
onDownload: !file.isFolder
|
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
|
||||||
? () => _downloadFile(context, fileManager, file)
|
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
|
||||||
: null,
|
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
|
||||||
onOpenInBrowser: !file.isFolder
|
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
|
||||||
? () => _openInBrowser(context, file)
|
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
|
||||||
: null,
|
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
|
||||||
onRename: () => FileOperationDialogs.showRenameDialog(
|
onShare: () => FileOperationDialogs.showShareDialog(context, file),
|
||||||
context,
|
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
|
||||||
fileManager,
|
|
||||||
file,
|
|
||||||
),
|
|
||||||
onMove: () => FileOperationDialogs.showMoveDialog(
|
|
||||||
context,
|
|
||||||
fileManager,
|
|
||||||
file,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
onCopy: () => FileOperationDialogs.showMoveDialog(
|
|
||||||
context,
|
|
||||||
fileManager,
|
|
||||||
file,
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
onShare: () =>
|
|
||||||
FileOperationDialogs.showShareDialog(context, file),
|
|
||||||
onDelete: () =>
|
|
||||||
FileOperationDialogs.showDeleteSingleConfirmation(
|
|
||||||
context,
|
|
||||||
fileManager,
|
|
||||||
file,
|
|
||||||
),
|
|
||||||
onInfo: () => _showFileInfo(file),
|
onInfo: () => _showFileInfo(file),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -918,8 +857,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
crossAxisCount = 5;
|
crossAxisCount = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
final itemWidth =
|
final itemWidth = (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;
|
||||||
|
|
||||||
@@ -928,6 +866,9 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
child: NotificationListener<ScrollNotification>(
|
child: NotificationListener<ScrollNotification>(
|
||||||
onNotification: _onScrollNotification,
|
onNotification: _onScrollNotification,
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
|
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,
|
||||||
@@ -959,36 +900,14 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSelect: () => fileManager.toggleSelection(file.path),
|
onSelect: () => fileManager.toggleSelection(file.path),
|
||||||
onDownload: !file.isFolder
|
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
|
||||||
? () => _downloadFile(context, fileManager, file)
|
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
|
||||||
: null,
|
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
|
||||||
onOpenInBrowser: !file.isFolder
|
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
|
||||||
? () => _openInBrowser(context, file)
|
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
|
||||||
: null,
|
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
|
||||||
onRename: () => FileOperationDialogs.showRenameDialog(
|
onShare: () => FileOperationDialogs.showShareDialog(context, file),
|
||||||
context,
|
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
|
||||||
fileManager,
|
|
||||||
file,
|
|
||||||
),
|
|
||||||
onMove: () => FileOperationDialogs.showMoveDialog(
|
|
||||||
context,
|
|
||||||
fileManager,
|
|
||||||
file,
|
|
||||||
false,
|
|
||||||
),
|
|
||||||
onCopy: () => FileOperationDialogs.showMoveDialog(
|
|
||||||
context,
|
|
||||||
fileManager,
|
|
||||||
file,
|
|
||||||
true,
|
|
||||||
),
|
|
||||||
onShare: () =>
|
|
||||||
FileOperationDialogs.showShareDialog(context, file),
|
|
||||||
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(
|
|
||||||
context,
|
|
||||||
fileManager,
|
|
||||||
file,
|
|
||||||
),
|
|
||||||
onInfo: () => _showFileInfo(file),
|
onInfo: () => _showFileInfo(file),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1006,33 +925,34 @@ 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(
|
||||||
context,
|
context,
|
||||||
fileManager,
|
fileManager,
|
||||||
fileManager.selectedFiles,
|
fileManager.selectedFiles,
|
||||||
false,
|
false,
|
||||||
),
|
),
|
||||||
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
|
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
|
||||||
context,
|
context,
|
||||||
fileManager,
|
fileManager,
|
||||||
fileManager.selectedFiles,
|
fileManager.selectedFiles,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
|
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
|
||||||
context,
|
context,
|
||||||
fileManager,
|
fileManager,
|
||||||
fileManager.selectedFiles,
|
fileManager.selectedFiles,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1056,17 +976,11 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
} else if (FileTypeUtils.isAudio(file.name)) {
|
} else if (FileTypeUtils.isAudio(file.name)) {
|
||||||
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
|
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
|
||||||
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
||||||
Navigator.of(
|
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
|
||||||
context,
|
|
||||||
).pushNamed(RouteNames.markdownPreview, arguments: file);
|
|
||||||
} else if (FileTypeUtils.isTextCode(file.name)) {
|
} else if (FileTypeUtils.isTextCode(file.name)) {
|
||||||
Navigator.of(
|
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
|
||||||
context,
|
|
||||||
).pushNamed(RouteNames.documentPreview, arguments: file);
|
|
||||||
} else {
|
} else {
|
||||||
ToastHelper.info(
|
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
|
||||||
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,10 +989,7 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
FileManagerProvider fileManager,
|
FileManagerProvider fileManager,
|
||||||
FileModel file,
|
FileModel file,
|
||||||
) async {
|
) async {
|
||||||
final downloadManager = Provider.of<DownloadManagerProvider>(
|
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
|
||||||
context,
|
|
||||||
listen: false,
|
|
||||||
);
|
|
||||||
final task = await downloadManager.addDownloadTask(
|
final task = await downloadManager.addDownloadTask(
|
||||||
fileName: file.name,
|
fileName: file.name,
|
||||||
fileUri: file.relativePath,
|
fileUri: file.relativePath,
|
||||||
@@ -1124,4 +1035,13 @@ class _FilesPageState extends State<FilesPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openInCloudreveApp(BuildContext context, FileModel file) {
|
||||||
|
Navigator.of(context).pushNamed(
|
||||||
|
RouteNames.cloudreveFileApp,
|
||||||
|
arguments: {
|
||||||
|
'file': file,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,45 +23,26 @@ class QuickFunctionsSection extends StatelessWidget {
|
|||||||
const QuickFunctionsSection({super.key});
|
const QuickFunctionsSection({super.key});
|
||||||
|
|
||||||
static final _functions = [
|
static final _functions = [
|
||||||
_QuickFunction(
|
_QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share),
|
||||||
icon: LucideIcons.share2,
|
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav),
|
||||||
label: '我的分享',
|
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload),
|
||||||
route: RouteNames.share,
|
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin),
|
||||||
),
|
|
||||||
_QuickFunction(
|
|
||||||
icon: LucideIcons.cloud,
|
|
||||||
label: 'WebDAV',
|
|
||||||
route: RouteNames.webdav,
|
|
||||||
),
|
|
||||||
_QuickFunction(
|
|
||||||
icon: LucideIcons.download,
|
|
||||||
label: '离线下载',
|
|
||||||
route: RouteNames.remoteDownload,
|
|
||||||
),
|
|
||||||
_QuickFunction(
|
|
||||||
icon: LucideIcons.trash2,
|
|
||||||
label: '回收站',
|
|
||||||
route: RouteNames.recycleBin,
|
|
||||||
),
|
|
||||||
_QuickFunction(
|
_QuickFunction(
|
||||||
icon: LucideIcons.refreshCw,
|
icon: LucideIcons.refreshCw,
|
||||||
label: '文件同步',
|
label: '文件同步',
|
||||||
onTap: (ctx) {
|
onTap: (ctx) {
|
||||||
final isMobilePlatform =
|
final nav = ctx.read<NavigationProvider>();
|
||||||
defaultTargetPlatform == TargetPlatform.android ||
|
// 桌面端有同步 Tab(index 4),直接切换;移动端跳转同步设置页
|
||||||
defaultTargetPlatform == TargetPlatform.iOS;
|
final isDesktop = defaultTargetPlatform != TargetPlatform.android &&
|
||||||
if (isMobilePlatform) {
|
defaultTargetPlatform != TargetPlatform.iOS;
|
||||||
|
if (isDesktop) {
|
||||||
|
nav.setIndex(4);
|
||||||
|
} else {
|
||||||
Navigator.of(ctx).pushNamed(RouteNames.syncSettings);
|
Navigator.of(ctx).pushNamed(RouteNames.syncSettings);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
ctx.read<NavigationProvider>().setIndex(3);
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
_QuickFunction(
|
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings),
|
||||||
icon: LucideIcons.settings,
|
|
||||||
label: '设置',
|
|
||||||
route: RouteNames.settings,
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
static const double _spacing = 12;
|
static const double _spacing = 12;
|
||||||
@@ -82,12 +63,9 @@ class QuickFunctionsSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
|
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text('快捷功能',
|
||||||
'快捷功能',
|
style: theme.textTheme.titleMedium
|
||||||
style: theme.textTheme.titleMedium?.copyWith(
|
?.copyWith(fontWeight: FontWeight.w600)),
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -101,8 +79,7 @@ class QuickFunctionsSection extends StatelessWidget {
|
|||||||
if (itemWidth < _minItemWidth) break;
|
if (itemWidth < _minItemWidth) break;
|
||||||
perRow = next;
|
perRow = next;
|
||||||
}
|
}
|
||||||
final itemWidth =
|
final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow;
|
||||||
(availableWidth - _spacing * (perRow - 1)) / perRow;
|
|
||||||
|
|
||||||
return Wrap(
|
return Wrap(
|
||||||
spacing: _spacing,
|
spacing: _spacing,
|
||||||
@@ -155,7 +132,9 @@ class _QuickFunctionCardState extends State<_QuickFunctionCard> {
|
|||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
color: _hovered ? colorScheme.surfaceContainerHighest : null,
|
color: _hovered
|
||||||
|
? colorScheme.surfaceContainerHighest
|
||||||
|
: null,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: widget.onTap,
|
onTap: widget.onTap,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|||||||
@@ -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)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,800 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:math';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
import '../../widgets/desktop_constrained.dart';
|
||||||
|
import '../../widgets/toast_helper.dart';
|
||||||
|
|
||||||
|
enum _NetworkTestTab { network, service, custom }
|
||||||
|
|
||||||
|
class NetworkTestPage extends StatefulWidget {
|
||||||
|
const NetworkTestPage({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<NetworkTestPage> createState() => _NetworkTestPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NetworkTestPageState extends State<NetworkTestPage> {
|
||||||
|
static final _panUri = Uri.parse('https://pan.gongyun.org');
|
||||||
|
static final _infoUri = Uri.parse('https://www.gongyun.org/appnetinfo.html');
|
||||||
|
static final _primaryIpLookupUri = Uri.parse('https://ipwho.is/');
|
||||||
|
static final _secondaryIpLookupUri = Uri.parse('https://ipapi.co/json/');
|
||||||
|
static const _probeHeaders = {'Cache-Control': 'no-cache'};
|
||||||
|
static const _probeGetFallbackHeaders = {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Range': 'bytes=0-0',
|
||||||
|
};
|
||||||
|
|
||||||
|
_NetworkTestTab _tab = _NetworkTestTab.network;
|
||||||
|
bool _networkLoading = false;
|
||||||
|
bool _serviceLoading = false;
|
||||||
|
_NetworkDiagnostics? _networkDiagnostics;
|
||||||
|
_ServiceDiagnostics? _serviceDiagnostics;
|
||||||
|
String? _networkError;
|
||||||
|
String? _serviceError;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _runNetworkTest());
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('网络测试'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: '说明',
|
||||||
|
icon: const Icon(Icons.info_outline),
|
||||||
|
onPressed: () => _openExternalUrl(_infoUri),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: DesktopConstrained(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
child: SegmentedButton<_NetworkTestTab>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: _NetworkTestTab.network,
|
||||||
|
icon: Icon(Icons.public),
|
||||||
|
label: Text('网络'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _NetworkTestTab.service,
|
||||||
|
icon: Icon(Icons.dns_outlined),
|
||||||
|
label: Text('服务'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _NetworkTestTab.custom,
|
||||||
|
icon: Icon(Icons.route_outlined),
|
||||||
|
label: Text('自定义线路'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_tab},
|
||||||
|
onSelectionChanged: (selection) {
|
||||||
|
final selected = selection.first;
|
||||||
|
if (selected == _NetworkTestTab.custom) {
|
||||||
|
_showCustomUnavailableDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _tab = selected);
|
||||||
|
if (selected == _NetworkTestTab.network &&
|
||||||
|
_networkDiagnostics == null &&
|
||||||
|
!_networkLoading) {
|
||||||
|
_runNetworkTest();
|
||||||
|
}
|
||||||
|
if (selected == _NetworkTestTab.service &&
|
||||||
|
_serviceDiagnostics == null &&
|
||||||
|
!_serviceLoading) {
|
||||||
|
_runServiceTest();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 180),
|
||||||
|
child: switch (_tab) {
|
||||||
|
_NetworkTestTab.network => _NetworkPanel(
|
||||||
|
key: const ValueKey('network'),
|
||||||
|
loading: _networkLoading,
|
||||||
|
error: _networkError,
|
||||||
|
diagnostics: _networkDiagnostics,
|
||||||
|
onRefresh: _runNetworkTest,
|
||||||
|
),
|
||||||
|
_NetworkTestTab.service => _ServicePanel(
|
||||||
|
key: const ValueKey('service'),
|
||||||
|
loading: _serviceLoading,
|
||||||
|
error: _serviceError,
|
||||||
|
diagnostics: _serviceDiagnostics,
|
||||||
|
onRefresh: _runServiceTest,
|
||||||
|
),
|
||||||
|
_NetworkTestTab.custom => const SizedBox.shrink(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showCustomUnavailableDialog() async {
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
title: const Text('自定义线路'),
|
||||||
|
content: const Text('自定义线路为会员功能,当前版本暂未开放。'),
|
||||||
|
actions: [
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
|
child: const Text('好'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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> _runNetworkTest() async {
|
||||||
|
if (_networkLoading) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_networkLoading = true;
|
||||||
|
_networkError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final ipFuture = _fetchIpInfo();
|
||||||
|
final panProbe = await _probeUri(_panUri);
|
||||||
|
final stability = await _testStability(_panUri);
|
||||||
|
final ipInfo = await ipFuture;
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_networkDiagnostics = _NetworkDiagnostics(
|
||||||
|
panReachable: panProbe.reachable,
|
||||||
|
latencyMs: panProbe.latencyMs,
|
||||||
|
ipInfo: ipInfo,
|
||||||
|
stable: stability.stable,
|
||||||
|
sampleCount: stability.sampleCount,
|
||||||
|
successCount: stability.successCount,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _networkError = e.toString());
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _networkLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _runServiceTest() async {
|
||||||
|
if (_serviceLoading) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_serviceLoading = true;
|
||||||
|
_serviceError = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final monitorHeartbeats = await _fetchMonitorHeartbeats('gongyun');
|
||||||
|
|
||||||
|
_ServiceItem monitor(String label, int monitorId) {
|
||||||
|
return _monitorItem(
|
||||||
|
label: label,
|
||||||
|
monitorId: monitorId,
|
||||||
|
heartbeats: monitorHeartbeats,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final telegramBot = await _probeItem(
|
||||||
|
label: 'Telegram bot',
|
||||||
|
uri: Uri.parse('https://bot.mygongyun.com'),
|
||||||
|
healthyStatusCodes: const {200, 401},
|
||||||
|
);
|
||||||
|
|
||||||
|
final business = <_ServiceItem>[
|
||||||
|
monitor('官网', 3),
|
||||||
|
monitor('网盘', 2),
|
||||||
|
monitor('短链', 37),
|
||||||
|
monitor('SSO', 39),
|
||||||
|
telegramBot,
|
||||||
|
];
|
||||||
|
|
||||||
|
final dc = <_ServiceItem>[
|
||||||
|
monitor('DC3-CN-SZ', 13),
|
||||||
|
monitor('DC3-CN-MIAMI', 11),
|
||||||
|
monitor('DC3-CN-NY', 12),
|
||||||
|
monitor('DC3-CN-LV', 10),
|
||||||
|
];
|
||||||
|
|
||||||
|
final backend = <_ServiceItem>[monitor('数据库', 32), monitor('键值对数据库', 36)];
|
||||||
|
|
||||||
|
final storage = <_ServiceItem>[
|
||||||
|
monitor('DC-S-NA-1', 5),
|
||||||
|
monitor('DC-S-NA-2', 6),
|
||||||
|
monitor('DC-S-EU', 7),
|
||||||
|
monitor('DC-S-EP', 8),
|
||||||
|
];
|
||||||
|
|
||||||
|
final acceleration = <_ServiceItem>[
|
||||||
|
monitor('香港', 18),
|
||||||
|
monitor('香港', 19),
|
||||||
|
monitor('新加坡', 22),
|
||||||
|
monitor('洛杉矶', 24),
|
||||||
|
monitor('蒙特利尔', 21),
|
||||||
|
];
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_serviceDiagnostics = _ServiceDiagnostics(
|
||||||
|
fetchedAt: DateTime.now(),
|
||||||
|
business: business,
|
||||||
|
dc: dc,
|
||||||
|
backend: backend,
|
||||||
|
storage: storage,
|
||||||
|
acceleration: acceleration,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) setState(() => _serviceError = e.toString());
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _serviceLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_IpInfo> _fetchIpInfo() async {
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(_primaryIpLookupUri)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
if (data['success'] != false) {
|
||||||
|
final connection = data['connection'];
|
||||||
|
final connectionMap = connection is Map<String, dynamic>
|
||||||
|
? connection
|
||||||
|
: <String, dynamic>{};
|
||||||
|
return _IpInfo(
|
||||||
|
ip: data['ip']?.toString() ?? '未知',
|
||||||
|
asn: connectionMap['asn']?.toString() ?? '未知',
|
||||||
|
operatorName: connectionMap['isp']?.toString().isNotEmpty == true
|
||||||
|
? connectionMap['isp'].toString()
|
||||||
|
: (connectionMap['org']?.toString() ?? '未知'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Fall back to the next public endpoint.
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(_secondaryIpLookupUri)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
if (response.statusCode >= 200 && response.statusCode < 300) {
|
||||||
|
final data = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
return _IpInfo(
|
||||||
|
ip: data['ip']?.toString() ?? '未知',
|
||||||
|
asn: data['asn']?.toString() ?? '未知',
|
||||||
|
operatorName: data['org']?.toString() ?? '未知',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Handled by the default value below.
|
||||||
|
}
|
||||||
|
|
||||||
|
return const _IpInfo(ip: '未知', asn: '未知', operatorName: '未知');
|
||||||
|
}
|
||||||
|
|
||||||
|
_ServiceItem _monitorItem({
|
||||||
|
required String label,
|
||||||
|
required int monitorId,
|
||||||
|
required Map<int, _MonitorHeartbeat> heartbeats,
|
||||||
|
}) {
|
||||||
|
final heartbeat = heartbeats[monitorId];
|
||||||
|
return _ServiceItem(
|
||||||
|
label: label,
|
||||||
|
online: heartbeat?.online ?? false,
|
||||||
|
latencyMs: heartbeat?.pingMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<int, _MonitorHeartbeat>> _fetchMonitorHeartbeats(
|
||||||
|
String pageId,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
final response = await http
|
||||||
|
.get(
|
||||||
|
Uri.parse('https://status.gongyun.org/api/monitor?pageId=$pageId'),
|
||||||
|
headers: _probeHeaders,
|
||||||
|
)
|
||||||
|
.timeout(const Duration(seconds: 10));
|
||||||
|
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decoded = jsonDecode(response.body);
|
||||||
|
if (decoded is! Map<String, dynamic>) return const {};
|
||||||
|
final data = decoded['data'];
|
||||||
|
if (data is! Map<String, dynamic>) return const {};
|
||||||
|
final heartbeatList = data['heartbeatList'];
|
||||||
|
if (heartbeatList is! Map<String, dynamic>) return const {};
|
||||||
|
|
||||||
|
final result = <int, _MonitorHeartbeat>{};
|
||||||
|
for (final entry in heartbeatList.entries) {
|
||||||
|
final monitorId = int.tryParse(entry.key);
|
||||||
|
final records = entry.value;
|
||||||
|
if (monitorId == null || records is! List || records.isEmpty) continue;
|
||||||
|
final latest = records.last;
|
||||||
|
if (latest is! Map<String, dynamic>) continue;
|
||||||
|
final status = _asInt(latest['status']);
|
||||||
|
final ping = _asInt(latest['ping']);
|
||||||
|
result[monitorId] = _MonitorHeartbeat(
|
||||||
|
online: status == 1,
|
||||||
|
pingMs: ping,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (_) {
|
||||||
|
return const {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _asInt(Object? value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.round();
|
||||||
|
return int.tryParse(value?.toString() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_ServiceItem> _probeItem({
|
||||||
|
required String label,
|
||||||
|
required Uri uri,
|
||||||
|
Set<int> healthyStatusCodes = const {},
|
||||||
|
}) async {
|
||||||
|
final probe = await _probeUri(uri, healthyStatusCodes: healthyStatusCodes);
|
||||||
|
return _ServiceItem(
|
||||||
|
label: label,
|
||||||
|
online: probe.reachable,
|
||||||
|
latencyMs: probe.latencyMs,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_ProbeResult> _probeUri(
|
||||||
|
Uri uri, {
|
||||||
|
Set<int> healthyStatusCodes = const {},
|
||||||
|
}) async {
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
var response = await http
|
||||||
|
.head(uri, headers: _probeHeaders)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
if (_shouldRetryProbeWithGet(response.statusCode)) {
|
||||||
|
response = await http
|
||||||
|
.get(uri, headers: _probeGetFallbackHeaders)
|
||||||
|
.timeout(const Duration(seconds: 8));
|
||||||
|
}
|
||||||
|
stopwatch.stop();
|
||||||
|
final isHealthy = _isHealthyStatus(
|
||||||
|
response.statusCode,
|
||||||
|
healthyStatusCodes,
|
||||||
|
);
|
||||||
|
return _ProbeResult(
|
||||||
|
reachable: isHealthy,
|
||||||
|
latencyMs: stopwatch.elapsedMilliseconds,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
stopwatch.stop();
|
||||||
|
return const _ProbeResult(reachable: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _shouldRetryProbeWithGet(int statusCode) {
|
||||||
|
return statusCode == 403 || statusCode == 405 || statusCode == 501;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isHealthyStatus(int statusCode, Set<int> healthyStatusCodes) {
|
||||||
|
if (healthyStatusCodes.isNotEmpty) {
|
||||||
|
return healthyStatusCodes.contains(statusCode);
|
||||||
|
}
|
||||||
|
return statusCode >= 200 && statusCode < 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<_StabilityResult> _testStability(Uri uri) async {
|
||||||
|
final latencies = <int>[];
|
||||||
|
var successCount = 0;
|
||||||
|
const sampleCount = 3;
|
||||||
|
|
||||||
|
for (var i = 0; i < sampleCount; i++) {
|
||||||
|
final probe = await _probeUri(uri);
|
||||||
|
if (probe.reachable) {
|
||||||
|
successCount += 1;
|
||||||
|
if (probe.latencyMs != null) latencies.add(probe.latencyMs!);
|
||||||
|
}
|
||||||
|
if (i < sampleCount - 1) {
|
||||||
|
await Future<void>.delayed(const Duration(milliseconds: 350));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final jitter = latencies.isEmpty
|
||||||
|
? 999999
|
||||||
|
: latencies.reduce(max) - latencies.reduce(min);
|
||||||
|
return _StabilityResult(
|
||||||
|
stable: successCount == sampleCount && jitter < 1500,
|
||||||
|
sampleCount: sampleCount,
|
||||||
|
successCount: successCount,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NetworkPanel extends StatelessWidget {
|
||||||
|
final bool loading;
|
||||||
|
final String? error;
|
||||||
|
final _NetworkDiagnostics? diagnostics;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
const _NetworkPanel({
|
||||||
|
super.key,
|
||||||
|
required this.loading,
|
||||||
|
required this.error,
|
||||||
|
required this.diagnostics,
|
||||||
|
required this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = diagnostics;
|
||||||
|
final errorColor = Theme.of(context).colorScheme.error;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
|
||||||
|
children: [
|
||||||
|
_SectionCard(
|
||||||
|
title: '网络',
|
||||||
|
children: [
|
||||||
|
if (loading) const LinearProgressIndicator(),
|
||||||
|
if (error != null) _ErrorText(error!),
|
||||||
|
_InfoRow(
|
||||||
|
label: '公云存储网盘访问测试',
|
||||||
|
value: data == null ? '检测中' : (data.panReachable ? '正常' : '离线'),
|
||||||
|
valueColor: _statusColor(context, data?.panReachable),
|
||||||
|
),
|
||||||
|
_InfoRow(label: '当前网络 IP 地址', value: data?.ipInfo.ip ?? '检测中'),
|
||||||
|
_InfoRow(label: 'IP 归属 AS', value: data?.ipInfo.asn ?? '检测中'),
|
||||||
|
_InfoRow(label: '运营商', value: data?.ipInfo.operatorName ?? '检测中'),
|
||||||
|
_InfoRow(
|
||||||
|
label: '连接时延',
|
||||||
|
value: data?.latencyMs == null ? '检测中' : '${data!.latencyMs} ms',
|
||||||
|
),
|
||||||
|
_InfoRow(
|
||||||
|
label: '长期连接稳定性',
|
||||||
|
value: data == null
|
||||||
|
? '检测中'
|
||||||
|
: '${data.stable ? '稳定' : '不稳定'} '
|
||||||
|
'(${data.successCount}/${data.sampleCount})',
|
||||||
|
valueColor: _statusColor(context, data?.stable),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_SectionCard(
|
||||||
|
title: '说明',
|
||||||
|
children: const [
|
||||||
|
_DescriptionLine('公云存储作为国际化云存储产品,已接入Cloudflare CDN作为全球加速引擎。'),
|
||||||
|
_DescriptionLine(
|
||||||
|
'当前网络 IP 地址、AS 与运营商信息通过第三方服务 ipwho.is 查询;如该服务不可用,将使用 ipapi.co 作为备用查询。',
|
||||||
|
),
|
||||||
|
_DescriptionLine(
|
||||||
|
'由于部分国家/地区有法律法规限制与审查,可能会出现包括但不限于网络波动、网络丢包以及网络连接不稳定现象',
|
||||||
|
),
|
||||||
|
_DescriptionLine(
|
||||||
|
'如若出现网络连接问题,您可将本页面内容向公云存储团队报告,团队会根据相关诊断线索进行排查与优化。',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_SectionCard(
|
||||||
|
title: '警告',
|
||||||
|
titleColor: errorColor,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'本页面内容涉及你本人敏感信息请勿提供给非公云存储团队人员,以避免你本人相关信息泄露!',
|
||||||
|
style: TextStyle(color: errorColor, fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: loading ? null : onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('重新检测'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServicePanel extends StatelessWidget {
|
||||||
|
final bool loading;
|
||||||
|
final String? error;
|
||||||
|
final _ServiceDiagnostics? diagnostics;
|
||||||
|
final VoidCallback onRefresh;
|
||||||
|
|
||||||
|
const _ServicePanel({
|
||||||
|
super.key,
|
||||||
|
required this.loading,
|
||||||
|
required this.error,
|
||||||
|
required this.diagnostics,
|
||||||
|
required this.onRefresh,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = diagnostics;
|
||||||
|
return ListView(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 18),
|
||||||
|
children: [
|
||||||
|
_SectionCard(
|
||||||
|
title: '业务状态',
|
||||||
|
children: [
|
||||||
|
if (loading) const LinearProgressIndicator(),
|
||||||
|
if (error != null) _ErrorText(error!),
|
||||||
|
..._itemsOrLoading(data?.business),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
_SectionCard(title: '离线下载与计算节点', children: _itemsOrLoading(data?.dc)),
|
||||||
|
_SectionCard(title: '后端', children: _itemsOrLoading(data?.backend)),
|
||||||
|
_SectionCard(title: '存储节点', children: _itemsOrLoading(data?.storage)),
|
||||||
|
_SectionCard(
|
||||||
|
title: '加速线路',
|
||||||
|
children: _itemsOrLoading(data?.acceleration, showLatency: true),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: loading ? null : onRefresh,
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
label: const Text('重新检测'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _itemsOrLoading(
|
||||||
|
List<_ServiceItem>? items, {
|
||||||
|
bool showLatency = false,
|
||||||
|
}) {
|
||||||
|
if (items == null) {
|
||||||
|
return const [_InfoRow(label: '状态', value: '检测中')];
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
.map(
|
||||||
|
(item) => _InfoRow(
|
||||||
|
label: item.label,
|
||||||
|
value: showLatency
|
||||||
|
? item.latencyLabel
|
||||||
|
: (item.online ? '正常' : '离线'),
|
||||||
|
valueColor: item.online ? Colors.green : Colors.red,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<Widget> children;
|
||||||
|
final Color? titleColor;
|
||||||
|
|
||||||
|
const _SectionCard({
|
||||||
|
required this.title,
|
||||||
|
required this.children,
|
||||||
|
this.titleColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 12),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: titleColor,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
...children,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _InfoRow extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String value;
|
||||||
|
final Color? valueColor;
|
||||||
|
|
||||||
|
const _InfoRow({required this.label, required this.value, this.valueColor});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(label, style: theme.textTheme.bodyMedium)),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Flexible(
|
||||||
|
child: Text(
|
||||||
|
value,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: valueColor,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DescriptionLine extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
|
||||||
|
const _DescriptionLine(this.text);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 5),
|
||||||
|
child: Text(text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ErrorText extends StatelessWidget {
|
||||||
|
final String error;
|
||||||
|
|
||||||
|
const _ErrorText(this.error);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 8),
|
||||||
|
child: Text(
|
||||||
|
'检测失败:$error',
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Color? _statusColor(BuildContext context, bool? value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
return value ? Colors.green : Theme.of(context).colorScheme.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NetworkDiagnostics {
|
||||||
|
final bool panReachable;
|
||||||
|
final int? latencyMs;
|
||||||
|
final _IpInfo ipInfo;
|
||||||
|
final bool stable;
|
||||||
|
final int sampleCount;
|
||||||
|
final int successCount;
|
||||||
|
|
||||||
|
const _NetworkDiagnostics({
|
||||||
|
required this.panReachable,
|
||||||
|
required this.latencyMs,
|
||||||
|
required this.ipInfo,
|
||||||
|
required this.stable,
|
||||||
|
required this.sampleCount,
|
||||||
|
required this.successCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _IpInfo {
|
||||||
|
final String ip;
|
||||||
|
final String asn;
|
||||||
|
final String operatorName;
|
||||||
|
|
||||||
|
const _IpInfo({
|
||||||
|
required this.ip,
|
||||||
|
required this.asn,
|
||||||
|
required this.operatorName,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServiceDiagnostics {
|
||||||
|
final DateTime fetchedAt;
|
||||||
|
final List<_ServiceItem> business;
|
||||||
|
final List<_ServiceItem> dc;
|
||||||
|
final List<_ServiceItem> backend;
|
||||||
|
final List<_ServiceItem> storage;
|
||||||
|
final List<_ServiceItem> acceleration;
|
||||||
|
|
||||||
|
const _ServiceDiagnostics({
|
||||||
|
required this.fetchedAt,
|
||||||
|
required this.business,
|
||||||
|
required this.dc,
|
||||||
|
required this.backend,
|
||||||
|
required this.storage,
|
||||||
|
required this.acceleration,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ServiceItem {
|
||||||
|
final String label;
|
||||||
|
final bool online;
|
||||||
|
final int? latencyMs;
|
||||||
|
|
||||||
|
const _ServiceItem({
|
||||||
|
required this.label,
|
||||||
|
required this.online,
|
||||||
|
this.latencyMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get latencyLabel {
|
||||||
|
if (!online) return '离线';
|
||||||
|
if (latencyMs == null) return '正常 / -- ms';
|
||||||
|
return '正常 / $latencyMs ms';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MonitorHeartbeat {
|
||||||
|
final bool online;
|
||||||
|
final int? pingMs;
|
||||||
|
|
||||||
|
const _MonitorHeartbeat({required this.online, this.pingMs});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProbeResult {
|
||||||
|
final bool reachable;
|
||||||
|
final int? latencyMs;
|
||||||
|
|
||||||
|
const _ProbeResult({required this.reachable, this.latencyMs});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StabilityResult {
|
||||||
|
final bool stable;
|
||||||
|
final int sampleCount;
|
||||||
|
final int successCount;
|
||||||
|
|
||||||
|
const _StabilityResult({
|
||||||
|
required this.stable,
|
||||||
|
required this.sampleCount,
|
||||||
|
required this.successCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|
||||||
/// 设置主页
|
/// 设置主页
|
||||||
@@ -83,112 +86,121 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
),
|
),
|
||||||
body: DesktopConstrained(
|
body: DesktopConstrained(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
_buildProfileCard(context, user, settings, capacity),
|
_buildProfileCard(context, user, settings, capacity),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_buildSection(
|
_buildSection(
|
||||||
title: '账户与安全',
|
title: '账户与安全',
|
||||||
children: [
|
children: [
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
title: '个人资料',
|
title: '个人资料',
|
||||||
subtitle: '修改昵称、头像',
|
subtitle: '修改昵称、头像',
|
||||||
onTap: () => _navigateTo(context, const ProfileEditPage()),
|
onTap: () => _navigateTo(context, const ProfileEditPage()),
|
||||||
),
|
),
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
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()),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
_buildSection(
|
),
|
||||||
title: '偏好',
|
_buildSection(
|
||||||
children: [
|
title: '偏好',
|
||||||
_SettingsTile(
|
children: [
|
||||||
icon: Icons.sync_outlined,
|
_SettingsTile(
|
||||||
title: '文件同步',
|
icon: Icons.sync_outlined,
|
||||||
subtitle: '本地与云端文件自动同步',
|
title: '文件同步',
|
||||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.syncSettings),
|
subtitle: '本地与云端文件自动同步',
|
||||||
),
|
onTap: () =>
|
||||||
_SettingsTile(
|
Navigator.of(context).pushNamed(RouteNames.syncSettings),
|
||||||
icon: Icons.apps_outlined,
|
),
|
||||||
title: '快捷入口',
|
_SettingsTile(
|
||||||
subtitle: '自定义概览页快捷目录',
|
icon: Icons.apps_outlined,
|
||||||
onTap: () => _navigateTo(context, const QuickAccessSettingsPage()),
|
title: '快捷入口',
|
||||||
),
|
subtitle: '自定义概览页快捷目录',
|
||||||
_SettingsTile(
|
onTap: () =>
|
||||||
icon: Icons.folder_outlined,
|
_navigateTo(context, const QuickAccessSettingsPage()),
|
||||||
title: '文件偏好',
|
),
|
||||||
subtitle: '版本保留、视图同步、分享可见性',
|
_SettingsTile(
|
||||||
onTap: () => _navigateTo(context, const FilePreferencesPage()),
|
icon: Icons.folder_outlined,
|
||||||
),
|
title: '文件偏好',
|
||||||
_SettingsTile(
|
subtitle: '版本保留、视图同步、分享可见性',
|
||||||
icon: Icons.tune,
|
onTap: () =>
|
||||||
title: '应用设置',
|
_navigateTo(context, const FilePreferencesPage()),
|
||||||
subtitle: '缓存、主题、语言',
|
),
|
||||||
onTap: () => _navigateTo(context, const AppSettingsPage()),
|
_SettingsTile(
|
||||||
),
|
icon: Icons.tune,
|
||||||
],
|
title: '应用设置',
|
||||||
),
|
subtitle: '缓存、主题、语言',
|
||||||
// 财务区域(有数据时才显示)
|
onTap: () => _navigateTo(context, const AppSettingsPage()),
|
||||||
if (settings != null) ..._buildProSections(context, settings),
|
),
|
||||||
_buildSection(
|
_SettingsTile(
|
||||||
title: '关于',
|
icon: Icons.network_check,
|
||||||
children: [
|
title: '网络测试',
|
||||||
ListTile(
|
subtitle: '服务连通性测试与自定义线路(会员)',
|
||||||
leading: const Icon(Icons.info_outline),
|
onTap: () => _openNetworkTest(context),
|
||||||
title: const Text('应用名称'),
|
),
|
||||||
subtitle: const Text('公云存储'),
|
],
|
||||||
),
|
),
|
||||||
ListTile(
|
// 财务区域(有数据时才显示)
|
||||||
leading: const Icon(Icons.tag),
|
if (settings != null) ..._buildProSections(context, settings),
|
||||||
title: const Text('版本号'),
|
_buildSection(
|
||||||
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
|
title: '关于',
|
||||||
),
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.copyright),
|
leading: const Icon(Icons.info_outline),
|
||||||
title: const Text('License'),
|
title: const Text('应用名称'),
|
||||||
subtitle: const Text('AGPL-3.0'),
|
subtitle: const Text('公云存储'),
|
||||||
),
|
),
|
||||||
ListTile(
|
_buildVersionTile(),
|
||||||
leading: const Icon(Icons.code),
|
ListTile(
|
||||||
title: const Text('二次开发'),
|
leading: const Icon(Icons.copyright),
|
||||||
subtitle: const Text('gongyun_app'),
|
title: const Text('License'),
|
||||||
trailing: const Icon(Icons.open_in_new, size: 16),
|
subtitle: const Text('AGPL-3.0'),
|
||||||
onTap: () {
|
),
|
||||||
launchUrl(
|
ListTile(
|
||||||
Uri.parse('https://git.saont.net/gongyun/app'),
|
leading: const Icon(Icons.code),
|
||||||
mode: LaunchMode.externalApplication,
|
title: const Text('二次开发'),
|
||||||
);
|
subtitle: const Text('gongyun_app'),
|
||||||
},
|
trailing: const Icon(Icons.open_in_new, size: 16),
|
||||||
),
|
onTap: () {
|
||||||
ListTile(
|
_openExternalUrl(
|
||||||
leading: const Icon(Icons.code),
|
Uri.parse('https://git.saont.net/gongyun/app'),
|
||||||
title: const Text('基于'),
|
);
|
||||||
subtitle: const Text('cloudreve4_flutter'),
|
},
|
||||||
trailing: const Icon(Icons.open_in_new, size: 16),
|
),
|
||||||
onTap: () {
|
ListTile(
|
||||||
launchUrl(
|
leading: const Icon(Icons.code),
|
||||||
Uri.parse('https://github.com/LimoYuan/cloudreve4_flutter'),
|
title: const Text('基于'),
|
||||||
mode: LaunchMode.externalApplication,
|
subtitle: const Text('cloudreve4_flutter'),
|
||||||
);
|
trailing: const Icon(Icons.open_in_new, size: 16),
|
||||||
},
|
onTap: () {
|
||||||
),
|
_openExternalUrl(
|
||||||
],
|
Uri.parse(
|
||||||
),
|
'https://github.com/LimoYuan/cloudreve4_flutter',
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
_buildLogoutButton(context, auth),
|
);
|
||||||
const SizedBox(height: 32),
|
},
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_buildLogoutButton(context, auth),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 财务区域(存储包、积分、会员)
|
/// 财务区域(存储包、积分、会员)
|
||||||
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,40 +209,54 @@ 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(
|
||||||
leading: const Icon(Icons.workspace_premium_outlined),
|
ListTile(
|
||||||
title: const Text('会员'),
|
leading: const Icon(Icons.workspace_premium_outlined),
|
||||||
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
|
title: const Text('会员'),
|
||||||
trailing: TextButton(
|
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
|
||||||
onPressed: () => _cancelMembership(context),
|
trailing: TextButton(
|
||||||
child: const Text('取消会员', style: TextStyle(color: Colors.red)),
|
onPressed: () => _cancelMembership(context),
|
||||||
|
child: const Text('取消会员', style: TextStyle(color: Colors.red)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
if (hasStoragePacks) {
|
if (hasStoragePacks) {
|
||||||
children.add(ListTile(
|
children.add(
|
||||||
leading: const Icon(Icons.inventory_2_outlined),
|
ListTile(
|
||||||
title: Text('存储包 (${settings.storagePacks.length})'),
|
leading: const Icon(Icons.inventory_2_outlined),
|
||||||
subtitle: Text(_storagePackSummary(settings.storagePacks)),
|
title: Text('存储包 (${settings.storagePacks.length})'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
subtitle: Text(_storagePackSummary(settings.storagePacks)),
|
||||||
onTap: () => _showStoragePacks(context, settings.storagePacks),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
));
|
onTap: () => _showStoragePacks(context, settings.storagePacks),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (hasCredit) {
|
if (hasCredit) {
|
||||||
children.add(ListTile(
|
children.add(
|
||||||
leading: const Icon(Icons.account_balance_wallet_outlined),
|
ListTile(
|
||||||
title: const Text('积分'),
|
leading: const Icon(Icons.account_balance_wallet_outlined),
|
||||||
subtitle: Text('${settings.credit} 积分'),
|
title: const Text('积分'),
|
||||||
trailing: const Icon(Icons.chevron_right),
|
subtitle: Text('${settings.credit} 积分'),
|
||||||
onTap: () => _navigateTo(context, CreditHistoryPage(currentCredit: settings.credit)),
|
trailing: const Icon(Icons.chevron_right),
|
||||||
));
|
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(
|
||||||
color: colorScheme.onSurfaceVariant,
|
'$usedText / $totalText',
|
||||||
)),
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
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,33 +472,47 @@ 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(
|
||||||
child: Padding(
|
(pack) => Card(
|
||||||
padding: const EdgeInsets.all(12),
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(12),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Row(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
Text(pack.name, style: Theme.of(ctx).textTheme.titleSmall),
|
children: [
|
||||||
Text(_formatBytes(pack.size), style: Theme.of(ctx).textTheme.labelLarge),
|
Text(
|
||||||
],
|
pack.name,
|
||||||
),
|
style: Theme.of(ctx).textTheme.titleSmall,
|
||||||
const SizedBox(height: 4),
|
),
|
||||||
Text(
|
Text(
|
||||||
'激活: ${_formatDate(pack.activeSince)}'
|
_formatBytes(pack.size),
|
||||||
'${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}',
|
style: Theme.of(ctx).textTheme.labelLarge,
|
||||||
style: Theme.of(ctx).textTheme.bodySmall,
|
),
|
||||||
),
|
],
|
||||||
if (pack.isExpired) ...[
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text('已过期', style: TextStyle(color: Theme.of(ctx).colorScheme.error, fontSize: 12)),
|
Text(
|
||||||
|
'激活: ${_formatDate(pack.activeSince)}'
|
||||||
|
'${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}',
|
||||||
|
style: Theme.of(ctx).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
if (pack.isExpired) ...[
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
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
@@ -4,11 +4,17 @@ import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.
|
|||||||
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/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,9 +53,10 @@ 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;
|
||||||
|
|
||||||
bool get _showSyncTab =>
|
bool get _showSyncTab =>
|
||||||
defaultTargetPlatform != TargetPlatform.android &&
|
defaultTargetPlatform != TargetPlatform.android &&
|
||||||
@@ -84,14 +91,104 @@ 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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|||||||
@@ -226,7 +226,7 @@ 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) {
|
||||||
|
|||||||
@@ -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!);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,25 +52,27 @@ class FileGridItem extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Builder(
|
return RepaintBoundary(
|
||||||
builder: (builderContext) => LayoutBuilder(
|
child: Builder(
|
||||||
builder: (context, constraints) {
|
builder: (builderContext) => LayoutBuilder(
|
||||||
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
|
builder: (context, constraints) {
|
||||||
|
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
|
||||||
|
|
||||||
return _FileGridItemHover(
|
return _FileGridItemHover(
|
||||||
file: file,
|
file: file,
|
||||||
isSelected: isSelected,
|
isSelected: isSelected,
|
||||||
isHighlighted: isHighlighted,
|
isHighlighted: isHighlighted,
|
||||||
showCheckbox: showCheckbox,
|
showCheckbox: showCheckbox,
|
||||||
contextHint: contextHint,
|
contextHint: contextHint,
|
||||||
fontSize: fontSize,
|
fontSize: fontSize,
|
||||||
tapToShowMenu: tapToShowMenu,
|
tapToShowMenu: tapToShowMenu,
|
||||||
onTap: tapToShowMenu ? null : onTap,
|
onTap: tapToShowMenu ? null : onTap,
|
||||||
onLongPress: () => _showMenu(builderContext),
|
onLongPress: () => _showMenu(builderContext),
|
||||||
onSelect: onSelect,
|
onSelect: onSelect,
|
||||||
onMore: () => _showMenu(builderContext),
|
onMore: () => _showMenu(builderContext),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
file: file,
|
fit: StackFit.expand,
|
||||||
contextHint: widget.contextHint,
|
children: [
|
||||||
borderRadius: 10,
|
ThumbnailImage(
|
||||||
|
file: file,
|
||||||
|
contextHint: widget.contextHint,
|
||||||
|
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,60 +134,53 @@ 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(
|
children: [
|
||||||
right: false,
|
Container(
|
||||||
child: Column(
|
padding: const EdgeInsets.only(
|
||||||
children: [
|
left: 16,
|
||||||
Container(
|
right: 8,
|
||||||
padding: const EdgeInsets.only(
|
top: 8,
|
||||||
left: 16,
|
bottom: 12,
|
||||||
right: 8,
|
),
|
||||||
top: 8,
|
decoration: BoxDecoration(
|
||||||
bottom: 12,
|
border: Border(
|
||||||
|
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
FileIconUtils.buildIconWidget(
|
||||||
|
context: context,
|
||||||
|
file: widget.file,
|
||||||
|
size: 32,
|
||||||
|
iconSize: 18,
|
||||||
|
borderRadius: 8,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
const SizedBox(width: 12),
|
||||||
border: Border(
|
Expanded(
|
||||||
bottom: BorderSide(
|
child: Text(
|
||||||
color: theme.dividerColor.withValues(alpha: 0.2),
|
widget.file.name,
|
||||||
),
|
style: theme.textTheme.titleMedium,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
IconButton(
|
||||||
children: [
|
icon: const Icon(LucideIcons.x),
|
||||||
FileIconUtils.buildIconWidget(
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
context: context,
|
|
||||||
file: widget.file,
|
|
||||||
size: 32,
|
|
||||||
iconSize: 18,
|
|
||||||
borderRadius: 8,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Text(
|
|
||||||
widget.file.name,
|
|
||||||
style: theme.textTheme.titleMedium,
|
|
||||||
maxLines: 1,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(LucideIcons.x),
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
Expanded(
|
),
|
||||||
child: _isLoading
|
),
|
||||||
? const Center(child: CircularProgressIndicator())
|
Expanded(
|
||||||
: _error != null
|
child: _isLoading
|
||||||
|
? const Center(child: CircularProgressIndicator())
|
||||||
|
: _error != null
|
||||||
? _buildError(theme)
|
? _buildError(theme)
|
||||||
: _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),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,17 +53,19 @@ class FileListItem extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return _FileListItemHover(
|
return RepaintBoundary(
|
||||||
file: file,
|
child: _FileListItemHover(
|
||||||
isSelected: isSelected,
|
file: file,
|
||||||
isHighlighted: isHighlighted,
|
isSelected: isSelected,
|
||||||
index: index,
|
isHighlighted: isHighlighted,
|
||||||
isDesktop: isDesktop,
|
index: index,
|
||||||
showCheckbox: showCheckbox,
|
isDesktop: isDesktop,
|
||||||
tapToShowMenu: tapToShowMenu,
|
showCheckbox: showCheckbox,
|
||||||
onTap: tapToShowMenu ? null : onTap,
|
tapToShowMenu: tapToShowMenu,
|
||||||
onLongPress: () => _showMenu(context),
|
onTap: tapToShowMenu ? null : onTap,
|
||||||
onSelect: onSelect,
|
onLongPress: () => _showMenu(context),
|
||||||
|
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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,98 +41,55 @@ 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) {
|
try {
|
||||||
final canDownload =
|
await UpdateService.instance.openDownloadsPage();
|
||||||
update.downloadUrl != null &&
|
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||||
(Platform.isAndroid || Platform.isWindows);
|
} catch (e) {
|
||||||
|
ToastHelper.failure('打开下载页面失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> startUpdate() async {
|
Future<void> skipPrompt() async {
|
||||||
if (downloading || !canDownload) return;
|
await UpdateService.instance.skipPromptForFiveDays(update);
|
||||||
|
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||||
|
}
|
||||||
|
|
||||||
setDialogState(() {
|
final releaseNotes = (update.description ?? '').trim();
|
||||||
downloading = true;
|
|
||||||
progress = 0;
|
|
||||||
status = '正在下载更新...';
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
return AlertDialog(
|
||||||
final file = await UpdateService.instance.downloadUpdate(
|
title: Text('发现新版本 ${update.version}'),
|
||||||
update,
|
content: ConstrainedBox(
|
||||||
onProgress: (received, total) {
|
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 360),
|
||||||
if (!mounted || total <= 0) return;
|
child: SingleChildScrollView(
|
||||||
setDialogState(() {
|
child: Column(
|
||||||
progress = received / total;
|
mainAxisSize: MainAxisSize.min,
|
||||||
});
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
},
|
children: [
|
||||||
);
|
Text('新版本号:${update.version}'),
|
||||||
if (file == null) return;
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
setDialogState(() {
|
'版本日志',
|
||||||
progress = 1;
|
style: Theme.of(dialogContext).textTheme.titleSmall,
|
||||||
status = '下载完成,正在打开安装包...';
|
),
|
||||||
});
|
const SizedBox(height: 6),
|
||||||
await UpdateService.instance.openInstaller(file);
|
Text(releaseNotes.isEmpty ? update.title : releaseNotes),
|
||||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
],
|
||||||
} catch (e) {
|
|
||||||
setDialogState(() {
|
|
||||||
downloading = false;
|
|
||||||
status = '下载或打开安装包失败';
|
|
||||||
});
|
|
||||||
ToastHelper.failure('更新失败: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AlertDialog(
|
|
||||||
title: Text('发现新版本 ${update.version}'),
|
|
||||||
content: ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(maxWidth: 420),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(update.title),
|
|
||||||
if ((update.description ?? '').isNotEmpty) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(
|
|
||||||
update.description!,
|
|
||||||
maxLines: 6,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
if (downloading) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
LinearProgressIndicator(
|
|
||||||
value: progress == 0 ? null : progress,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(status ?? ''),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
actions: [
|
),
|
||||||
TextButton(
|
),
|
||||||
onPressed: downloading
|
actions: [
|
||||||
? null
|
TextButton(onPressed: skipPrompt, child: const Text('跳过')),
|
||||||
: () => Navigator.of(dialogContext).pop(),
|
TextButton(
|
||||||
child: const Text('稍后'),
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||||
),
|
child: const Text('取消'),
|
||||||
FilledButton(
|
),
|
||||||
onPressed: downloading ? null : startUpdate,
|
FilledButton(onPressed: openDownloadsPage, child: const Text('更新')),
|
||||||
child: const Text('立即更新'),
|
],
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,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';
|
||||||
|
|
||||||
/// 路由名称
|
/// 路由名称
|
||||||
@@ -37,6 +40,8 @@ class RouteNames {
|
|||||||
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 syncSettings = '/sync-settings';
|
static const String syncSettings = '/sync-settings';
|
||||||
|
static const String shareLink = '/share-link';
|
||||||
|
static const String cloudreveFileApp = '/cloudreve-file-app';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 应用路由
|
/// 应用路由
|
||||||
@@ -226,6 +231,35 @@ class AppRouter {
|
|||||||
builder: (context) => const SyncSettingsPage(),
|
builder: (context) => const SyncSettingsPage(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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,30 +280,44 @@ class CaptchaService {
|
|||||||
final config = captchaWebConfig;
|
final config = captchaWebConfig;
|
||||||
final displayName = config?.displayName ?? '人机验证';
|
final displayName = config?.displayName ?? '人机验证';
|
||||||
|
|
||||||
return Column(
|
return StatefulBuilder(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
builder: (context, setState) {
|
||||||
children: [
|
final hasProxy = _proxyConfig != null;
|
||||||
OutlinedButton.icon(
|
return Column(
|
||||||
onPressed: _isLoadingCaptcha
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
? null
|
children: [
|
||||||
: () => openCaptchaChallenge(context),
|
OutlinedButton.icon(
|
||||||
icon: Icon(
|
onPressed: _isLoadingCaptcha ? null : () async {
|
||||||
_captchaToken == null
|
await openCaptchaChallenge(context);
|
||||||
? Icons.verified_user_outlined
|
setState(() {});
|
||||||
: Icons.verified,
|
},
|
||||||
),
|
onLongPress: _isDesktop && Platform.isWindows
|
||||||
label: Text(
|
? () => _showProxyDialog(context, setState)
|
||||||
_captchaToken == null
|
: null,
|
||||||
? '点击完成 $displayName'
|
icon: Icon(
|
||||||
: '$displayName 已完成,点击重新验证',
|
_captchaToken == null
|
||||||
),
|
? Icons.verified_user_outlined
|
||||||
),
|
: Icons.verified,
|
||||||
const SizedBox(height: 8),
|
),
|
||||||
Text(
|
label: Text(
|
||||||
'当前验证码类型:$displayName',
|
_captchaToken == null
|
||||||
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
|
? '点击完成 $displayName'
|
||||||
),
|
: '$displayName 已完成,点击重新验证',
|
||||||
],
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_isDesktop && Platform.isWindows
|
||||||
|
? '当前验证码类型:$displayName${hasProxy ? ' (代理: $_proxyConfig)' : ''}\n网络问题验证失败可长按上方按钮可以设置代理(仅windows)'
|
||||||
|
: '当前验证码类型:$displayName',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Theme.of(context).hintColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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() ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 删除分享
|
/// 删除分享
|
||||||
|
|||||||
@@ -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(
|
||||||
final Dio _dio = Dio(
|
'https://www.gongyun.org/downloads.html',
|
||||||
BaseOptions(
|
|
||||||
connectTimeout: const Duration(seconds: 12),
|
|
||||||
receiveTimeout: const Duration(seconds: 20),
|
|
||||||
followRedirects: true,
|
|
||||||
responseType: ResponseType.plain,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
static final BaseOptions _dioOptions = BaseOptions(
|
||||||
|
connectTimeout: const Duration(seconds: 12),
|
||||||
|
receiveTimeout: const Duration(seconds: 20),
|
||||||
|
followRedirects: true,
|
||||||
|
responseType: ResponseType.plain,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Dio _dio;
|
||||||
|
final Future<PackageInfo> Function() _packageInfoProvider;
|
||||||
|
|
||||||
bool _checking = false;
|
bool _checking = false;
|
||||||
|
AppUpdateInfo? _availableUpdate;
|
||||||
|
|
||||||
|
AppUpdateInfo? get availableUpdate => _availableUpdate;
|
||||||
|
bool get hasUpdate => _availableUpdate != null;
|
||||||
|
|
||||||
Future<AppUpdateInfo?> checkForUpdate() async {
|
Future<AppUpdateInfo?> checkForUpdate() async {
|
||||||
if (_checking) return null;
|
if (_checking) return null;
|
||||||
if (!_supportsAutoDownload) return null;
|
|
||||||
|
|
||||||
_checking = true;
|
_checking = true;
|
||||||
try {
|
try {
|
||||||
final current = await PackageInfo.fromPlatform();
|
final current = await _packageInfoProvider();
|
||||||
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
||||||
if (latest == null) return null;
|
if (latest == null ||
|
||||||
if (_compareVersions(latest.version, current.version) <= 0) return null;
|
_compareVersions(latest.version, current.version) <= 0) {
|
||||||
|
_setAvailableUpdate(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
_setAvailableUpdate(latest);
|
||||||
return latest;
|
return latest;
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
||||||
@@ -54,6 +76,34 @@ class UpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> shouldShowPrompt(AppUpdateInfo update) async {
|
||||||
|
final storage = StorageService.instance;
|
||||||
|
final skipVersion = await storage.getString(
|
||||||
|
StorageKeys.updatePromptSkipVersion,
|
||||||
|
);
|
||||||
|
final skipUntil = await storage.getInt(StorageKeys.updatePromptSkipUntil);
|
||||||
|
if (skipVersion != update.version || skipUntil == null) return true;
|
||||||
|
|
||||||
|
final now = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
if (skipUntil > now) return false;
|
||||||
|
|
||||||
|
await storage.remove(StorageKeys.updatePromptSkipUntil);
|
||||||
|
await storage.remove(StorageKeys.updatePromptSkipVersion);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> skipPromptForFiveDays(AppUpdateInfo update) async {
|
||||||
|
final skipUntil = DateTime.now()
|
||||||
|
.add(const Duration(days: 5))
|
||||||
|
.millisecondsSinceEpoch;
|
||||||
|
final storage = StorageService.instance;
|
||||||
|
await storage.setString(
|
||||||
|
StorageKeys.updatePromptSkipVersion,
|
||||||
|
update.version,
|
||||||
|
);
|
||||||
|
await storage.setInt(StorageKeys.updatePromptSkipUntil, skipUntil);
|
||||||
|
}
|
||||||
|
|
||||||
Future<File?> downloadUpdate(
|
Future<File?> downloadUpdate(
|
||||||
AppUpdateInfo update, {
|
AppUpdateInfo update, {
|
||||||
void Function(int received, int total)? onProgress,
|
void Function(int received, int total)? onProgress,
|
||||||
@@ -96,6 +146,15 @@ class UpdateService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> openDownloadsPage() async {
|
||||||
|
if (!await launchUrl(
|
||||||
|
downloadsPageUrl,
|
||||||
|
mode: LaunchMode.externalApplication,
|
||||||
|
)) {
|
||||||
|
throw Exception('无法打开下载页面: $downloadsPageUrl');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
||||||
final response = await _dio.getUri<String>(releasesRssUrl);
|
final response = await _dio.getUri<String>(releasesRssUrl);
|
||||||
final body = response.data;
|
final body = response.data;
|
||||||
@@ -119,7 +178,6 @@ class UpdateService {
|
|||||||
final update = await _buildUpdateInfo(release);
|
final update = await _buildUpdateInfo(release);
|
||||||
if (update == null) continue;
|
if (update == null) continue;
|
||||||
|
|
||||||
if (update.downloadUrl == null) continue;
|
|
||||||
return update;
|
return update;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,8 +264,6 @@ class UpdateService {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
bool get _supportsAutoDownload => Platform.isAndroid || Platform.isWindows;
|
|
||||||
|
|
||||||
bool _isReleaseAttachment(Uri uri) {
|
bool _isReleaseAttachment(Uri uri) {
|
||||||
return uri.path.contains('/releases/download/');
|
return uri.path.contains('/releases/download/');
|
||||||
}
|
}
|
||||||
@@ -345,6 +401,16 @@ class UpdateService {
|
|||||||
return Uri.decodeComponent(segment);
|
return Uri.decodeComponent(segment);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _setAvailableUpdate(AppUpdateInfo? update) {
|
||||||
|
final previous = _availableUpdate;
|
||||||
|
if (previous?.version == update?.version &&
|
||||||
|
previous?.pageUrl == update?.pageUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_availableUpdate = update;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,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
|
||||||
|
|||||||
@@ -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>> {
|
||||||
|
|||||||
@@ -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}")
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -283,6 +283,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 分页响应 =====
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ impl SyncEngine {
|
|||||||
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).await.unwrap_or_default();
|
||||||
|
|||||||
@@ -78,6 +78,10 @@ impl SyncEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
RemoteFileEvent::Deleted { uri, name } => {
|
RemoteFileEvent::Deleted { uri, name } => {
|
||||||
|
// 清理过期抑制条目
|
||||||
|
let now = std::time::Instant::now();
|
||||||
|
self.suppress_paths.retain(|_, ts| now.duration_since(*ts).as_secs() < 30);
|
||||||
|
|
||||||
let relative = crate::diff::remote_relative_path(
|
let relative = crate::diff::remote_relative_path(
|
||||||
remote_root,
|
remote_root,
|
||||||
uri,
|
uri,
|
||||||
@@ -86,6 +90,13 @@ impl SyncEngine {
|
|||||||
);
|
);
|
||||||
tracing::info!("[远程事件] 删除: {}", relative);
|
tracing::info!("[远程事件] 删除: {}", relative);
|
||||||
|
|
||||||
|
// 被抑制的路径:上传失败清理远端碎片等场景,不应删除本地文件
|
||||||
|
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() {
|
if local_path.exists() {
|
||||||
if local_path.is_dir() {
|
if local_path.is_dir() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ pub struct Worker {
|
|||||||
ensured_dirs: Arc<DashMap<String, ()>>,
|
ensured_dirs: Arc<DashMap<String, ()>>,
|
||||||
conflict_resolver: ConflictResolver,
|
conflict_resolver: ConflictResolver,
|
||||||
event_sink: Arc<crate::event_sink::EventSink>,
|
event_sink: Arc<crate::event_sink::EventSink>,
|
||||||
|
/// 抑制路径:上传失败清理远端碎片时,防止 SSE 删除事件误删本地文件
|
||||||
|
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
|
||||||
shutdown_token: CancellationToken,
|
shutdown_token: CancellationToken,
|
||||||
#[cfg(feature = "windows-cfapi")]
|
#[cfg(feature = "windows-cfapi")]
|
||||||
platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
|
platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
|
||||||
@@ -55,6 +57,7 @@ impl Worker {
|
|||||||
ensured_dirs: Arc<DashMap<String, ()>>,
|
ensured_dirs: Arc<DashMap<String, ()>>,
|
||||||
conflict_resolver: ConflictResolver,
|
conflict_resolver: ConflictResolver,
|
||||||
event_sink: Arc<crate::event_sink::EventSink>,
|
event_sink: Arc<crate::event_sink::EventSink>,
|
||||||
|
suppress_paths: Arc<DashMap<String, std::time::Instant>>,
|
||||||
shutdown_token: CancellationToken,
|
shutdown_token: CancellationToken,
|
||||||
#[cfg(feature = "windows-cfapi")] platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
|
#[cfg(feature = "windows-cfapi")] platform_adapter: Option<Arc<dyn PlaceholderCreator>>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
@@ -69,6 +72,7 @@ impl Worker {
|
|||||||
ensured_dirs,
|
ensured_dirs,
|
||||||
conflict_resolver,
|
conflict_resolver,
|
||||||
event_sink,
|
event_sink,
|
||||||
|
suppress_paths,
|
||||||
shutdown_token,
|
shutdown_token,
|
||||||
#[cfg(feature = "windows-cfapi")]
|
#[cfg(feature = "windows-cfapi")]
|
||||||
platform_adapter,
|
platform_adapter,
|
||||||
@@ -783,6 +787,8 @@ impl Worker {
|
|||||||
Some(&e.to_string()),
|
Some(&e.to_string()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// 清理远端碎片:强制解锁 + 删除
|
||||||
|
self.cleanup_failed_upload(&rel_path).await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("[{}] 上传任务异常: {}: {}", tid, rel_path, e);
|
tracing::error!("[{}] 上传任务异常: {}: {}", tid, rel_path, e);
|
||||||
@@ -798,6 +804,8 @@ impl Worker {
|
|||||||
Some(&e.to_string()),
|
Some(&e.to_string()),
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
// 清理远端碎片:强制解锁 + 删除
|
||||||
|
self.cleanup_failed_upload(&rel_path).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1208,7 +1216,22 @@ impl Worker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 7. 删除远程文件(Full 始终删除;MirrorWcf 仅在 WcfDeleteMode::SyncRemote 时删除)
|
// 上传失败后清理远端碎片:删除远端可能已创建的部分文件
|
||||||
|
// delete_files 内部遇到锁冲突(40073)会自动解锁后重试
|
||||||
|
// 删除前先插入 suppress_paths,防止 SSE 删除事件误删本地文件
|
||||||
|
async fn cleanup_failed_upload(&self, relative_path: &str) {
|
||||||
|
let remote_uri = format!("{}/{}", self.config.remote_root, relative_path);
|
||||||
|
let tid = &self.task_id;
|
||||||
|
|
||||||
|
// 先标记抑制,30s 内 SSE 删除事件不会删本地文件
|
||||||
|
self.suppress_paths.insert(relative_path.to_string(), std::time::Instant::now());
|
||||||
|
|
||||||
|
match self.api.delete_files(&[&remote_uri]).await {
|
||||||
|
Ok(_) => tracing::info!("[{}] 上传失败清理-已删除远端碎片: {}", tid, remote_uri),
|
||||||
|
Err(e) => tracing::debug!("[{}] 上传失败清理-删除失败(可能不存在): {}", tid, e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn step_delete_remote(&self, summary: &mut SyncSummary) {
|
async fn step_delete_remote(&self, summary: &mut SyncSummary) {
|
||||||
let should_delete = matches!(self.config.sync_mode, SyncMode::Full)
|
let should_delete = matches!(self.config.sync_mode, SyncMode::Full)
|
||||||
|| (matches!(self.config.sync_mode, SyncMode::MirrorWcf)
|
|| (matches!(self.config.sync_mode, SyncMode::MirrorWcf)
|
||||||
|
|||||||
+73
-1
@@ -298,7 +298,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.3"
|
version: "1.3.3"
|
||||||
ffi:
|
ffi:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
|
||||||
@@ -390,6 +390,78 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.0"
|
version: "0.7.0"
|
||||||
|
flutter_inappwebview:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview
|
||||||
|
sha256: "3952d116ee93bad2946401377e7ade87b5ef200e95ecb5ba1affa1b6329a6867"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "6.2.0-beta.3"
|
||||||
|
flutter_inappwebview_android:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_android
|
||||||
|
sha256: "8dfb76bd4e507112c3942c2272eeb01fab2e42be11374e5eb226f58698e7a04b"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0-beta.3"
|
||||||
|
flutter_inappwebview_internal_annotations:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_internal_annotations
|
||||||
|
sha256: e30fba942e3debea7b7e6cdd4f0f59ce89dd403a9865193e3221293b6d1544c6
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.3.0"
|
||||||
|
flutter_inappwebview_ios:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_ios
|
||||||
|
sha256: ae8a78829398771be863aa3c8804a9d40728e1815e66c9c966f86d2cc3ae4fd9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0-beta.3"
|
||||||
|
flutter_inappwebview_linux:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_linux
|
||||||
|
sha256: "2e1a3b09bb911fb5a8bb155cb7f1eb1428a19b6e20363b9db48beef428b8cef5"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.1.0-beta.1"
|
||||||
|
flutter_inappwebview_macos:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_macos
|
||||||
|
sha256: "545148cb5c46475ce669ab21621e9f2ad66e05f8e80b2cf49d4018879ab52393"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0-beta.3"
|
||||||
|
flutter_inappwebview_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_platform_interface
|
||||||
|
sha256: e3522c76e6760d1c0a9ff690e30e1503f226783d3277fa4d26675911977e9766
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.4.0-beta.3"
|
||||||
|
flutter_inappwebview_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_web
|
||||||
|
sha256: e98b8875ccb6a3fd255873318db45c18ab135ed0ed22d20169abad9f5c810eb9
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "1.2.0-beta.3"
|
||||||
|
flutter_inappwebview_windows:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: flutter_inappwebview_windows
|
||||||
|
sha256: "902edd6f6326952af822e21aa928f7426d723d45c94c15e6ce3c2d5640d28ad7"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.0-beta.3"
|
||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
|||||||
+3
-1
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.3.2+3
|
version: 1.3.5+5
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.11.4
|
sdk: ^3.11.4
|
||||||
@@ -94,6 +94,8 @@ dependencies:
|
|||||||
qr_flutter: ^4.1.0
|
qr_flutter: ^4.1.0
|
||||||
webview_flutter: ^4.13.0
|
webview_flutter: ^4.13.0
|
||||||
xml: ^6.6.1
|
xml: ^6.6.1
|
||||||
|
flutter_inappwebview: ^6.2.0-beta.3
|
||||||
|
ffi: ^2.1.0
|
||||||
|
|
||||||
# 桌面端
|
# 桌面端
|
||||||
window_manager: ^0.5.0
|
window_manager: ^0.5.0
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import 'package:cloudreve4_flutter/services/update_service.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('UpdateService', () {
|
||||||
|
test(
|
||||||
|
'checkForUpdate releases checking flag when package info fails',
|
||||||
|
() async {
|
||||||
|
var calls = 0;
|
||||||
|
final service = UpdateService.test(
|
||||||
|
packageInfoProvider: () async {
|
||||||
|
calls += 1;
|
||||||
|
throw Exception('package info unavailable');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(await service.checkForUpdate(), isNull);
|
||||||
|
expect(await service.checkForUpdate(), isNull);
|
||||||
|
expect(calls, 2);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -32,6 +32,56 @@ set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}")
|
|||||||
# Use Unicode for all projects.
|
# Use Unicode for all projects.
|
||||||
add_definitions(-DUNICODE -D_UNICODE)
|
add_definitions(-DUNICODE -D_UNICODE)
|
||||||
|
|
||||||
|
# flutter_inappwebview_windows requires NuGet. The plugin's own CMake only
|
||||||
|
# calls find_program(NUGET nuget), so seed the cache with an absolute path.
|
||||||
|
if(DEFINED NUGET AND NOT NUGET STREQUAL "" AND NOT NUGET STREQUAL "NUGET-NOTFOUND")
|
||||||
|
if(NOT EXISTS "${NUGET}")
|
||||||
|
unset(NUGET CACHE)
|
||||||
|
unset(NUGET)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT DEFINED NUGET OR NUGET STREQUAL "" OR NUGET STREQUAL "NUGET-NOTFOUND")
|
||||||
|
set(_NUGET_CANDIDATES
|
||||||
|
"$ENV{SystemRoot}/System32/nuget.exe"
|
||||||
|
"$ENV{windir}/System32/nuget.exe"
|
||||||
|
"C:/Windows/System32/nuget.exe"
|
||||||
|
"$ENV{SystemRoot}/Sysnative/nuget.exe"
|
||||||
|
"$ENV{windir}/Sysnative/nuget.exe"
|
||||||
|
"C:/Windows/Sysnative/nuget.exe"
|
||||||
|
"$ENV{USERPROFILE}/.local/bin/nuget.exe"
|
||||||
|
"$ENV{LOCALAPPDATA}/Microsoft/WinGet/Links/nuget.exe"
|
||||||
|
"C:/ProgramData/chocolatey/bin/nuget.exe"
|
||||||
|
)
|
||||||
|
foreach(_NUGET_CANDIDATE IN LISTS _NUGET_CANDIDATES)
|
||||||
|
if(EXISTS "${_NUGET_CANDIDATE}")
|
||||||
|
set(NUGET "${_NUGET_CANDIDATE}")
|
||||||
|
break()
|
||||||
|
endif()
|
||||||
|
endforeach()
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NOT DEFINED NUGET OR NUGET STREQUAL "" OR NUGET STREQUAL "NUGET-NOTFOUND")
|
||||||
|
find_program(NUGET NAMES nuget.exe nuget)
|
||||||
|
endif()
|
||||||
|
|
||||||
|
if(NUGET AND NOT NUGET STREQUAL "NUGET-NOTFOUND")
|
||||||
|
set(_PROJECT_NUGET "${CMAKE_BINARY_DIR}/nuget.exe")
|
||||||
|
execute_process(
|
||||||
|
COMMAND "${CMAKE_COMMAND}" -E copy_if_different "${NUGET}" "${_PROJECT_NUGET}"
|
||||||
|
RESULT_VARIABLE _NUGET_COPY_RESULT
|
||||||
|
)
|
||||||
|
if(_NUGET_COPY_RESULT EQUAL 0 AND EXISTS "${_PROJECT_NUGET}")
|
||||||
|
set(NUGET "${_PROJECT_NUGET}")
|
||||||
|
else()
|
||||||
|
message(WARNING "Failed to copy NuGet to ${_PROJECT_NUGET}; using ${NUGET}")
|
||||||
|
endif()
|
||||||
|
message(STATUS "Found NuGet: ${NUGET}")
|
||||||
|
set(NUGET "${NUGET}" CACHE FILEPATH "nuget executable" FORCE)
|
||||||
|
else()
|
||||||
|
message(WARNING "NuGet executable was not found. Windows WebView builds may fail.")
|
||||||
|
endif()
|
||||||
|
|
||||||
# Compilation settings that should be applied to most targets.
|
# Compilation settings that should be applied to most targets.
|
||||||
#
|
#
|
||||||
# Be cautious about adding new options here, as plugins use this function by
|
# Be cautious about adding new options here, as plugins use this function by
|
||||||
@@ -110,8 +160,20 @@ ENSURE_WINDOWS_ARCHIVE(
|
|||||||
|
|
||||||
# Generated plugin build rules, which manage building the plugins and adding
|
# Generated plugin build rules, which manage building the plugins and adding
|
||||||
# them to the application.
|
# them to the application.
|
||||||
|
if(POLICY CMP0175)
|
||||||
|
# flutter_inappwebview_windows 0.7.0-beta.3 passes DEPENDS to the TARGET
|
||||||
|
# form of add_custom_command. Keep older CMake behavior for plugins that do
|
||||||
|
# not set this policy themselves.
|
||||||
|
set(CMAKE_POLICY_DEFAULT_CMP0175 OLD)
|
||||||
|
endif()
|
||||||
include(flutter/generated_plugins.cmake)
|
include(flutter/generated_plugins.cmake)
|
||||||
|
|
||||||
|
if(MSVC)
|
||||||
|
if(TARGET flutter_inappwebview_windows_plugin)
|
||||||
|
# flutter_inappwebview 插件有很多 MSVC 警告, 屏蔽该插件不输出任何警告
|
||||||
|
target_compile_options(flutter_inappwebview_windows_plugin PRIVATE /W0)
|
||||||
|
endif()
|
||||||
|
endif()
|
||||||
|
|
||||||
# === Installation ===
|
# === Installation ===
|
||||||
# Support files are copied into place next to the executable, so that it can
|
# Support files are copied into place next to the executable, so that it can
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
#include <desktop_drop/desktop_drop_plugin.h>
|
#include <desktop_drop/desktop_drop_plugin.h>
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
#include <flutter_acrylic/flutter_acrylic_plugin.h>
|
#include <flutter_acrylic/flutter_acrylic_plugin.h>
|
||||||
|
#include <flutter_inappwebview_windows/flutter_inappwebview_windows_plugin_c_api.h>
|
||||||
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
#include <media_kit_libs_windows_video/media_kit_libs_windows_video_plugin_c_api.h>
|
||||||
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
#include <media_kit_video/media_kit_video_plugin_c_api.h>
|
||||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||||
@@ -24,6 +25,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
FlutterAcrylicPluginRegisterWithRegistrar(
|
FlutterAcrylicPluginRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FlutterAcrylicPlugin"));
|
registry->GetRegistrarForPlugin("FlutterAcrylicPlugin"));
|
||||||
|
FlutterInappwebviewWindowsPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("FlutterInappwebviewWindowsPluginCApi"));
|
||||||
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
|
MediaKitLibsWindowsVideoPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
registry->GetRegistrarForPlugin("MediaKitLibsWindowsVideoPluginCApi"));
|
||||||
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
MediaKitVideoPluginCApiRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
desktop_drop
|
desktop_drop
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
flutter_acrylic
|
flutter_acrylic
|
||||||
|
flutter_inappwebview_windows
|
||||||
media_kit_libs_windows_video
|
media_kit_libs_windows_video
|
||||||
media_kit_video
|
media_kit_video
|
||||||
permission_handler_windows
|
permission_handler_windows
|
||||||
|
|||||||
Reference in New Issue
Block a user