Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 526990a01a | |||
|
db10873aaf
|
|||
|
e6c60a9d6d
|
|||
|
33509de4e3
|
|||
|
42db3f9683
|
|||
|
eef561796a
|
|||
|
b02daf1448
|
|||
| 3b94f40def | |||
| edcde96051 | |||
|
5ee6ba1e28
|
|||
|
61ad85f6fc
|
|||
|
8dfc22691a
|
|||
| c583c51d80 | |||
|
4d6ca139e5
|
|||
|
81864c99c2
|
|||
|
fb5fd6c9bc
|
|||
|
c208a06af5
|
|||
|
c2db8b38e9
|
|||
|
24d20cbfcb
|
|||
|
12f2c2660e
|
|||
|
98af110531
|
|||
| 886046a72b | |||
| d20f7790e3 | |||
|
17673b2862
|
|||
|
546cef5ba6
|
|||
|
39d8361080
|
@@ -12,6 +12,10 @@ permissions:
|
||||
contents: write
|
||||
releases: write
|
||||
|
||||
env:
|
||||
PUB_HOSTED_URL: https://pub.flutter-io.cn
|
||||
FLUTTER_STORAGE_BASE_URL: https://storage.flutter-io.cn
|
||||
|
||||
jobs:
|
||||
android:
|
||||
name: Build Android APK
|
||||
@@ -113,7 +117,7 @@ jobs:
|
||||
flutter doctor -v
|
||||
|
||||
- name: Install dependencies
|
||||
run: flutter pub get
|
||||
run: flutter pub get --enforce-lockfile
|
||||
|
||||
- name: Restore Android signing files
|
||||
shell: bash
|
||||
@@ -140,10 +144,12 @@ jobs:
|
||||
|
||||
- name: Build Android release APK
|
||||
shell: bash
|
||||
env:
|
||||
IP_NODE_API_BASE_URL: ${{ secrets.IP_NODE_API_BASE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
rm -rf build dist .dart_tool android/.gradle
|
||||
rm -rf build dist android/.gradle
|
||||
|
||||
pkg_dir="$PWD/dist/android"
|
||||
if [ -d "$pkg_dir" ]; then
|
||||
@@ -151,8 +157,13 @@ jobs:
|
||||
rm -rf "$pkg_dir"
|
||||
fi
|
||||
|
||||
build_args=(--release --no-pub)
|
||||
if [ -n "${IP_NODE_API_BASE_URL:-}" ]; then
|
||||
build_args+=("--dart-define=IP_NODE_API_BASE_URL=${IP_NODE_API_BASE_URL}")
|
||||
fi
|
||||
|
||||
echo "Building Flutter Android release APK..."
|
||||
flutter build apk -v --release
|
||||
flutter build apk "${build_args[@]}"
|
||||
|
||||
mkdir -p "$pkg_dir"
|
||||
apk_path="$(find "$PWD/build/app/outputs" -type f -name '*release*.apk' | head -n 1)"
|
||||
@@ -191,7 +202,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$GITHUB_REF" == refs/tags/android-app-v* ]]; then
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
tag_name="${GITHUB_REF#refs/tags/}"
|
||||
else
|
||||
app_version="$(awk -F '[:+]' '/^version:/ {gsub(/[[:space:]]/, "", $2); print $2; exit}' pubspec.yaml)"
|
||||
@@ -199,7 +210,7 @@ jobs:
|
||||
echo "Unable to resolve app version from pubspec.yaml."
|
||||
exit 1
|
||||
fi
|
||||
tag_name="android-app-v${app_version}"
|
||||
tag_name="v${app_version}"
|
||||
fi
|
||||
|
||||
echo "tag_name=$tag_name" >> "$GITHUB_OUTPUT"
|
||||
@@ -249,7 +260,7 @@ jobs:
|
||||
|
||||
releases_url="$GITEA_API_URL/repos/$GITEA_OWNER/$GITEA_REPO/releases"
|
||||
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}."
|
||||
|
||||
status="$(curl -sS -o release.json -w "%{http_code}" \
|
||||
@@ -265,7 +276,7 @@ jobs:
|
||||
print(json.dumps({
|
||||
"tag_name": tag,
|
||||
"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}.",
|
||||
"draft": False,
|
||||
"prerelease": False,
|
||||
@@ -353,7 +364,7 @@ jobs:
|
||||
fi
|
||||
done < asset-ids-to-delete.txt
|
||||
|
||||
release_asset_name="gongyun-${TAG_NAME}.apk"
|
||||
release_asset_name="app-release-${TAG_NAME}.apk"
|
||||
curl -sS -f \
|
||||
-X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
|
||||
+12
@@ -21,6 +21,7 @@ migrate_working_dir/
|
||||
.frontend/
|
||||
|
||||
# custom
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
claude_project.md
|
||||
PROJECT_REQUIREMENTS.md
|
||||
@@ -28,6 +29,10 @@ DESIGN_OVERVIEW.md
|
||||
DESIGN.md
|
||||
refactory.md
|
||||
DESIGN-copy.md
|
||||
todo.md
|
||||
feat-2315.md
|
||||
*.txt
|
||||
*.json
|
||||
*.jsonl
|
||||
example.dart
|
||||
install.sh
|
||||
@@ -58,6 +63,13 @@ lib/firebase_options.dart
|
||||
*secret*.json
|
||||
*secrets*.json
|
||||
*.local.json
|
||||
native/target
|
||||
native/logs
|
||||
sync_refactory.md
|
||||
linux-fuse.md
|
||||
tools/*
|
||||
*.diff
|
||||
android/app/src/main/jniLibs/*
|
||||
|
||||
# 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
|
||||
|
||||
@@ -1,3 +1,208 @@
|
||||
# app
|
||||
|
||||
🚀 基于 Flutter 的 公云存储 官方客户端,提供移动端/PC便捷管理体验
|
||||
[](https://flutter.dev)
|
||||
[](https://dart.dev)
|
||||
[](https://www.gnu.org/licenses/agpl-3.0)
|
||||
|
||||
> 🚀 基于 Flutter 的 公云存储 官方客户端,提供移动端/PC便捷管理体验
|
||||
|
||||
---
|
||||
|
||||
## 📖 项目简介
|
||||
|
||||
功能丰富的云存储客户端,支持文件上传、下载、管理和分享。针对 公云存储 API 进行了基本完整适配和优化。
|
||||
|
||||
### ✨ 特性亮点
|
||||
|
||||
- 🎯 现支持的功能基本完整适配 公云存储 API
|
||||
- 📱 跨平台支持(Android / Linux / Windows / Web)
|
||||
- 🎨 现代化 Material Design 3 界面
|
||||
- ⚡ 支持断点续传和下载进度监听
|
||||
- 🔐 安全的 Token 认证机制
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 技术栈
|
||||
|
||||
| 组件 | 版本 |
|
||||
|------|------|
|
||||
| Flutter | 3.41.6 |
|
||||
| Dart | 3.11.4 |
|
||||
| 后端 API | 公云存储 |
|
||||
|
||||
**开发环境详情:**
|
||||
|
||||
```
|
||||
Debia
|
||||
Flutter 3.41.6 • channel stable
|
||||
Framework • revision db50e20168 • 2026-03-25
|
||||
Dart 3.11.4 • DevTools 2.54.2
|
||||
```
|
||||
|
||||
**构建环境详情:**
|
||||
|
||||
```
|
||||
Android: compileAPI: 36, targetAPI: 36, miniAPI: 34
|
||||
Windows: 11
|
||||
Linux: Debian 12
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 功能状态
|
||||
|
||||
### ✅ 基础功能
|
||||
|
||||
| 功能模块 | 状态 | 说明 |
|
||||
|--------|------|----------------------------------------|
|
||||
| 用户登录 | ✅ | 自定义服务器, Token 认证、持久化 |
|
||||
| 文件列表 | ✅ | 列表/网格双视图 |
|
||||
| 刷新列表 | ✅ | 增量更新 |
|
||||
| 全屏手势 | ✅ | 右侧左滑返回上级目录, 根目录提示退出 |
|
||||
| 文件下载 | ✅ | 原生/浏览器双实现、进度监听、断点续传、后台下载 |
|
||||
| 文件上传 | ✅ | 进度展示、分片上传<服务端需要开启> Windows/Linux支持拖拽上传 |
|
||||
| 删除文件 | ✅ | 删除文件 |
|
||||
| 重命名 | ✅ | 重命名文件 |
|
||||
| 移动复制 | ✅ | 移动/复制文件, 添加文件夹选择器对话框 |
|
||||
| 我的分享 | ✅ | 完整的分享功能, 包括创建,删除,管理列表,编辑等 |
|
||||
| 找回密码 | ✅ | 使用邮箱找回密码 (依赖控制台STMP可用性) |
|
||||
| 用户注册 | ✅ | 使用邮箱注册新用户 (依赖控制台允许注册新用户) |
|
||||
| 回收站 | ✅ | 文件恢复/彻底删除 |
|
||||
| WebDav | ✅ | 增删改查(硬编码查50条) |
|
||||
| 文件搜索 | ✅ | 全局搜索功能, 点击跳转到对应目录 |
|
||||
| 设置页面 | ✅ | 增加多个实用的设置项 |
|
||||
| 离线下载 | ✅ | 离线下载(依赖服务端aria2可用) |
|
||||
| 缩略图 | ✅ | 网格布局缩略图懒加载支持 |
|
||||
|
||||
> 文件下载
|
||||
> 原生/浏览器双实现原因:
|
||||
>
|
||||
> > ~~选型使用了 `flutter_downloader` 来实现Android后台下载, 避免切换后台下载异常, 但这玩意儿不支持跨平台, 所以PC端就实现了获取文件url地址在浏览器打开进行下载; 正常应该选用 `background_downloader`~~
|
||||
>
|
||||
> 文件上传
|
||||
>
|
||||
> > 看后端接口文档, 必须要按分片顺序上传, 看着是不支持多分片并发上传, 差点意思, 效率不高.
|
||||
|
||||
------
|
||||
|
||||
### ✅ 设置页面
|
||||
|
||||
| 功能模块 | 状态 | 说明 |
|
||||
|------|----|-------------------------------------|
|
||||
| 个人资料 | ✅ | 修改昵称和头像 |
|
||||
| 安全设置 | ✅ | 修改密码/2FA等 |
|
||||
| 快捷入口 | ✅ | 概览页快捷入口设置, 默认4个, 支持新增修改和调整顺序 |
|
||||
| 文件偏好 | ✅ | 历史版本开关, 视图同步, 个人主页分享链接可见性 |
|
||||
| 应用设置 | ✅ | 深色模式/主题/语言/gravatar镜像/下载设置/缓存/日志管理等 |
|
||||
| 关于 | ✅ | APP信息 |
|
||||
|
||||
-----
|
||||
|
||||
### ✅ 预览模块
|
||||
|
||||
| 功能模块 | 状态 | 说明 |
|
||||
|----------|------|------|
|
||||
| 图片预览 | ✅ | 全平台支持, win/linux 支持CTRL+鼠标滚轮缩放,双击恢复,平滑动画 |
|
||||
| PDF预览 | ✅ | 全平台支持, 支持缩放, 选中文字复制等 |
|
||||
| 音频预览 | ✅ | 全平台支持流式播放, 算好看的播放器UI, 进度条, 暂停, 快进/退10秒 |
|
||||
| 视频预览 | ✅ | 全平台支持流式播放, 暂停, 调整音量, 全屏, 增加倍速支持 |
|
||||
| 文本预览 | ✅ | 全平台支持, 189中语言代码高亮, SourceCodePro等宽字体, 一键复制 |
|
||||
| MD预览 | ✅ | 全平台支持, 类github风格, TOC, 暗色模式支持.(dark缺陷) |
|
||||
|
||||
> 音视频预览库底层是 mpv 提供编解码能力, 理论上 mpv 支持的格式均支持, 具体没有实测;
|
||||
>
|
||||
> (待改进)视频预览进度条内嵌: MaterialVideoControlsTheme -> MaterialVideoControls
|
||||
>
|
||||
> 文本预览现在是一次性渲染, 大文件会有性能问题, 如果借用listview来优化, 会丢失代码高亮, 暂时保持现阶段的情况, 另外应该也没啥大文本文件预览的场景
|
||||
|
||||
### 🚧 开发中
|
||||
|
||||
| 功能模块 | 进度 | 说明 |
|
||||
|---------------|------|-----------------------------|
|
||||
| ~~文件预览~~ | ✅ | 图片/文档/视频等预览(核心功能基本完成) |
|
||||
| ~~设置页面~~ | ✅ | 用户信息, 2FA等 |
|
||||
| ~~桌面端托盘~~ | ✅ | 桌面端托盘 |
|
||||
| ~~桌面端原生下载~~ | ✅ | 统一为 `background_downloader` |
|
||||
| ~~我的页面~~ | ✅ | 我的页面 |
|
||||
| ~~批量C&M~~ | ✅ | 批量移动/复制 |
|
||||
| ~~桌面端支持拖拽上传~~ | ✅ | windows/linux 支持拖拽上传到当前文件夹 |
|
||||
|
||||
### 📝 待优化
|
||||
|
||||
- [x] SnackBar 样式美化 (`oktoast`)
|
||||
- [x] 错误/提示优化 (重构所有SnackBar为okToast)
|
||||
- [x] 重构所有开发过程中的`debugPrint`为 `logger` 库
|
||||
- [x] Windows/Linux平台使用`background_downloader`替代`flutter_downloader`下载, 增加下载速度显示
|
||||
- [x] ListView 似乎还是完整重绘, 上传插值似乎也还是在完整重绘
|
||||
|
||||
|
||||
### 🚧 待重构
|
||||
| 功能模块 | 进度 | 说明 |
|
||||
|-------------------------|----|---------------------------------------------------------------------------|
|
||||
| UI | ✅ | windows/linux/Android phone&pad 完整ui重构 |
|
||||
| `background_downloader` | ✅ | flutter_downloader -> background_downloader 全平台统一下载管理, 支持后台, 断点续传, 自动恢复等 |
|
||||
| 搜索 | ✅ | 移除旧搜索, 实现新版本支持实时搜索, 搜索历史, 搜索防抖; 优化搜索结果点击跳转 |
|
||||
| 拖拽上传 | ✅ | Windows/Linux支持拖拽上传 |
|
||||
|
||||
---
|
||||
|
||||
## 🚀 快速开始
|
||||
|
||||
### 环境要求
|
||||
|
||||
- Flutter SDK >= 3.41.6
|
||||
- Dart SDK >= 3.11.4
|
||||
- 公云存储 后端服务
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
如果当前网络无法解析 `pub.dev`,先设置 Pub/Flutter 镜像:
|
||||
|
||||
```powershell
|
||||
$env:PUB_HOSTED_URL="https://pub.flutter-io.cn"
|
||||
$env:FLUTTER_STORAGE_BASE_URL="https://storage.flutter-io.cn"
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
### 运行项目
|
||||
|
||||
```bash
|
||||
flutter run # pdf 和 音视频会再构建过程中下载github上的依赖,自行解决网络问题
|
||||
```
|
||||
|
||||
### 构建发布
|
||||
|
||||
```bash
|
||||
# Android
|
||||
flutter build apk --release --no-pub
|
||||
# Linux
|
||||
flutter build -d linux --release --no-pub
|
||||
# windows
|
||||
flutter build -d windows --release --no-pub
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📬 联系方式
|
||||
|
||||
- 📧 问题反馈:提交 Issue
|
||||
- 💬 讨论交流:无
|
||||
|
||||
---
|
||||
|
||||
## ⚖️ 开源协议 (License)
|
||||
|
||||
本项目采用 **AGPL-3.0 (GNU Affero General Public License v3.0)** 协议开源。
|
||||
|
||||
### 核心约束:
|
||||
1. **传染性**:如果你修改了本项目代码并重新发布,你的项目也必须以 AGPL-3.0 协议开源。
|
||||
2. **云端公开声明**:如果你在服务器/云真机等上运行本项目并向公众提供网络服务(网盘服务),你必须向用户公开你所使用的源代码(包括任何修改)。
|
||||
3. **禁止闭源商业化**:未经授权,禁止将本项目代码闭源后作为商业产品销售。
|
||||
|
||||
详情请参阅项目根目录下的 [LICENSE](./LICENSE) 文件。
|
||||
|
||||
---
|
||||
|
||||
@@ -30,6 +30,14 @@ if (keystorePropertiesFile.exists()) {
|
||||
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 {
|
||||
namespace = "com.limo.cloudreve4_flutter"
|
||||
compileSdk = getLocalProperty("flutter.compileSdkVersion", 36)
|
||||
@@ -38,10 +46,12 @@ android {
|
||||
// 2. 配置签名选项
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
keyAlias = keystoreProperties["keyAlias"] as String?
|
||||
keyPassword = keystoreProperties["keyPassword"] as String?
|
||||
storeFile = keystoreProperties["storeFile"]?.let { file(it) }
|
||||
storePassword = keystoreProperties["storePassword"] as String?
|
||||
if (hasReleaseSigning) {
|
||||
keyAlias = keystoreProperties.getProperty("keyAlias")
|
||||
keyPassword = keystoreProperties.getProperty("keyPassword")
|
||||
storeFile = releaseStoreFile
|
||||
storePassword = keystoreProperties.getProperty("storePassword")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,15 +64,35 @@ android {
|
||||
jvmTarget = JavaVersion.VERSION_17.toString()
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("src/main/jniLibs")
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
|
||||
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.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = getLocalProperty("flutter.minSdkVersion", 24)
|
||||
targetSdk = getLocalProperty("flutter.targetSdkVersion", 35)
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
|
||||
ndk {
|
||||
// abiFilters.addAll(listOf("arm64-v8a", "armeabi-v7a"))
|
||||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = true
|
||||
reset()
|
||||
include("armeabi-v7a", "arm64-v8a", "x86_64")
|
||||
// include("armeabi-v7a", "arm64-v8a")
|
||||
isUniversalApk = true
|
||||
}
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -76,7 +106,7 @@ android {
|
||||
buildTypes {
|
||||
release {
|
||||
// 1. 将原来的 getByName("debug") 替换为 getByName("release")
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
signingConfig = signingConfigs.getByName(if (hasReleaseSigning) "release" else "debug")
|
||||
|
||||
// 2. 建议同时开启混淆,这能大幅减小网盘应用的体积并保护代码
|
||||
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
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
/// 排序字段
|
||||
enum SortField {
|
||||
name('名称', 'name'),
|
||||
size('大小', 'size'),
|
||||
updatedAt('修改时间', 'updated_at'),
|
||||
createdAt('创建时间', 'created_at');
|
||||
|
||||
final String label;
|
||||
final String apiKey;
|
||||
const SortField(this.label, this.apiKey);
|
||||
}
|
||||
|
||||
/// 排序方向
|
||||
enum SortDirection {
|
||||
asc('升序', 'asc'),
|
||||
desc('降序', 'desc');
|
||||
|
||||
final String label;
|
||||
final String apiKey;
|
||||
const SortDirection(this.label, this.apiKey);
|
||||
}
|
||||
|
||||
/// 排序选项
|
||||
class SortOption {
|
||||
final SortField field;
|
||||
final SortDirection direction;
|
||||
|
||||
const SortOption(this.field, this.direction);
|
||||
|
||||
static const default_ = SortOption(SortField.name, SortDirection.asc);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SortOption &&
|
||||
field == other.field &&
|
||||
direction == other.direction;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(field, direction);
|
||||
|
||||
/// 持久化字符串,格式: "name_asc"
|
||||
String toKey() => '${field.apiKey}_${direction.apiKey}';
|
||||
|
||||
/// 从持久化字符串恢复
|
||||
static SortOption fromKey(String? key) {
|
||||
if (key == null) return default_;
|
||||
final parts = key.split('_');
|
||||
if (parts.length != 2) return default_;
|
||||
final field = SortField.values
|
||||
.where((f) => f.apiKey == parts[0])
|
||||
.firstOrNull;
|
||||
final dir = SortDirection.values
|
||||
.where((d) => d.apiKey == parts[1])
|
||||
.firstOrNull;
|
||||
if (field == null || dir == null) return default_;
|
||||
return SortOption(field, dir);
|
||||
}
|
||||
|
||||
/// 生成菜单项显示文本
|
||||
String get menuLabel {
|
||||
final dirLabel = switch (field) {
|
||||
SortField.name => direction == SortDirection.asc ? 'A→Z' : 'Z→A',
|
||||
SortField.size => direction == SortDirection.asc ? '小→大' : '大→小',
|
||||
SortField.updatedAt => direction == SortDirection.asc ? '旧→新' : '新→旧',
|
||||
SortField.createdAt => direction == SortDirection.asc ? '旧→新' : '新→旧',
|
||||
};
|
||||
return '${field.label} $dirLabel';
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ class StorageKeys {
|
||||
static const String downloadTasks = 'download_tasks';
|
||||
static const String downloadWifiOnly = 'download_wifi_only';
|
||||
static const String downloadRetries = 'download_retries';
|
||||
static const String selectedCustomLineNode = 'selected_custom_line_node';
|
||||
|
||||
// 任务记录
|
||||
static const String taskRetentionDays = 'task_retention_days';
|
||||
@@ -27,4 +28,24 @@ class StorageKeys {
|
||||
|
||||
// 搜索历史
|
||||
static const String searchHistory = 'search_history';
|
||||
|
||||
// 同步相关
|
||||
static const String syncConfig = 'sync_config';
|
||||
static const String syncState = 'sync_state';
|
||||
static const String syncCumStats = 'sync_cum_stats';
|
||||
static const String clientId = 'client_id';
|
||||
|
||||
// 文件排序
|
||||
static const String fileSortOption = 'file_sort_option';
|
||||
|
||||
// 日志级别
|
||||
static const String logLevel = 'app_log_level';
|
||||
|
||||
// 公告
|
||||
static const String siteAnnouncementDismissedFingerprint =
|
||||
'site_announcement_dismissed_fingerprint';
|
||||
|
||||
// 版本更新
|
||||
static const String updatePromptSkipUntil = 'update_prompt_skip_until';
|
||||
static const String updatePromptSkipVersion = 'update_prompt_skip_version';
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'dart:io';
|
||||
import 'package:external_path/external_path.dart';
|
||||
|
||||
class SyncDefaults {
|
||||
SyncDefaults._();
|
||||
|
||||
/// 默认同步目录(同步版本,Android 返回空串,需用 getDefaultLocalRoot 异步获取)
|
||||
static String defaultLocalRoot() {
|
||||
if (Platform.isWindows) {
|
||||
return '${Platform.environment['USERPROFILE']}\\Documents\\Cloudreve4';
|
||||
} else if (Platform.isLinux) {
|
||||
final xdgDownload =
|
||||
Platform.environment['XDG_DOWNLOAD_DIR'] ??
|
||||
("${Platform.environment['HOME'] ?? ''}/Downloads");
|
||||
return '$xdgDownload/Cloudreve4';
|
||||
} else if (Platform.isAndroid) {
|
||||
return '';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// 默认同步目录(异步版本,Android 通过 ExternalPath 获取公共目录)
|
||||
static Future<String> getDefaultLocalRoot() async {
|
||||
if (Platform.isAndroid) {
|
||||
return getDefaultAndroidLocalRoot();
|
||||
}
|
||||
return defaultLocalRoot();
|
||||
}
|
||||
|
||||
static const String defaultRemoteRoot = 'cloudreve://my';
|
||||
static const String defaultSyncMode = 'full';
|
||||
static const String defaultConflictStrategy = 'keep_both';
|
||||
static const int defaultMaxConcurrentTransfers = 3;
|
||||
static const int defaultBandwidthLimitKbps = 0;
|
||||
static const int defaultMaxWorkers = 0; // 0 = CPU 核心数
|
||||
static const String defaultLogLevel = 'info';
|
||||
|
||||
// ===== Android 相册同步专用 =====
|
||||
static String get defaultAndroidLocalRoot {
|
||||
// runtime 获取 DCIM 公共目录,再拼接 /Camera
|
||||
// ExternalPath 无法 const,使用 getter 延迟求值
|
||||
throw UnsupportedError('Use getDefaultAndroidLocalRoot() instead');
|
||||
}
|
||||
|
||||
static Future<String> getDefaultAndroidLocalRoot() async {
|
||||
final dcim = await ExternalPath.getExternalStoragePublicDirectory(
|
||||
ExternalPath.DIRECTORY_DCIM,
|
||||
);
|
||||
return '$dcim/Camera';
|
||||
}
|
||||
|
||||
static const String defaultAndroidRemoteRoot = 'cloudreve://my/DCIM/Camera';
|
||||
static const String defaultAndroidSyncMode = 'album_upload';
|
||||
}
|
||||
@@ -10,6 +10,10 @@ class AppLogger {
|
||||
static Logger? _logger;
|
||||
static File? _logFile;
|
||||
|
||||
/// 当前日志级别(默认 info,debug 模式下也是 info 避免刷屏)
|
||||
static Level _level = Level.info;
|
||||
static Level get level => _level;
|
||||
|
||||
/// 初始化日志,必须在 main 中 await
|
||||
static Future<void> init() async {
|
||||
if (_logger != null) return;
|
||||
@@ -22,6 +26,10 @@ class AppLogger {
|
||||
}
|
||||
_logFile = File(p.join(logDir.path, 'log.txt'));
|
||||
|
||||
_createLogger();
|
||||
}
|
||||
|
||||
static void _createLogger() {
|
||||
// 2. 配置多路输出:同时输出到控制台和文件
|
||||
_logger = Logger(
|
||||
printer: PrettyPrinter(
|
||||
@@ -38,10 +46,16 @@ class AppLogger {
|
||||
file: _logFile!,
|
||||
),
|
||||
]),
|
||||
filter: ProductionFilter(),
|
||||
filter: _LevelFilter(_level),
|
||||
);
|
||||
}
|
||||
|
||||
/// 运行时切换日志级别
|
||||
static void setLevel(Level level) {
|
||||
_level = level;
|
||||
_createLogger();
|
||||
}
|
||||
|
||||
// 使用 getter 确保 logger 已初始化,防止空指针
|
||||
static Logger get _instance {
|
||||
_logger ??= Logger(
|
||||
@@ -67,6 +81,9 @@ class AppLogger {
|
||||
/// Error 级别日志
|
||||
static void e(String message) => _instance.e(message);
|
||||
|
||||
/// Trace 级别日志(高频轮询/查询使用,仅 trace 级别可见)
|
||||
static void t(String message) => _instance.t(message);
|
||||
|
||||
/// Debug 级别日志(支持格式化)
|
||||
static void df(String message, List<Object> args) => _instance.d(message, error: args);
|
||||
|
||||
@@ -132,6 +149,17 @@ class AppLogger {
|
||||
}
|
||||
}
|
||||
|
||||
/// 自定义级别过滤器:低于设定级别的日志被过滤
|
||||
class _LevelFilter extends LogFilter {
|
||||
final Level minLevel;
|
||||
_LevelFilter(this.minLevel);
|
||||
|
||||
@override
|
||||
bool shouldLog(LogEvent event) {
|
||||
return event.level.index >= minLevel.index;
|
||||
}
|
||||
}
|
||||
|
||||
/// 定义一个简单的自定义 FileOutput,防止 Logger 自带版本不支持追加
|
||||
class CustomFileOutput extends LogOutput {
|
||||
final File file;
|
||||
|
||||
+151
-30
@@ -1,90 +1,192 @@
|
||||
/// 文件工具类
|
||||
class FileUtils {
|
||||
/// 将路径转换为 Cloudreve URI 格式
|
||||
/// "/" → "cloudreve://my", "/subfolder" → "cloudreve://my/subfolder"
|
||||
static String safeDecodePathSegment(String value, {int maxPasses = 5}) {
|
||||
var decoded = value;
|
||||
|
||||
for (var i = 0; i < maxPasses; i++) {
|
||||
try {
|
||||
final next = Uri.decodeComponent(decoded);
|
||||
if (next == decoded) break;
|
||||
decoded = next;
|
||||
} on FormatException {
|
||||
break;
|
||||
} on ArgumentError {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
}
|
||||
|
||||
static String decodePathForDisplay(String path) {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => safeDecodePathSegment(segment))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
static String toCloudreveUri(String path) {
|
||||
if (path.startsWith('cloudreve://')) return path;
|
||||
if (path == '/' || path.isEmpty) return 'cloudreve://my';
|
||||
final cleanPath = path.startsWith('/') ? path.substring(1) : path;
|
||||
return 'cloudreve://my/$cleanPath';
|
||||
}
|
||||
/// 获取文件扩展名
|
||||
|
||||
static String toCloudreveUriWithQuery(
|
||||
String path,
|
||||
Map<String, String> queryParameters,
|
||||
) {
|
||||
final uri = Uri.parse(toCloudreveUri(path));
|
||||
return uri.replace(queryParameters: queryParameters).toString();
|
||||
}
|
||||
|
||||
static String getFileExtension(String fileName) {
|
||||
final dotIndex = fileName.lastIndexOf('.');
|
||||
if (dotIndex == -1) return '';
|
||||
return fileName.substring(dotIndex + 1).toLowerCase();
|
||||
}
|
||||
|
||||
/// 判断是否为图片文件
|
||||
static bool isImageFile(String fileName) {
|
||||
const imageExtensions = [
|
||||
'jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'heic'
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'gif',
|
||||
'webp',
|
||||
'bmp',
|
||||
'svg',
|
||||
'heic',
|
||||
'heif',
|
||||
'avif',
|
||||
'tif',
|
||||
'tiff',
|
||||
];
|
||||
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) {
|
||||
const videoExtensions = [
|
||||
'mp4', 'webm', 'mkv', 'avi', 'mov', 'flv', 'wmv'
|
||||
'mp4',
|
||||
'webm',
|
||||
'mkv',
|
||||
'avi',
|
||||
'mov',
|
||||
'flv',
|
||||
'wmv',
|
||||
'm4v',
|
||||
'mpg',
|
||||
'mpeg',
|
||||
'3gp',
|
||||
'ts',
|
||||
'm2ts',
|
||||
];
|
||||
return videoExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为音频文件
|
||||
static bool isAudioFile(String fileName) {
|
||||
const audioExtensions = [
|
||||
'mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'
|
||||
];
|
||||
const audioExtensions = ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'];
|
||||
return audioExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为PDF文件
|
||||
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) {
|
||||
return getFileExtension(fileName) == 'pdf';
|
||||
}
|
||||
|
||||
/// 判断是否为文本文件
|
||||
static bool isTextFile(String fileName) {
|
||||
const textExtensions = [
|
||||
'txt', 'md', 'json', 'xml', 'yaml', 'yml', 'ini', 'conf'
|
||||
'txt',
|
||||
'md',
|
||||
'json',
|
||||
'xml',
|
||||
'yaml',
|
||||
'yml',
|
||||
'ini',
|
||||
'conf',
|
||||
];
|
||||
return textExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为代码文件
|
||||
static bool isCodeFile(String fileName) {
|
||||
const codeExtensions = [
|
||||
'js', 'ts', 'tsx', 'jsx', 'dart', 'java', 'py', 'c', 'cpp',
|
||||
'h', 'hpp', 'cs', 'php', 'rb', 'go', 'rs', 'swift', 'kt',
|
||||
'html', 'css', 'scss', 'less', 'sql', 'sh', 'bat'
|
||||
'js',
|
||||
'ts',
|
||||
'tsx',
|
||||
'jsx',
|
||||
'dart',
|
||||
'java',
|
||||
'py',
|
||||
'c',
|
||||
'cpp',
|
||||
'h',
|
||||
'hpp',
|
||||
'cs',
|
||||
'php',
|
||||
'rb',
|
||||
'go',
|
||||
'rs',
|
||||
'swift',
|
||||
'kt',
|
||||
'html',
|
||||
'css',
|
||||
'scss',
|
||||
'less',
|
||||
'sql',
|
||||
'sh',
|
||||
'bat',
|
||||
];
|
||||
return codeExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为压缩文件
|
||||
static bool isArchiveFile(String fileName) {
|
||||
const archiveExtensions = [
|
||||
'zip', 'rar', '7z', 'tar', 'gz', 'bz2'
|
||||
];
|
||||
const archiveExtensions = ['zip', 'rar', '7z', 'tar', 'gz', 'bz2'];
|
||||
return archiveExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否为文档文件
|
||||
static bool isDocumentFile(String fileName) {
|
||||
const docExtensions = [
|
||||
'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'odt', 'ods', 'odp'
|
||||
'doc',
|
||||
'docx',
|
||||
'xls',
|
||||
'xlsx',
|
||||
'ppt',
|
||||
'pptx',
|
||||
'odt',
|
||||
'ods',
|
||||
'odp',
|
||||
];
|
||||
return docExtensions.contains(getFileExtension(fileName));
|
||||
}
|
||||
|
||||
/// 判断是否可预览
|
||||
static bool isPreviewable(String fileName) {
|
||||
return isImageFile(fileName) || isVideoFile(fileName) ||
|
||||
isPdfFile(fileName) || isTextFile(fileName) || isCodeFile(fileName);
|
||||
return isImageFile(fileName) ||
|
||||
isVideoFile(fileName) ||
|
||||
isPdfFile(fileName) ||
|
||||
isTextFile(fileName) ||
|
||||
isCodeFile(fileName);
|
||||
}
|
||||
|
||||
/// 获取MIME类型
|
||||
static String getMimeType(String fileName) {
|
||||
final ext = getFileExtension(fileName);
|
||||
final mimeTypes = {
|
||||
@@ -94,13 +196,33 @@ class FileUtils {
|
||||
'gif': 'image/gif',
|
||||
'webp': 'image/webp',
|
||||
'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',
|
||||
'webm': 'video/webm',
|
||||
'mkv': 'video/x-matroska',
|
||||
'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',
|
||||
'wav': 'audio/wav',
|
||||
'ogg': 'audio/ogg',
|
||||
'flac': 'audio/flac',
|
||||
'aac': 'audio/aac',
|
||||
'm4a': 'audio/mp4',
|
||||
'pdf': 'application/pdf',
|
||||
'txt': 'text/plain',
|
||||
'json': 'application/json',
|
||||
@@ -112,7 +234,6 @@ class FileUtils {
|
||||
return mimeTypes[ext] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
/// 获取文件图标
|
||||
static String getFileIcon(String fileName) {
|
||||
if (isImageFile(fileName)) return 'assets/icons/image.svg';
|
||||
if (isVideoFile(fileName)) return 'assets/icons/video.svg';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
class AppUpdateInfo {
|
||||
final String version;
|
||||
final String title;
|
||||
final String? description;
|
||||
final Uri pageUrl;
|
||||
final Uri? downloadUrl;
|
||||
final String? fileName;
|
||||
final DateTime? publishedAt;
|
||||
|
||||
const AppUpdateInfo({
|
||||
required this.version,
|
||||
required this.title,
|
||||
required this.pageUrl,
|
||||
this.description,
|
||||
this.downloadUrl,
|
||||
this.fileName,
|
||||
this.publishedAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import '../../src/rust/api/ffi_types.dart' as ffi;
|
||||
|
||||
class SyncConfigModel {
|
||||
final String baseUrl;
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final String localRoot;
|
||||
final String remoteRoot;
|
||||
final String syncMode;
|
||||
final String conflictStrategy;
|
||||
final String wcfDeleteMode;
|
||||
final int maxConcurrentTransfers;
|
||||
final int bandwidthLimitKbps;
|
||||
final List<String> excludedPaths;
|
||||
final int maxWorkers;
|
||||
final String dataDir;
|
||||
final String clientId;
|
||||
final String logLevel;
|
||||
|
||||
const SyncConfigModel({
|
||||
required this.baseUrl,
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.localRoot,
|
||||
required this.dataDir,
|
||||
required this.clientId,
|
||||
this.remoteRoot = 'cloudreve://my',
|
||||
this.syncMode = 'full',
|
||||
this.conflictStrategy = 'keep_both',
|
||||
this.wcfDeleteMode = 'wcf_delete_local_only',
|
||||
this.maxConcurrentTransfers = 3,
|
||||
this.bandwidthLimitKbps = 0,
|
||||
this.excludedPaths = const [],
|
||||
this.maxWorkers = 0,
|
||||
this.logLevel = 'info',
|
||||
});
|
||||
|
||||
SyncConfigModel copyWith({
|
||||
String? baseUrl,
|
||||
String? accessToken,
|
||||
String? refreshToken,
|
||||
String? localRoot,
|
||||
String? remoteRoot,
|
||||
String? syncMode,
|
||||
String? conflictStrategy,
|
||||
String? wcfDeleteMode,
|
||||
int? maxConcurrentTransfers,
|
||||
int? bandwidthLimitKbps,
|
||||
List<String>? excludedPaths,
|
||||
int? maxWorkers,
|
||||
String? dataDir,
|
||||
String? clientId,
|
||||
String? logLevel,
|
||||
}) {
|
||||
return SyncConfigModel(
|
||||
baseUrl: baseUrl ?? this.baseUrl,
|
||||
accessToken: accessToken ?? this.accessToken,
|
||||
refreshToken: refreshToken ?? this.refreshToken,
|
||||
localRoot: localRoot ?? this.localRoot,
|
||||
remoteRoot: remoteRoot ?? this.remoteRoot,
|
||||
syncMode: syncMode ?? this.syncMode,
|
||||
conflictStrategy: conflictStrategy ?? this.conflictStrategy,
|
||||
wcfDeleteMode: wcfDeleteMode ?? this.wcfDeleteMode,
|
||||
maxConcurrentTransfers:
|
||||
maxConcurrentTransfers ?? this.maxConcurrentTransfers,
|
||||
bandwidthLimitKbps: bandwidthLimitKbps ?? this.bandwidthLimitKbps,
|
||||
excludedPaths: excludedPaths ?? this.excludedPaths,
|
||||
maxWorkers: maxWorkers ?? this.maxWorkers,
|
||||
dataDir: dataDir ?? this.dataDir,
|
||||
clientId: clientId ?? this.clientId,
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
);
|
||||
}
|
||||
|
||||
ffi.SyncConfigFfi toFfi() {
|
||||
return ffi.SyncConfigFfi(
|
||||
baseUrl: baseUrl,
|
||||
accessToken: accessToken,
|
||||
refreshToken: refreshToken,
|
||||
localRoot: localRoot,
|
||||
remoteRoot: remoteRoot,
|
||||
syncMode: syncMode,
|
||||
conflictStrategy: conflictStrategy,
|
||||
wcfDeleteMode: wcfDeleteMode,
|
||||
maxConcurrentTransfers: maxConcurrentTransfers,
|
||||
bandwidthLimitKbps: BigInt.from(bandwidthLimitKbps),
|
||||
excludedPaths: excludedPaths,
|
||||
maxWorkers: maxWorkers,
|
||||
dataDir: dataDir,
|
||||
clientId: clientId,
|
||||
logLevel: logLevel,
|
||||
);
|
||||
}
|
||||
|
||||
static SyncConfigModel fromFfi(ffi.SyncConfigFfi ffi) {
|
||||
return SyncConfigModel(
|
||||
baseUrl: ffi.baseUrl,
|
||||
accessToken: ffi.accessToken,
|
||||
refreshToken: ffi.refreshToken,
|
||||
localRoot: ffi.localRoot,
|
||||
remoteRoot: ffi.remoteRoot,
|
||||
syncMode: ffi.syncMode,
|
||||
conflictStrategy: ffi.conflictStrategy,
|
||||
wcfDeleteMode: ffi.wcfDeleteMode,
|
||||
maxConcurrentTransfers: ffi.maxConcurrentTransfers,
|
||||
bandwidthLimitKbps: ffi.bandwidthLimitKbps.toInt(),
|
||||
excludedPaths: ffi.excludedPaths,
|
||||
maxWorkers: ffi.maxWorkers,
|
||||
dataDir: ffi.dataDir,
|
||||
clientId: ffi.clientId,
|
||||
logLevel: ffi.logLevel,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import '../../src/rust/api/ffi_types.dart' as ffi;
|
||||
|
||||
sealed class SyncEventModel {}
|
||||
|
||||
class SyncStateChanged extends SyncEventModel {
|
||||
final String newState;
|
||||
SyncStateChanged(this.newState);
|
||||
}
|
||||
|
||||
class SyncProgress extends SyncEventModel {
|
||||
final int synced;
|
||||
final int total;
|
||||
final String currentFile;
|
||||
SyncProgress(this.synced, this.total, this.currentFile);
|
||||
}
|
||||
|
||||
class SyncFileUploaded extends SyncEventModel {
|
||||
final String localPath;
|
||||
final String remoteUri;
|
||||
SyncFileUploaded(this.localPath, this.remoteUri);
|
||||
}
|
||||
|
||||
class SyncFileDownloaded extends SyncEventModel {
|
||||
final String localPath;
|
||||
final String remoteUri;
|
||||
SyncFileDownloaded(this.localPath, this.remoteUri);
|
||||
}
|
||||
|
||||
class SyncConflictDetected extends SyncEventModel {
|
||||
final String localPath;
|
||||
final String conflictType;
|
||||
SyncConflictDetected(this.localPath, this.conflictType);
|
||||
}
|
||||
|
||||
class SyncError extends SyncEventModel {
|
||||
final String message;
|
||||
final bool recoverable;
|
||||
SyncError(this.message, this.recoverable);
|
||||
}
|
||||
|
||||
class SyncTokenExpired extends SyncEventModel {}
|
||||
|
||||
class SyncDiskSpaceWarning extends SyncEventModel {
|
||||
final int availableMb;
|
||||
SyncDiskSpaceWarning(this.availableMb);
|
||||
}
|
||||
|
||||
class SyncInitialSyncComplete extends SyncEventModel {
|
||||
final SyncSummaryModel summary;
|
||||
SyncInitialSyncComplete(this.summary);
|
||||
}
|
||||
|
||||
class SyncWorkerCompleted extends SyncEventModel {
|
||||
final String taskId;
|
||||
final int uploaded;
|
||||
final int downloaded;
|
||||
final int renamed;
|
||||
final int moved;
|
||||
final int failed;
|
||||
final int durationMs;
|
||||
SyncWorkerCompleted({
|
||||
required this.taskId,
|
||||
required this.uploaded,
|
||||
required this.downloaded,
|
||||
required this.renamed,
|
||||
required this.moved,
|
||||
required this.failed,
|
||||
required this.durationMs,
|
||||
});
|
||||
}
|
||||
|
||||
class SyncWorkerFailed extends SyncEventModel {
|
||||
final String taskId;
|
||||
final String message;
|
||||
SyncWorkerFailed({required this.taskId, required this.message});
|
||||
}
|
||||
|
||||
class SyncTaskItemUpdated extends SyncEventModel {
|
||||
final String taskId;
|
||||
final String relativePath;
|
||||
final String action;
|
||||
final String status;
|
||||
SyncTaskItemUpdated({
|
||||
required this.taskId,
|
||||
required this.relativePath,
|
||||
required this.action,
|
||||
required this.status,
|
||||
});
|
||||
}
|
||||
|
||||
/// 将 FFI 事件转换为 Dart 模型
|
||||
SyncEventModel? syncEventFromFfi(ffi.SyncEventFfi event) {
|
||||
return event.when(
|
||||
stateChanged: (newState) => SyncStateChanged(newState),
|
||||
progress: (synced, total, currentFile) =>
|
||||
SyncProgress(synced.toInt(), total.toInt(), currentFile),
|
||||
fileUploaded: (localPath, remoteUri) =>
|
||||
SyncFileUploaded(localPath, remoteUri),
|
||||
fileDownloaded: (localPath, remoteUri) =>
|
||||
SyncFileDownloaded(localPath, remoteUri),
|
||||
conflictDetected: (localPath, conflictType) =>
|
||||
SyncConflictDetected(localPath, conflictType),
|
||||
error: (message, recoverable) => SyncError(message, recoverable),
|
||||
tokenExpired: () => SyncTokenExpired(),
|
||||
diskSpaceWarning: (availableMb) =>
|
||||
SyncDiskSpaceWarning(availableMb.toInt()),
|
||||
initialSyncComplete: (summary) =>
|
||||
SyncInitialSyncComplete(SyncSummaryModel.fromFfi(summary)),
|
||||
workerStarted: (taskId, trigger, uploadCount, downloadCount) => null,
|
||||
workerCompleted:
|
||||
(taskId, uploaded, downloaded, renamed, moved, failed, durationMs) =>
|
||||
SyncWorkerCompleted(
|
||||
taskId: taskId,
|
||||
uploaded: uploaded,
|
||||
downloaded: downloaded,
|
||||
renamed: renamed,
|
||||
moved: moved,
|
||||
failed: failed,
|
||||
durationMs: durationMs.toInt(),
|
||||
),
|
||||
workerFailed: (taskId, message) =>
|
||||
SyncWorkerFailed(taskId: taskId, message: message),
|
||||
taskItemUpdated: (taskId, relativePath, action, status) =>
|
||||
SyncTaskItemUpdated(
|
||||
taskId: taskId,
|
||||
relativePath: relativePath,
|
||||
action: action,
|
||||
status: status,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class SyncSummaryModel {
|
||||
final int uploaded;
|
||||
final int downloaded;
|
||||
final int renamed;
|
||||
final int moved;
|
||||
final int conflicts;
|
||||
final int failed;
|
||||
final int skipped;
|
||||
final int deletedLocal;
|
||||
final int deletedRemote;
|
||||
final int durationMs;
|
||||
|
||||
const SyncSummaryModel({
|
||||
this.uploaded = 0,
|
||||
this.downloaded = 0,
|
||||
this.renamed = 0,
|
||||
this.moved = 0,
|
||||
this.conflicts = 0,
|
||||
this.failed = 0,
|
||||
this.skipped = 0,
|
||||
this.deletedLocal = 0,
|
||||
this.deletedRemote = 0,
|
||||
this.durationMs = 0,
|
||||
});
|
||||
|
||||
static SyncSummaryModel fromFfi(ffi.SyncSummaryFfi f) {
|
||||
return SyncSummaryModel(
|
||||
uploaded: f.uploaded,
|
||||
downloaded: f.downloaded,
|
||||
renamed: f.renamed,
|
||||
moved: f.moved,
|
||||
conflicts: f.conflicts,
|
||||
failed: f.failed,
|
||||
skipped: f.skipped,
|
||||
deletedLocal: f.deletedLocal,
|
||||
deletedRemote: f.deletedRemote,
|
||||
durationMs: f.durationMs.toInt(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import '../../src/rust/api/ffi_types.dart' as ffi;
|
||||
|
||||
class SyncStatusModel {
|
||||
final String state;
|
||||
final int syncedFiles;
|
||||
final int totalFiles;
|
||||
final int uploadingCount;
|
||||
final int downloadingCount;
|
||||
final int conflictCount;
|
||||
final int errorCount;
|
||||
final String? lastSyncTime;
|
||||
final String? errorMessage;
|
||||
|
||||
const SyncStatusModel({
|
||||
this.state = 'idle',
|
||||
this.syncedFiles = 0,
|
||||
this.totalFiles = 0,
|
||||
this.uploadingCount = 0,
|
||||
this.downloadingCount = 0,
|
||||
this.conflictCount = 0,
|
||||
this.errorCount = 0,
|
||||
this.lastSyncTime,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
bool get isIdle => state == 'idle';
|
||||
bool get isInitializing => state == 'initializing';
|
||||
bool get isInitialSync => state == 'initialSync';
|
||||
bool get isContinuous => state == 'continuous';
|
||||
bool get isPaused => state == 'paused';
|
||||
bool get isError => state == 'error';
|
||||
bool get isStopped => state == 'stopped';
|
||||
bool get isActive => isInitializing || isInitialSync || isContinuous;
|
||||
|
||||
static SyncStatusModel fromFfi(ffi.SyncStatusFfi f) {
|
||||
return SyncStatusModel(
|
||||
state: f.state,
|
||||
syncedFiles: f.syncedFiles.toInt(),
|
||||
totalFiles: f.totalFiles.toInt(),
|
||||
uploadingCount: f.uploadingCount,
|
||||
downloadingCount: f.downloadingCount,
|
||||
conflictCount: f.conflictCount,
|
||||
errorCount: f.errorCount,
|
||||
lastSyncTime: f.lastSyncTime,
|
||||
errorMessage: f.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
class SyncTaskModel {
|
||||
final String id;
|
||||
final String trigger;
|
||||
final int totalCount;
|
||||
final int completedCount;
|
||||
final int failedCount;
|
||||
final String status;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
final String? finishedAt;
|
||||
|
||||
const SyncTaskModel({
|
||||
required this.id,
|
||||
required this.trigger,
|
||||
required this.totalCount,
|
||||
required this.completedCount,
|
||||
required this.failedCount,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.finishedAt,
|
||||
});
|
||||
|
||||
String get shortId => id.length > 8 ? id.substring(0, 8) : id;
|
||||
|
||||
String get triggerLabel => switch (trigger) {
|
||||
'initial_sync' => '初始同步',
|
||||
'continuous' => '持续同步',
|
||||
'manual' => '手动同步',
|
||||
_ => trigger,
|
||||
};
|
||||
|
||||
String get statusLabel => switch (status) {
|
||||
'pending' => '等待中',
|
||||
'running' => '执行中',
|
||||
'completed' => '已完成',
|
||||
'failed' => '失败',
|
||||
'cancelled' => '已取消',
|
||||
_ => status,
|
||||
};
|
||||
|
||||
double get progress =>
|
||||
totalCount > 0 ? completedCount / totalCount : 0.0;
|
||||
}
|
||||
|
||||
class SyncTaskItemModel {
|
||||
final int id;
|
||||
final String taskId;
|
||||
final String relativePath;
|
||||
final String actionType;
|
||||
final String status;
|
||||
final int fileSize;
|
||||
final String? errorMessage;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncTaskItemModel({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.relativePath,
|
||||
required this.actionType,
|
||||
required this.status,
|
||||
required this.fileSize,
|
||||
this.errorMessage,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
String get actionLabel => switch (actionType) {
|
||||
'upload' => '上传',
|
||||
'download' => '下载',
|
||||
'delete_local' => '删本地',
|
||||
'delete_remote' => '删远程',
|
||||
'rename' => '重命名',
|
||||
'move' => '移动',
|
||||
'mkdir_remote' => '创建远程目录',
|
||||
'mkdir_local' => '创建本地目录',
|
||||
'conflict_resolve' => '冲突解决',
|
||||
'create_placeholder' => '创建占位符',
|
||||
_ => actionType,
|
||||
};
|
||||
|
||||
String get statusLabel => switch (status) {
|
||||
'pending' => '未开始',
|
||||
'running' => '进行中',
|
||||
'completed' => '已完成',
|
||||
'failed' => '失败',
|
||||
'skipped' => '跳过',
|
||||
_ => status,
|
||||
};
|
||||
|
||||
String get filename {
|
||||
final parts = relativePath.split('/');
|
||||
return parts.isNotEmpty ? parts.last : relativePath;
|
||||
}
|
||||
}
|
||||
+38
-5
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
|
||||
import 'package:logger/logger.dart' show Level;
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/desktop_title_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -11,6 +12,7 @@ import 'package:media_kit/media_kit.dart';
|
||||
import 'package:oktoast/oktoast.dart';
|
||||
import 'package:window_manager/window_manager.dart';
|
||||
import 'config/app_config.dart';
|
||||
import 'core/constants/storage_keys.dart';
|
||||
import 'presentation/providers/auth_provider.dart';
|
||||
import 'presentation/providers/file_manager_provider.dart';
|
||||
import 'presentation/providers/navigation_provider.dart';
|
||||
@@ -19,21 +21,55 @@ import 'presentation/providers/download_manager_provider.dart';
|
||||
import 'presentation/providers/user_setting_provider.dart';
|
||||
import 'presentation/providers/admin_provider.dart';
|
||||
import 'presentation/providers/quick_access_provider.dart';
|
||||
import 'presentation/providers/sync_provider.dart';
|
||||
import 'presentation/providers/theme_provider.dart';
|
||||
import 'services/upload_service.dart';
|
||||
import 'services/api_service.dart';
|
||||
import 'services/storage_service.dart';
|
||||
import 'services/server_service.dart';
|
||||
import 'services/cache_manager_service.dart';
|
||||
import 'services/avatar_cache_service.dart';
|
||||
import 'services/host_mapping_proxy_service.dart';
|
||||
import 'core/utils/video_fullscreen.dart';
|
||||
import 'services/desktop_service.dart';
|
||||
import 'router/app_router.dart';
|
||||
import 'presentation/widgets/toast_helper.dart';
|
||||
import 'presentation/widgets/update_prompt.dart';
|
||||
import 'src/rust/frb_generated.dart' show RustSyncApi;
|
||||
|
||||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||
|
||||
Level _parseLogLevel(String level) {
|
||||
return switch (level) {
|
||||
'error' => Level.error,
|
||||
'warning' => Level.warning,
|
||||
'info' => Level.info,
|
||||
'debug' => Level.debug,
|
||||
'trace' => Level.trace,
|
||||
_ => Level.info,
|
||||
};
|
||||
}
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
HttpOverrides.global = HostMappingHttpOverrides();
|
||||
|
||||
await AppLogger.init();
|
||||
final savedLevel = await StorageService.instance.getString(
|
||||
StorageKeys.logLevel,
|
||||
);
|
||||
if (savedLevel != null) {
|
||||
final level = _parseLogLevel(savedLevel);
|
||||
AppLogger.setLevel(level);
|
||||
}
|
||||
AppLogger.i("应用启动,日志系统就绪");
|
||||
|
||||
try {
|
||||
await RustSyncApi.init();
|
||||
AppLogger.i("RustSyncApi 初始化成功");
|
||||
} catch (e) {
|
||||
AppLogger.e("RustSyncApi 初始化失败: $e");
|
||||
}
|
||||
|
||||
// 捕获 flutter_cache_manager 在 Windows 上删除缓存文件时的文件占用异常
|
||||
// 该异常是后台异步抛出的,无法通过 try-catch 拦截,需绑定错误处理器静默忽略
|
||||
@@ -51,10 +87,6 @@ void main() async {
|
||||
return false;
|
||||
};
|
||||
|
||||
// 初始化日志
|
||||
await AppLogger.init();
|
||||
AppLogger.i("应用启动,日志系统就绪");
|
||||
|
||||
// 桌面端初始化窗口管理和系统托盘
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
// 实例化 FlutterSingleInstance 获取单实例句柄
|
||||
@@ -156,6 +188,7 @@ class CloudreveApp extends StatelessWidget {
|
||||
ChangeNotifierProvider(create: (_) => UserSettingProvider()),
|
||||
ChangeNotifierProvider(create: (_) => AdminProvider()),
|
||||
ChangeNotifierProvider(create: (_) => QuickAccessProvider()..load()),
|
||||
ChangeNotifierProvider(create: (_) => SyncProvider()),
|
||||
],
|
||||
child: const AppView(),
|
||||
);
|
||||
@@ -215,7 +248,7 @@ class AppView extends StatelessWidget {
|
||||
currentWidget = FilterQualityWidget(child: currentWidget);
|
||||
}
|
||||
// 添加全局错误处理
|
||||
return ErrorHandler(child: currentWidget);
|
||||
return UpdatePrompt(child: ErrorHandler(child: currentWidget));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
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: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 {
|
||||
final String type;
|
||||
@@ -21,12 +73,20 @@ class CaptchaWebConfig {
|
||||
const CaptchaWebConfig.recaptchaV2({
|
||||
required String siteKey,
|
||||
String displayName = 'reCAPTCHA V2',
|
||||
}) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey);
|
||||
}) : this._(
|
||||
type: 'recaptcha',
|
||||
displayName: displayName,
|
||||
siteKey: siteKey,
|
||||
);
|
||||
|
||||
const CaptchaWebConfig.turnstile({
|
||||
required String siteKey,
|
||||
String displayName = 'Cloudflare Turnstile',
|
||||
}) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey);
|
||||
}) : this._(
|
||||
type: 'turnstile',
|
||||
displayName: displayName,
|
||||
siteKey: siteKey,
|
||||
);
|
||||
|
||||
const CaptchaWebConfig.cap({
|
||||
required String instanceUrl,
|
||||
@@ -34,22 +94,36 @@ class CaptchaWebConfig {
|
||||
String? assetServer,
|
||||
String displayName = 'Cap',
|
||||
}) : this._(
|
||||
type: 'cap',
|
||||
displayName: displayName,
|
||||
instanceUrl: instanceUrl,
|
||||
siteKey: siteKey,
|
||||
assetServer: assetServer,
|
||||
);
|
||||
type: 'cap',
|
||||
displayName: displayName,
|
||||
instanceUrl: instanceUrl,
|
||||
siteKey: siteKey,
|
||||
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 {
|
||||
final CaptchaWebConfig config;
|
||||
final String baseUrl;
|
||||
final CaptchaProxyConfig? proxyConfig;
|
||||
|
||||
const CaptchaChallengePage({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.baseUrl,
|
||||
this.proxyConfig,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -57,61 +131,98 @@ class CaptchaChallengePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
late final WebViewController _controller;
|
||||
// ── 移动端 ──
|
||||
mobile.WebViewController? _mobileController;
|
||||
|
||||
// ── 桌面端 ──
|
||||
InAppWebViewController? _desktopController;
|
||||
Key _desktopKey = UniqueKey();
|
||||
|
||||
// ── 共享状态 ──
|
||||
bool _isLoading = true;
|
||||
int _progress = 0;
|
||||
String? _errorMessage;
|
||||
String? _statusText;
|
||||
bool _disposed = false;
|
||||
|
||||
// ── 是否设置了代理环境变量(用于清理时判断)──
|
||||
bool _proxyEnvSet = false;
|
||||
|
||||
// ── HTML ──
|
||||
late String _currentHtml;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentHtml = _buildHtml(widget.config);
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.transparent)
|
||||
..addJavaScriptChannel(
|
||||
'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();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
// Windows: 在 WebView2 创建前设置代理环境变量
|
||||
if (_isDesktop && widget.proxyConfig != null && Platform.isWindows) {
|
||||
_applyWebView2Proxy(widget.proxyConfig);
|
||||
_proxyEnvSet = true;
|
||||
}
|
||||
|
||||
_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 {
|
||||
if (_disposed) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_statusText = null;
|
||||
_progress = 0;
|
||||
_currentHtml = _buildHtml(widget.config);
|
||||
});
|
||||
|
||||
final html = _buildHtml(widget.config);
|
||||
await _controller.loadHtmlString(html, baseUrl: widget.baseUrl);
|
||||
if (!_isDesktop && _mobileController != null) {
|
||||
await _mobileController!.loadHtmlString(
|
||||
_currentHtml,
|
||||
baseUrl: widget.baseUrl,
|
||||
);
|
||||
} else if (_isDesktop) {
|
||||
setState(() => _desktopKey = UniqueKey());
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bridge 消息处理 ─────────────────────────────────
|
||||
|
||||
void _handleBridgeMessage(String rawMessage) {
|
||||
AppLogger.d(
|
||||
'Bridge 收到消息: ${rawMessage.length > 100 ? "${rawMessage.substring(0, 100)}..." : rawMessage}',
|
||||
);
|
||||
try {
|
||||
final decoded = jsonDecode(rawMessage);
|
||||
if (decoded is! Map) return;
|
||||
@@ -120,8 +231,18 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
|
||||
if (type == 'success') {
|
||||
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);
|
||||
AppLogger.d('pop 完成');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -130,7 +251,8 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
final progress = decoded['progress']?.toString();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusText = progress == null ? '正在验证...' : '正在验证... $progress';
|
||||
_statusText =
|
||||
progress == null ? '正在验证...' : '正在验证... $progress';
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -145,6 +267,11 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'debug') {
|
||||
AppLogger.d('JS debug: ${decoded['message']}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'expired') {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -153,25 +280,131 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略非 JSON 消息。
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ─── 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) {
|
||||
switch (config.type) {
|
||||
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':
|
||||
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':
|
||||
return _buildCapHtml(
|
||||
instanceUrl: config.instanceUrl!,
|
||||
siteKey: config.siteKey!,
|
||||
assetServer: config.assetServer,
|
||||
final endpoint = _capEndpoint(config.instanceUrl!, config.siteKey!);
|
||||
final scriptUrl = _capWidgetScript(config.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>
|
||||
''',
|
||||
);
|
||||
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>
|
||||
<script>
|
||||
function sendBridge(payload) {
|
||||
payload._jsTs = Date.now();
|
||||
var json = JSON.stringify(payload);
|
||||
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) {}
|
||||
}
|
||||
function markStatus(text, isError) {
|
||||
@@ -289,139 +528,9 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
''';
|
||||
}
|
||||
|
||||
String _buildTurnstileHtml(String siteKey) {
|
||||
return _baseHtml(
|
||||
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');
|
||||
}
|
||||
// ═════════════════════════════════════════════════════
|
||||
// Widget 构建
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -446,51 +555,173 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||
_isDesktop ? _buildDesktopWebView() : _buildMobileWebView(),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
if (_errorMessage != null)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
_buildOverlayBanner(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
)
|
||||
else if (_statusText != null)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
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),
|
||||
),
|
||||
_buildOverlayBanner(
|
||||
child: Text(
|
||||
_statusText!,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 移动端 WebView ──────────────────────────────────
|
||||
|
||||
Widget _buildMobileWebView() {
|
||||
return mobile.WebViewWidget(controller: _mobileController!);
|
||||
}
|
||||
|
||||
// ── 桌面端 WebView ──────────────────────────────────
|
||||
|
||||
Widget _buildDesktopWebView() {
|
||||
AppLogger.i('WebView 验证码 BaseUrl: ${WebUri(widget.baseUrl)}');
|
||||
|
||||
return InAppWebView(
|
||||
key: _desktopKey,
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
domStorageEnabled: true,
|
||||
safeBrowsingEnabled: true,
|
||||
isInspectable: true,
|
||||
cacheMode: CacheMode.LOAD_NO_CACHE,
|
||||
supportMultipleWindows: true,
|
||||
allowUniversalAccessFromFileURLs: true,
|
||||
allowFileAccessFromFileURLs: true,
|
||||
userAgent: _desktopUserAgent,
|
||||
transparentBackground: true,
|
||||
supportZoom: false,
|
||||
useHybridComposition: true,
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
_desktopController = controller;
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'CaptchaBridge',
|
||||
callback: (args) {
|
||||
if (args.isNotEmpty) {
|
||||
_handleBridgeMessage(args[0].toString());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
controller.loadUrl(
|
||||
urlRequest: URLRequest(
|
||||
url: WebUri("${widget.baseUrl}/virtual_captcha.html"),
|
||||
),
|
||||
);
|
||||
},
|
||||
shouldInterceptRequest: (controller, request) async {
|
||||
if (request.url.toString().contains('virtual_captcha.html')) {
|
||||
AppLogger.i('黑魔法 -> 拦截成功,正在注入动态 HTML');
|
||||
return WebResourceResponse(
|
||||
contentType: 'text/html',
|
||||
contentEncoding: 'utf-8',
|
||||
data: Uint8List.fromList(utf8.encode(_currentHtml)),
|
||||
statusCode: 200,
|
||||
reasonPhrase: 'OK',
|
||||
);
|
||||
}
|
||||
|
||||
if (request.url.toString().contains('/h/b/rc') && request.method == 'POST') {
|
||||
AppLogger.w('发现黑魔法后遗症校验请求 (POST),执行强制 404');
|
||||
await Future.delayed(Duration(seconds: 2));
|
||||
return WebResourceResponse(
|
||||
contentType: 'text/plain',
|
||||
statusCode: 404,
|
||||
reasonPhrase: 'Not Found',
|
||||
data: Uint8List(0),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onLoadStart: (controller, url) {},
|
||||
onLoadStop: (controller, url) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
},
|
||||
onProgressChanged: (controller, progress) {
|
||||
if (mounted) setState(() => _progress = progress);
|
||||
},
|
||||
onReceivedError: (controller, request, error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = '${error.type}: ${error.description}'.trim();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── 通用底部提示条 ──────────────────────────────────
|
||||
|
||||
Widget _buildOverlayBanner({required Widget child, Color? color}) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color ??
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════
|
||||
// 辅助
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
String _capEndpoint(String instanceUrl, String siteKey) {
|
||||
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return '$trimmedInstance/$trimmedSiteKey/';
|
||||
}
|
||||
|
||||
String _capWidgetScript(String? assetServer) {
|
||||
final asset = assetServer?.trim();
|
||||
if (asset != null && asset.isNotEmpty) {
|
||||
if (asset.startsWith('http://') || asset.startsWith('https://')) {
|
||||
return asset;
|
||||
}
|
||||
if (asset.toLowerCase() == 'jsdelivr') {
|
||||
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||
}
|
||||
if (asset.toLowerCase() == 'unpkg') {
|
||||
return 'https://unpkg.com/cap-widget';
|
||||
}
|
||||
}
|
||||
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||
}
|
||||
|
||||
String _js(String input) {
|
||||
return input
|
||||
.replaceAll(r'\', r'\\')
|
||||
.replaceAll("'", r"\'")
|
||||
.replaceAll('\n', r'\n')
|
||||
.replaceAll('\r', r'\r');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/utils/date_utils.dart' as date_utils;
|
||||
import '../../../core/constants/sort_options.dart';
|
||||
import '../../../core/constants/storage_keys.dart';
|
||||
import '../../../core/utils/file_type_utils.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../router/app_router.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
import '../../../services/storage_service.dart';
|
||||
import '../../providers/file_manager_provider.dart';
|
||||
import '../../widgets/file_info_dialog.dart';
|
||||
import '../../widgets/file_operation_dialogs.dart';
|
||||
import '../../widgets/selection_toolbar.dart';
|
||||
import '../../widgets/thumbnail_image.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
|
||||
@@ -52,20 +61,30 @@ class CategoryFilesPage extends StatefulWidget {
|
||||
State<CategoryFilesPage> createState() => _CategoryFilesPageState();
|
||||
}
|
||||
|
||||
class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
class _CategoryFilesPageState extends State<CategoryFilesPage>
|
||||
with TickerProviderStateMixin {
|
||||
final _fileService = FileService();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
final List<FileModel> _files = [];
|
||||
final Set<String> _selectedFilePaths = <String>{};
|
||||
String? _nextPageToken;
|
||||
String? _contextHint;
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingMore = false;
|
||||
String? _errorMessage;
|
||||
SortOption _sortOption = SortOption.default_;
|
||||
|
||||
bool get _hasSelection => _selectedFilePaths.isNotEmpty;
|
||||
|
||||
List<FileModel> get _selectedFiles => _files
|
||||
.where((file) => _selectedFilePaths.contains(file.path))
|
||||
.toList(growable: false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_restoreSortOption();
|
||||
_loadFiles(refresh: true);
|
||||
_scrollController.addListener(_onScroll);
|
||||
}
|
||||
@@ -74,6 +93,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
void didUpdateWidget(covariant CategoryFilesPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.args.category != widget.args.category) {
|
||||
_clearSelection();
|
||||
_loadFiles(refresh: true);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +122,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
_nextPageToken = null;
|
||||
_contextHint = null;
|
||||
_files.clear();
|
||||
_selectedFilePaths.clear();
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
@@ -114,6 +135,8 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
final response = await _fileService.listFilesByCategory(
|
||||
category: widget.args.category,
|
||||
pageSize: 100,
|
||||
orderBy: _sortOption.field.apiKey,
|
||||
orderDirection: _sortOption.direction.apiKey,
|
||||
nextPageToken: refresh ? null : _nextPageToken,
|
||||
);
|
||||
|
||||
@@ -154,24 +177,200 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSortMenu() {
|
||||
final allOptions = [
|
||||
for (final field in SortField.values)
|
||||
for (final dir in SortDirection.values) SortOption(field, dir),
|
||||
];
|
||||
|
||||
return PopupMenuButton<SortOption>(
|
||||
icon: const Icon(LucideIcons.arrowUpDown),
|
||||
tooltip: '排序',
|
||||
position: PopupMenuPosition.under,
|
||||
onSelected: _setSortOption,
|
||||
itemBuilder: (context) => allOptions.map((option) {
|
||||
final isSelected = _sortOption == option;
|
||||
return PopupMenuItem<SortOption>(
|
||||
value: option,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isSelected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 24),
|
||||
Text(option.menuLabel),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _refresh() => _loadFiles(refresh: true);
|
||||
|
||||
Future<void> _restoreSortOption() async {
|
||||
final key = await StorageService.instance.getString(
|
||||
StorageKeys.fileSortOption,
|
||||
);
|
||||
final option = SortOption.fromKey(key);
|
||||
if (option != _sortOption) {
|
||||
setState(() => _sortOption = option);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setSortOption(SortOption option) async {
|
||||
if (_sortOption == option) return;
|
||||
setState(() => _sortOption = option);
|
||||
await StorageService.instance.setString(
|
||||
StorageKeys.fileSortOption,
|
||||
option.toKey(),
|
||||
);
|
||||
await _loadFiles(refresh: true);
|
||||
}
|
||||
|
||||
void _toggleSelection(FileModel file) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
if (_selectedFilePaths.contains(file.path)) {
|
||||
_selectedFilePaths.remove(file.path);
|
||||
} else {
|
||||
_selectedFilePaths.add(file.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
if (_selectedFilePaths.isEmpty) return;
|
||||
setState(_selectedFilePaths.clear);
|
||||
}
|
||||
|
||||
void _selectAllVisible() {
|
||||
setState(() {
|
||||
_selectedFilePaths
|
||||
..clear()
|
||||
..addAll(_files.map((file) => file.path));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
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;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(args.title),
|
||||
if (_hasSelection) {
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
tooltip: '取消选择',
|
||||
onPressed: _clearSelection,
|
||||
),
|
||||
centerTitle: true,
|
||||
title: Text('已选中 ${_selectedFilePaths.length} 个文件'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: '刷新',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
TextButton(onPressed: _selectAllVisible, child: const Text('全选')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
title: Text(args.title),
|
||||
actions: [
|
||||
_buildSortMenu(),
|
||||
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)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -255,7 +454,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
horizontalPadding,
|
||||
10,
|
||||
horizontalPadding,
|
||||
24,
|
||||
_hasSelection ? 92 : 24,
|
||||
),
|
||||
children: [
|
||||
_buildSummaryHeader(context),
|
||||
@@ -271,11 +470,26 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: spacing),
|
||||
child: _CategoryFileTile(
|
||||
key: ValueKey(
|
||||
'category-file-${file.id.isNotEmpty ? file.id : file.path}',
|
||||
),
|
||||
file: file,
|
||||
contextHint: _contextHint,
|
||||
category: widget.args.category,
|
||||
accentColor: widget.args.color,
|
||||
onTap: () => _openFile(context, file),
|
||||
isSelected: _selectedFilePaths.contains(
|
||||
file.path,
|
||||
),
|
||||
selectionMode: _hasSelection,
|
||||
onTap: () {
|
||||
if (_hasSelection) {
|
||||
_toggleSelection(file);
|
||||
} else {
|
||||
_openFile(context, file);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggleSelection(file),
|
||||
onSelect: () => _toggleSelection(file),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -392,6 +606,140 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSelectedFiles(
|
||||
BuildContext context,
|
||||
List<FileModel> selectedFiles,
|
||||
) async {
|
||||
if (selectedFiles.isEmpty) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('删除确认'),
|
||||
content: Text('确定删除这 ${selectedFiles.length} 个文件吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await _fileService.deleteFiles(
|
||||
uris: selectedFiles.map((file) => file.path).toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
final selectedPaths = selectedFiles.map((file) => file.path).toSet();
|
||||
_files.removeWhere((file) => selectedPaths.contains(file.path));
|
||||
_selectedFilePaths.clear();
|
||||
});
|
||||
ToastHelper.success('删除成功');
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ToastHelper.failure('删除失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renameFile(BuildContext context, FileModel file) async {
|
||||
final controller = TextEditingController(text: file.name);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('重命名'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '新名称',
|
||||
prefixIcon: Icon(LucideIcons.edit3, size: 20),
|
||||
),
|
||||
autofocus: true,
|
||||
onSubmitted: (_) => Navigator.of(dialogContext).pop(true),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final newName = controller.text.trim();
|
||||
if (confirmed != true || newName.isEmpty || newName == file.name) return;
|
||||
|
||||
try {
|
||||
final response = await _fileService.renameFile(
|
||||
uri: file.path,
|
||||
newName: newName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (response.isEmpty) {
|
||||
await _refresh();
|
||||
} else {
|
||||
final updatedFile = FileModel.fromJson(response);
|
||||
setState(() {
|
||||
final index = _files.indexWhere((item) => item.path == file.path);
|
||||
if (index != -1) _files[index] = updatedFile;
|
||||
_selectedFilePaths
|
||||
..remove(file.path)
|
||||
..add(updatedFile.path);
|
||||
});
|
||||
}
|
||||
ToastHelper.success('重命名成功');
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ToastHelper.failure('重命名失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSelectionMore(BuildContext context, FileModel file) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text('重命名'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_renameFile(context, file);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('查看详情'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
FileInfoPanel.showAsBottomSheet(context, file);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryFileTile extends StatelessWidget {
|
||||
@@ -399,14 +747,23 @@ class _CategoryFileTile extends StatelessWidget {
|
||||
final String? contextHint;
|
||||
final String category;
|
||||
final Color accentColor;
|
||||
final bool isSelected;
|
||||
final bool selectionMode;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
final VoidCallback onSelect;
|
||||
|
||||
const _CategoryFileTile({
|
||||
super.key,
|
||||
required this.file,
|
||||
required this.contextHint,
|
||||
required this.category,
|
||||
required this.accentColor,
|
||||
required this.isSelected,
|
||||
required this.selectionMode,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -419,73 +776,127 @@ class _CategoryFileTile extends StatelessWidget {
|
||||
final ext = FileTypeUtils.getExtension(file.name);
|
||||
final isPsd = ext == 'psd' || ext == 'psb';
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.dividerColor.withValues(alpha: 0.12),
|
||||
final borderColor = isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.dividerColor.withValues(alpha: 0.12);
|
||||
|
||||
final showSelectionCircle = selectionMode || isSelected;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOutCubic,
|
||||
scale: isSelected ? 0.985 : 1.0,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
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 +935,53 @@ class _CategoryFileTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionCircle extends StatelessWidget {
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SelectionCircle({required this.selected, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected
|
||||
? colorScheme.primary
|
||||
: colorScheme.surface.withValues(alpha: 0.86),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline.withValues(alpha: 0.42),
|
||||
width: 1.4,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.10),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: selected
|
||||
? const Icon(LucideIcons.check, color: Colors.white, size: 16)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeBadge extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
|
||||
@@ -4,10 +4,12 @@ import 'dart:ui';
|
||||
|
||||
import 'package:cross_file/cross_file.dart';
|
||||
import 'package:desktop_drop/desktop_drop.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:cloudreve4_flutter/data/models/file_model.dart';
|
||||
import 'package:cloudreve4_flutter/services/file_service.dart';
|
||||
import 'package:cloudreve4_flutter/services/upload_service.dart';
|
||||
import '../../../core/utils/file_utils.dart';
|
||||
import '../../../core/constants/sort_options.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
@@ -41,6 +43,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
bool _isFirstLoad = true;
|
||||
FileModel? _infoFile;
|
||||
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final ScrollController _breadcrumbController = ScrollController();
|
||||
|
||||
// FAB 状态
|
||||
bool _isFabVisible = true;
|
||||
@@ -50,24 +54,37 @@ class _FilesPageState extends State<FilesPage> {
|
||||
// 桌面端拖拽状态
|
||||
bool _isDraggingOver = false;
|
||||
|
||||
// 滑动手势追踪
|
||||
Offset? _swipeStartPos;
|
||||
DateTime? _swipeStartTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScrollForPagination);
|
||||
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
if (screenWidth >= 1000) {
|
||||
fileManager.setViewType(FileViewType.grid);
|
||||
} else {
|
||||
fileManager.setViewType(FileViewType.list);
|
||||
}
|
||||
fileManager.restoreSortOption();
|
||||
if (_isFirstLoad) {
|
||||
fileManager.loadFiles();
|
||||
_isFirstLoad = false;
|
||||
}
|
||||
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
|
||||
final downloadManager = Provider.of<DownloadManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
downloadManager.initialize();
|
||||
}
|
||||
});
|
||||
@@ -75,8 +92,13 @@ class _FilesPageState extends State<FilesPage> {
|
||||
// 上传完成 → 自动刷新当前目录文件列表
|
||||
UploadService.instance.onUploadCompleted = (targetPath, fileName) {
|
||||
if (!mounted) return;
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final normalizedCurrent = FileUtils.toCloudreveUri(
|
||||
fileManager.currentPath,
|
||||
);
|
||||
if (targetPath == normalizedCurrent) {
|
||||
final fileUri = targetPath.endsWith('/')
|
||||
? '$targetPath$fileName'
|
||||
@@ -88,11 +110,74 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.removeListener(_onScrollForPagination);
|
||||
_scrollController.dispose();
|
||||
_breadcrumbController.dispose();
|
||||
_fabShowTimer?.cancel();
|
||||
HardwareKeyboard.instance.removeHandler(_handleKeyEvent);
|
||||
UploadService.instance.onUploadCompleted = null;
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScrollForPagination() {
|
||||
if (!_scrollController.hasClients) return;
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
if (!fileManager.hasMore ||
|
||||
fileManager.isLoadingMore ||
|
||||
fileManager.isLoading) {
|
||||
return;
|
||||
}
|
||||
final position = _scrollController.position;
|
||||
if (position.pixels >= position.maxScrollExtent - 320) {
|
||||
fileManager.loadMoreFiles();
|
||||
}
|
||||
}
|
||||
|
||||
bool _handleKeyEvent(KeyEvent event) {
|
||||
if (!mounted || event is! KeyDownEvent) return false;
|
||||
// Ctrl+F / Cmd+F → 打开搜索
|
||||
if (event.logicalKey == LogicalKeyboardKey.keyF &&
|
||||
(HardwareKeyboard.instance.isControlPressed ||
|
||||
HardwareKeyboard.instance.isMetaPressed)) {
|
||||
if (ModalRoute.of(context)?.isCurrent == false) return false;
|
||||
final nav = Provider.of<NavigationProvider>(context, listen: false);
|
||||
if (nav.currentIndex == 1 && !SearchDialog.isShowing) {
|
||||
SearchDialog.show(context);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onPointerDown(PointerDownEvent event) {
|
||||
_swipeStartPos = event.position;
|
||||
_swipeStartTime = DateTime.now();
|
||||
}
|
||||
|
||||
void _onPointerUp(PointerUpEvent event) {
|
||||
if (_swipeStartPos == null || _swipeStartTime == null) return;
|
||||
final dx = event.position.dx - _swipeStartPos!.dx;
|
||||
final dy = event.position.dy - _swipeStartPos!.dy;
|
||||
final duration = DateTime.now().difference(_swipeStartTime!);
|
||||
_swipeStartPos = null;
|
||||
_swipeStartTime = null;
|
||||
// 从右往左快速滑动 → 返回上一级
|
||||
if (dx < -150 &&
|
||||
dx.abs() > dy.abs() * 1.5 &&
|
||||
duration.inMilliseconds < 500) {
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
if (fileManager.currentPath != '/') {
|
||||
fileManager.goBack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showFileInfo(FileModel file) {
|
||||
setState(() => _infoFile = file);
|
||||
// 等待下一帧 rebuild 完成,endDrawer 从 null 变为非 null 后再打开
|
||||
@@ -101,6 +186,39 @@ class _FilesPageState extends State<FilesPage> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showSelectionMore(FileModel file, FileManagerProvider fileManager) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text('重命名'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
FileOperationDialogs.showRenameDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('查看详情'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_showFileInfo(file);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- FAB 显隐控制 ----
|
||||
|
||||
void _hideFab() {
|
||||
@@ -147,13 +265,17 @@ class _FilesPageState extends State<FilesPage> {
|
||||
Widget build(BuildContext context) {
|
||||
final isDesktop = MediaQuery.of(context).size.width >= 1000;
|
||||
|
||||
return Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: _buildAppBar(context),
|
||||
body: _buildBody(context),
|
||||
bottomNavigationBar: _buildBottomBar(context),
|
||||
endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null,
|
||||
floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context),
|
||||
return Listener(
|
||||
onPointerDown: _onPointerDown,
|
||||
onPointerUp: _onPointerUp,
|
||||
child: Scaffold(
|
||||
key: _scaffoldKey,
|
||||
appBar: _buildAppBar(context),
|
||||
body: _buildBody(context),
|
||||
bottomNavigationBar: _buildBottomBar(context),
|
||||
endDrawer: _infoFile != null ? FileInfoPanel(file: _infoFile!) : null,
|
||||
floatingActionButton: isDesktop ? null : _buildSpeedDialFAB(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -168,8 +290,13 @@ class _FilesPageState extends State<FilesPage> {
|
||||
if (fileManager.currentPath == '/') {
|
||||
return const Text('文件');
|
||||
}
|
||||
final segments = fileManager.currentPath.split('/').where((s) => s.isNotEmpty).toList();
|
||||
return Text(segments.isNotEmpty ? segments.last : '文件');
|
||||
final segments = fileManager.currentPath
|
||||
.split('/')
|
||||
.where((s) => s.isNotEmpty)
|
||||
.toList();
|
||||
return Text(
|
||||
segments.isNotEmpty ? _decodePathSegment(segments.last) : '文件',
|
||||
);
|
||||
}
|
||||
return _buildMobileBreadcrumb(context, fileManager);
|
||||
},
|
||||
@@ -178,15 +305,45 @@ class _FilesPageState extends State<FilesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
|
||||
/// 循环解码路径段,处理多重 URL 编码(如 %25E4%25B8%25AD → 中文)
|
||||
String _decodePathSegment(String 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 _buildMobileBreadcrumb(
|
||||
BuildContext context,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final pathParts = fileManager.currentPath.split('/');
|
||||
pathParts.removeWhere((part) => part.isEmpty);
|
||||
|
||||
// 路径变化后自动滚动到末尾
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_breadcrumbController.hasClients) {
|
||||
_breadcrumbController.animateTo(
|
||||
_breadcrumbController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
controller: _breadcrumbController,
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
_buildBreadcrumbChip(
|
||||
@@ -194,22 +351,30 @@ class _FilesPageState extends State<FilesPage> {
|
||||
label: '文件',
|
||||
icon: LucideIcons.home,
|
||||
color: colorScheme.primary,
|
||||
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
|
||||
onTap: () => fileManager.currentPath != '/'
|
||||
? fileManager.enterFolder('/')
|
||||
: null,
|
||||
),
|
||||
for (int i = 0; i < pathParts.length; i++) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: theme.hintColor.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
_buildBreadcrumbChip(
|
||||
context,
|
||||
label: pathParts[i],
|
||||
label: _decodePathSegment(pathParts[i]),
|
||||
icon: null,
|
||||
color: colorScheme.primary,
|
||||
isLast: i == pathParts.length - 1,
|
||||
onTap: () {
|
||||
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
|
||||
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
|
||||
if (targetPath != fileManager.currentPath) {
|
||||
fileManager.enterFolder(targetPath);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -233,7 +398,9 @@ class _FilesPageState extends State<FilesPage> {
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isLast ? color.withValues(alpha: 0.15) : color.withValues(alpha: 0.06),
|
||||
color: isLast
|
||||
? color.withValues(alpha: 0.15)
|
||||
: color.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
@@ -267,12 +434,19 @@ class _FilesPageState extends State<FilesPage> {
|
||||
Consumer<FileManagerProvider>(
|
||||
builder: (context, fileManager, child) {
|
||||
return IconButton(
|
||||
icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh),
|
||||
icon: Icon(
|
||||
fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh,
|
||||
),
|
||||
onPressed: () => fileManager.refreshFiles(),
|
||||
tooltip: '刷新',
|
||||
);
|
||||
},
|
||||
),
|
||||
Consumer<FileManagerProvider>(
|
||||
builder: (context, fileManager, child) {
|
||||
return _buildSortMenu(fileManager);
|
||||
},
|
||||
),
|
||||
Consumer<FileManagerProvider>(
|
||||
builder: (context, fileManager, child) {
|
||||
final icon = fileManager.viewType == FileViewType.list
|
||||
@@ -287,14 +461,19 @@ class _FilesPageState extends State<FilesPage> {
|
||||
: FileViewType.list,
|
||||
);
|
||||
},
|
||||
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
|
||||
tooltip: fileManager.viewType == FileViewType.list
|
||||
? '网格视图'
|
||||
: '列表视图',
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
FileOperationDialogs.showCreateDialog(context, fileManager);
|
||||
},
|
||||
tooltip: '新建',
|
||||
@@ -306,7 +485,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_download),
|
||||
onPressed: () => Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
|
||||
onPressed: () =>
|
||||
Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
|
||||
tooltip: '下载',
|
||||
),
|
||||
];
|
||||
@@ -314,6 +494,11 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
List<Widget> _buildMobileActions() {
|
||||
return [
|
||||
Consumer<FileManagerProvider>(
|
||||
builder: (context, fileManager, child) {
|
||||
return _buildSortMenu(fileManager);
|
||||
},
|
||||
),
|
||||
Consumer<FileManagerProvider>(
|
||||
builder: (context, fileManager, child) {
|
||||
final icon = fileManager.viewType == FileViewType.list
|
||||
@@ -328,13 +513,52 @@ class _FilesPageState extends State<FilesPage> {
|
||||
: FileViewType.list,
|
||||
);
|
||||
},
|
||||
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
|
||||
tooltip: fileManager.viewType == FileViewType.list
|
||||
? '网格视图'
|
||||
: '列表视图',
|
||||
);
|
||||
},
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
Widget _buildSortMenu(FileManagerProvider fileManager) {
|
||||
final allOptions = [
|
||||
for (final field in SortField.values)
|
||||
for (final dir in SortDirection.values) SortOption(field, dir),
|
||||
];
|
||||
|
||||
return PopupMenuButton<SortOption>(
|
||||
icon: const Icon(LucideIcons.arrowUpDown),
|
||||
tooltip: '排序',
|
||||
position: PopupMenuPosition.under,
|
||||
onSelected: (option) => fileManager.setSortOption(option),
|
||||
itemBuilder: (context) => allOptions.map((option) {
|
||||
final isSelected = fileManager.sortOption == option;
|
||||
return PopupMenuItem<SortOption>(
|
||||
value: option,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isSelected)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
size: 16,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox(width: 24),
|
||||
Text(option.menuLabel),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- SpeedDial FAB ----
|
||||
|
||||
Widget _buildSpeedDialFAB(BuildContext context) {
|
||||
@@ -380,8 +604,16 @@ class _FilesPageState extends State<FilesPage> {
|
||||
isDark: isDark,
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
_onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager));
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
_onFabSubAction(
|
||||
() => FileOperationDialogs.showCreateDialog(
|
||||
context,
|
||||
fileManager,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
_buildFabSubItem(
|
||||
@@ -391,7 +623,10 @@ class _FilesPageState extends State<FilesPage> {
|
||||
label: '离线下载',
|
||||
isDark: isDark,
|
||||
colorScheme: colorScheme,
|
||||
onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)),
|
||||
onTap: () => _onFabSubAction(
|
||||
() =>
|
||||
Navigator.of(context).pushNamed(RouteNames.remoteDownload),
|
||||
),
|
||||
),
|
||||
Consumer<FileManagerProvider>(
|
||||
builder: (context, fileManager, _) {
|
||||
@@ -477,7 +712,10 @@ class _FilesPageState extends State<FilesPage> {
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 7,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
@@ -558,7 +796,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
final isDesktop = MediaQuery.of(context).size.width >= 1000;
|
||||
final child = _buildFileList(context);
|
||||
|
||||
if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
|
||||
if (!isDesktop ||
|
||||
!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
|
||||
return child;
|
||||
}
|
||||
|
||||
@@ -582,13 +821,19 @@ class _FilesPageState extends State<FilesPage> {
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface.withValues(alpha: 0.85),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.upload, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||
Icon(
|
||||
LucideIcons.upload,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'释放文件以上传到当前目录',
|
||||
@@ -618,8 +863,14 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
if (files.isEmpty) return;
|
||||
|
||||
final uploadManager = Provider.of<UploadManagerProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final uploadManager = Provider.of<UploadManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
uploadManager.markShouldShowDialog();
|
||||
uploadManager.startUpload(files, fileManager.currentPath);
|
||||
ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
|
||||
@@ -649,12 +900,19 @@ class _FilesPageState extends State<FilesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorView(BuildContext context, FileManagerProvider fileManager) {
|
||||
Widget _buildErrorView(
|
||||
BuildContext context,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 64, color: Theme.of(context).colorScheme.error),
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
fileManager.errorMessage!,
|
||||
@@ -692,20 +950,38 @@ class _FilesPageState extends State<FilesPage> {
|
||||
Widget _buildListView(BuildContext context, FileManagerProvider fileManager) {
|
||||
final isDesktop = MediaQuery.of(context).size.width >= 1000;
|
||||
final showCheckbox = fileManager.hasSelection;
|
||||
final itemCount =
|
||||
fileManager.files.length +
|
||||
(fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
if (isDesktop) FileListHeader(showCheckbox: showCheckbox),
|
||||
if (isDesktop)
|
||||
FileListHeader(
|
||||
showCheckbox: showCheckbox,
|
||||
currentSort: fileManager.sortOption,
|
||||
onSort: (option) => fileManager.setSortOption(option),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => _onRefresh(fileManager),
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScrollNotification,
|
||||
child: ListView.builder(
|
||||
itemCount: fileManager.files.length,
|
||||
controller: _scrollController,
|
||||
key: PageStorageKey('files_list_${fileManager.currentPath}'),
|
||||
cacheExtent: 900,
|
||||
keyboardDismissBehavior:
|
||||
ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= fileManager.files.length) {
|
||||
return _buildLoadMoreIndicator(context, fileManager);
|
||||
}
|
||||
final file = fileManager.files[index];
|
||||
final isSelected = fileManager.selectedFiles.contains(file.path);
|
||||
final isSelected = fileManager.selectedFiles.contains(
|
||||
file.path,
|
||||
);
|
||||
|
||||
return FileListItem(
|
||||
key: ValueKey('file_${file.id}'),
|
||||
@@ -727,13 +1003,40 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
},
|
||||
onSelect: () => fileManager.toggleSelection(file.path),
|
||||
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
|
||||
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
|
||||
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
|
||||
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
|
||||
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
|
||||
onShare: () => FileOperationDialogs.showShareDialog(context, file),
|
||||
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
|
||||
onDownload: !file.isFolder
|
||||
? () => _downloadFile(context, fileManager, file)
|
||||
: null,
|
||||
onOpenInBrowser: !file.isFolder
|
||||
? () => _openInBrowser(context, file)
|
||||
: null,
|
||||
onOpenInCloudreveApp: !file.isFolder
|
||||
? () => _openInCloudreveApp(context, file)
|
||||
: null,
|
||||
onRename: () => FileOperationDialogs.showRenameDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
),
|
||||
onMove: () => FileOperationDialogs.showMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
false,
|
||||
),
|
||||
onCopy: () => FileOperationDialogs.showMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
true,
|
||||
),
|
||||
onShare: () =>
|
||||
FileOperationDialogs.showShareDialog(context, file),
|
||||
onDelete: () =>
|
||||
FileOperationDialogs.showDeleteSingleConfirmation(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
),
|
||||
onInfo: () => _showFileInfo(file),
|
||||
);
|
||||
},
|
||||
@@ -762,15 +1065,23 @@ class _FilesPageState extends State<FilesPage> {
|
||||
crossAxisCount = 5;
|
||||
}
|
||||
|
||||
final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
|
||||
final itemWidth =
|
||||
(availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
|
||||
final childAspectRatio = itemWidth / 160;
|
||||
final showCheckbox = fileManager.hasSelection;
|
||||
final itemCount =
|
||||
fileManager.files.length +
|
||||
(fileManager.hasMore || fileManager.isLoadingMore ? 1 : 0);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () => _onRefresh(fileManager),
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScrollNotification,
|
||||
child: GridView.builder(
|
||||
controller: _scrollController,
|
||||
key: PageStorageKey('files_grid_${fileManager.currentPath}'),
|
||||
cacheExtent: 1100,
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
@@ -778,8 +1089,11 @@ class _FilesPageState extends State<FilesPage> {
|
||||
crossAxisSpacing: spacing / 2,
|
||||
childAspectRatio: childAspectRatio,
|
||||
),
|
||||
itemCount: fileManager.files.length,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: (context, index) {
|
||||
if (index >= fileManager.files.length) {
|
||||
return _buildGridLoadMoreIndicator(context, fileManager);
|
||||
}
|
||||
final file = fileManager.files[index];
|
||||
final isSelected = fileManager.selectedFiles.contains(file.path);
|
||||
|
||||
@@ -802,13 +1116,39 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
},
|
||||
onSelect: () => fileManager.toggleSelection(file.path),
|
||||
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
|
||||
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
|
||||
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
|
||||
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
|
||||
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
|
||||
onShare: () => FileOperationDialogs.showShareDialog(context, file),
|
||||
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
|
||||
onDownload: !file.isFolder
|
||||
? () => _downloadFile(context, fileManager, file)
|
||||
: null,
|
||||
onOpenInBrowser: !file.isFolder
|
||||
? () => _openInBrowser(context, file)
|
||||
: null,
|
||||
onOpenInCloudreveApp: !file.isFolder
|
||||
? () => _openInCloudreveApp(context, file)
|
||||
: null,
|
||||
onRename: () => FileOperationDialogs.showRenameDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
),
|
||||
onMove: () => FileOperationDialogs.showMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
false,
|
||||
),
|
||||
onCopy: () => FileOperationDialogs.showMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
true,
|
||||
),
|
||||
onShare: () =>
|
||||
FileOperationDialogs.showShareDialog(context, file),
|
||||
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
),
|
||||
onInfo: () => _showFileInfo(file),
|
||||
);
|
||||
},
|
||||
@@ -817,6 +1157,62 @@ class _FilesPageState extends State<FilesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoadMoreIndicator(
|
||||
BuildContext context,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
if (fileManager.isLoadingMore) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => fileManager.loadMoreFiles(),
|
||||
icon: const Icon(LucideIcons.chevronsDown, size: 16),
|
||||
label: const Text('加载更多'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildGridLoadMoreIndicator(
|
||||
BuildContext context,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
if (fileManager.isLoadingMore) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => fileManager.loadMoreFiles(),
|
||||
icon: const Icon(LucideIcons.chevronsDown, size: 16),
|
||||
label: const Text('加载更多'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBottomBar(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth >= 1000;
|
||||
@@ -826,33 +1222,34 @@ class _FilesPageState extends State<FilesPage> {
|
||||
if (fileManager.hasSelection) {
|
||||
return SelectionToolbar(
|
||||
selectionCount: fileManager.selectedFiles.length,
|
||||
totalCount: fileManager.files.length,
|
||||
onCancel: () => fileManager.clearSelection(),
|
||||
onRename: fileManager.selectedFiles.length == 1
|
||||
? () => FileOperationDialogs.showRenameDialog(
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.files.firstWhere(
|
||||
(f) => f.path == fileManager.selectedFiles.first,
|
||||
),
|
||||
)
|
||||
onSelectAll: () => fileManager.selectAll(),
|
||||
onMore: fileManager.selectedFiles.length == 1
|
||||
? () => _showSelectionMore(
|
||||
fileManager.files.firstWhere(
|
||||
(f) => f.path == fileManager.selectedFiles.first,
|
||||
),
|
||||
fileManager,
|
||||
)
|
||||
: null,
|
||||
onMove: () => FileOperationDialogs.showBatchMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
false,
|
||||
),
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
false,
|
||||
),
|
||||
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
true,
|
||||
),
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
true,
|
||||
),
|
||||
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
),
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -876,11 +1273,17 @@ class _FilesPageState extends State<FilesPage> {
|
||||
} else if (FileTypeUtils.isAudio(file.name)) {
|
||||
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
|
||||
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
||||
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteNames.markdownPreview, arguments: file);
|
||||
} else if (FileTypeUtils.isTextCode(file.name)) {
|
||||
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteNames.documentPreview, arguments: file);
|
||||
} else {
|
||||
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
|
||||
ToastHelper.info(
|
||||
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,7 +1292,10 @@ class _FilesPageState extends State<FilesPage> {
|
||||
FileManagerProvider fileManager,
|
||||
FileModel file,
|
||||
) async {
|
||||
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
|
||||
final downloadManager = Provider.of<DownloadManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final task = await downloadManager.addDownloadTask(
|
||||
fileName: file.name,
|
||||
fileUri: file.relativePath,
|
||||
@@ -935,4 +1341,10 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openInCloudreveApp(BuildContext context, FileModel file) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteNames.cloudreveFileApp, arguments: {'file': file});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ class QuickAccessGrid extends StatelessWidget {
|
||||
child: _QuickAccessButton(
|
||||
item: items[index],
|
||||
onTap: () => _onTap(context, items[index]),
|
||||
fillHeight: fillHeight,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -150,8 +151,13 @@ class QuickAccessGrid extends StatelessWidget {
|
||||
class _QuickAccessButton extends StatelessWidget {
|
||||
final QuickAccessConfig item;
|
||||
final VoidCallback onTap;
|
||||
final bool fillHeight;
|
||||
|
||||
const _QuickAccessButton({required this.item, required this.onTap});
|
||||
const _QuickAccessButton({
|
||||
required this.item,
|
||||
required this.onTap,
|
||||
this.fillHeight = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -179,24 +185,27 @@ class _QuickAccessButton extends StatelessWidget {
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(item.icon, color: foreground, size: 22),
|
||||
const SizedBox(width: 9),
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
child: SizedBox(
|
||||
height: fillHeight ? double.infinity : null,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(item.icon, color: foreground, size: 22),
|
||||
const SizedBox(width: 9),
|
||||
Flexible(
|
||||
child: Text(
|
||||
item.label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: foreground,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
|
||||
/// 音频预览页面
|
||||
@@ -43,7 +44,11 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
player.open(Media(url), play: true);
|
||||
await CustomLineService.instance.configureMediaPlayerProxy(
|
||||
player,
|
||||
url,
|
||||
);
|
||||
await player.open(Media(url), play: true);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
@@ -104,11 +109,7 @@ class _AudioPreviewPageState extends State<AudioPreviewPage> {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.white54,
|
||||
),
|
||||
const Icon(Icons.error_outline, size: 64, color: Colors.white54),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
@@ -140,14 +141,17 @@ class AudioPlayerWidget extends StatefulWidget {
|
||||
final Player player;
|
||||
final String fileName;
|
||||
|
||||
const AudioPlayerWidget({super.key, required this.player, required this.fileName});
|
||||
const AudioPlayerWidget({
|
||||
super.key,
|
||||
required this.player,
|
||||
required this.fileName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<AudioPlayerWidget> createState() => _AudioPlayerWidgetState();
|
||||
}
|
||||
|
||||
class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
@@ -155,10 +159,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
const Color(0xFF1A1A2E),
|
||||
const Color(0xFF16213E),
|
||||
],
|
||||
colors: [const Color(0xFF1A1A2E), const Color(0xFF16213E)],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
@@ -246,12 +247,18 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||
return SliderTheme(
|
||||
data: SliderThemeData(
|
||||
trackHeight: 4,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 8),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 16),
|
||||
thumbShape: const RoundSliderThumbShape(
|
||||
enabledThumbRadius: 8,
|
||||
),
|
||||
overlayShape: const RoundSliderOverlayShape(
|
||||
overlayRadius: 16,
|
||||
),
|
||||
activeTrackColor: const Color(0xFFE94560),
|
||||
inactiveTrackColor: Colors.white.withValues(alpha: 0.1),
|
||||
thumbColor: const Color(0xFFE94560),
|
||||
overlayColor: const Color(0xFFE94560).withValues(alpha: 0.3),
|
||||
overlayColor: const Color(
|
||||
0xFFE94560,
|
||||
).withValues(alpha: 0.3),
|
||||
),
|
||||
child: Slider(
|
||||
value: position.inMilliseconds.toDouble(),
|
||||
@@ -259,9 +266,7 @@ class _AudioPlayerWidgetState extends State<AudioPlayerWidget> {
|
||||
? duration.inMilliseconds.toDouble()
|
||||
: 1.0,
|
||||
onChanged: (value) {
|
||||
widget.player.seek(
|
||||
Duration(milliseconds: value.toInt()),
|
||||
);
|
||||
widget.player.seek(Duration(milliseconds: value.toInt()));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart' as desktop;
|
||||
import 'package:webview_flutter/webview_flutter.dart' as mobile;
|
||||
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/api_service.dart';
|
||||
|
||||
bool get _useDesktopWebView =>
|
||||
!kIsWeb && (Platform.isWindows || Platform.isLinux);
|
||||
|
||||
/// 完全交给 Cloudreve 官方 Web 前端处理文件打开。
|
||||
///
|
||||
/// 这里不再自己读取 file_viewers,也不再自己创建 viewerSession。
|
||||
/// Cloudreve 前端本身会根据 `/home?path=...&open=...` 打开对应文件,
|
||||
/// 并使用它自己的文件应用、WOPI、Markdown、表格、压缩包、EPUB 等逻辑。
|
||||
class CloudreveFileAppPage extends StatefulWidget {
|
||||
final FileModel file;
|
||||
final String? preferredAction;
|
||||
|
||||
const CloudreveFileAppPage({
|
||||
super.key,
|
||||
required this.file,
|
||||
this.preferredAction,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CloudreveFileAppPage> createState() => _CloudreveFileAppPageState();
|
||||
}
|
||||
|
||||
class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
|
||||
mobile.WebViewController? _mobileController;
|
||||
desktop.InAppWebViewController? _desktopController;
|
||||
|
||||
bool _isPreparing = true;
|
||||
bool _sessionInjected = false;
|
||||
int _progress = 0;
|
||||
String? _error;
|
||||
late Uri _targetUri;
|
||||
late Uri _originUri;
|
||||
String? _sessionStateJson;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (!_useDesktopWebView) {
|
||||
_mobileController = mobile.WebViewController()
|
||||
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.transparent)
|
||||
..setNavigationDelegate(
|
||||
mobile.NavigationDelegate(
|
||||
onProgress: (progress) {
|
||||
if (!mounted) return;
|
||||
setState(() => _progress = progress);
|
||||
},
|
||||
onPageFinished: (_) => _injectSessionAndOpenIfNeeded(),
|
||||
onWebResourceError: (error) {
|
||||
if (!mounted) return;
|
||||
if (error.isForMainFrame == true) {
|
||||
setState(() {
|
||||
_error = '${error.errorCode}: ${error.description}';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
_prepareAndOpen();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_desktopController?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _prepareAndOpen() async {
|
||||
setState(() {
|
||||
_isPreparing = true;
|
||||
_error = null;
|
||||
_progress = 0;
|
||||
_sessionInjected = false;
|
||||
if (_useDesktopWebView) {
|
||||
_desktopController = null;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
_originUri = _buildOriginUri();
|
||||
_targetUri = _buildCloudreveHomeUri();
|
||||
|
||||
_sessionStateJson = await _buildCloudreveFrontendSessionJson();
|
||||
|
||||
// 先打开同源页面,再注入 localStorage。
|
||||
// 这样 Cloudreve 官方前端可以直接复用 App 的 access_token。
|
||||
await _loadOrigin();
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isPreparing = false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isPreparing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Uri _buildOriginUri() {
|
||||
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
|
||||
return Uri(
|
||||
scheme: base.scheme,
|
||||
host: base.host,
|
||||
port: base.hasPort ? base.port : null,
|
||||
path: '/',
|
||||
);
|
||||
}
|
||||
|
||||
Uri _buildCloudreveHomeUri() {
|
||||
final parent = _parentUri(widget.file.relativePath);
|
||||
final openTarget = widget.file.id.isNotEmpty
|
||||
? widget.file.id
|
||||
: widget.file.relativePath;
|
||||
|
||||
return Uri(
|
||||
scheme: _originUri.scheme,
|
||||
host: _originUri.host,
|
||||
port: _originUri.hasPort ? _originUri.port : null,
|
||||
path: '/home',
|
||||
queryParameters: {
|
||||
'path': parent,
|
||||
'open': openTarget,
|
||||
'size': widget.file.size.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _parentUri(String uri) {
|
||||
final normalized = uri.endsWith('/')
|
||||
? uri.substring(0, uri.length - 1)
|
||||
: uri;
|
||||
final index = normalized.lastIndexOf('/');
|
||||
if (index <= 'cloudreve://my'.length) {
|
||||
return 'cloudreve://my';
|
||||
}
|
||||
return normalized.substring(0, index);
|
||||
}
|
||||
|
||||
Future<String?> _buildCloudreveFrontendSessionJson() async {
|
||||
final tokenGetter = ApiService.instance.getTokenCallback;
|
||||
final token = tokenGetter == null ? null : await tokenGetter();
|
||||
if (token == null || token.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? user;
|
||||
try {
|
||||
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||
'/user/me',
|
||||
);
|
||||
user = Map<String, dynamic>.from(response);
|
||||
} catch (_) {
|
||||
user = null;
|
||||
}
|
||||
|
||||
final userId = user?['id']?.toString() ?? user?['uid']?.toString() ?? 'app';
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
final accessExpires = now.add(const Duration(hours: 2)).toIso8601String();
|
||||
final refreshExpires = now.add(const Duration(hours: 2)).toIso8601String();
|
||||
|
||||
final sessionState = {
|
||||
'current': userId,
|
||||
'sessions': {
|
||||
userId: {
|
||||
'user': user ?? {'id': userId},
|
||||
'token': {
|
||||
'access_token': token,
|
||||
// App 侧暂时没有把 refresh token 暴露给这里。
|
||||
// 给 Cloudreve 前端一个短期可用 session;过期后 WebView 内刷新会要求重新登录。
|
||||
'refresh_token': '',
|
||||
'access_expires': accessExpires,
|
||||
'refresh_expires': refreshExpires,
|
||||
},
|
||||
'settings': {},
|
||||
},
|
||||
},
|
||||
'anonymousSettings': {},
|
||||
};
|
||||
|
||||
return jsonEncode(sessionState);
|
||||
}
|
||||
|
||||
Future<void> _injectSessionAndOpenIfNeeded() async {
|
||||
if (_sessionInjected) return;
|
||||
_sessionInjected = true;
|
||||
|
||||
final sessionJson = _sessionStateJson;
|
||||
final target = _targetUri.toString();
|
||||
|
||||
try {
|
||||
if (sessionJson != null) {
|
||||
final script =
|
||||
'''
|
||||
try {
|
||||
localStorage.setItem('cloudreve_session', ${jsonEncode(sessionJson)});
|
||||
} catch (e) {}
|
||||
window.location.replace(${jsonEncode(target)});
|
||||
''';
|
||||
await _runJavaScript(script);
|
||||
} else {
|
||||
await _loadTarget();
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openTargetAgain() async {
|
||||
_sessionInjected = true;
|
||||
await _loadTarget();
|
||||
}
|
||||
|
||||
Future<void> _reloadWebView() async {
|
||||
if (_useDesktopWebView) {
|
||||
await _desktopController?.reload();
|
||||
} else {
|
||||
await _mobileController?.reload();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadOrigin() async {
|
||||
if (_useDesktopWebView) {
|
||||
final controller = _desktopController;
|
||||
if (controller == null) return;
|
||||
await _desktopController?.loadUrl(
|
||||
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_originUri)),
|
||||
);
|
||||
} else {
|
||||
await _mobileController!.loadRequest(_originUri);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadTarget() async {
|
||||
if (_useDesktopWebView) {
|
||||
await _desktopController?.loadUrl(
|
||||
urlRequest: desktop.URLRequest(url: desktop.WebUri.uri(_targetUri)),
|
||||
);
|
||||
} else {
|
||||
await _mobileController!.loadRequest(_targetUri);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runJavaScript(String source) async {
|
||||
if (_useDesktopWebView) {
|
||||
await _desktopController?.evaluateJavascript(source: source);
|
||||
} else {
|
||||
await _mobileController!.runJavaScript(source);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.file.name, overflow: TextOverflow.ellipsis),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '重新打开',
|
||||
icon: const Icon(Icons.open_in_browser),
|
||||
onPressed: _openTargetAgain,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '刷新',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _reloadWebView,
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(3),
|
||||
child: _progress > 0 && _progress < 100
|
||||
? LinearProgressIndicator(value: _progress / 100)
|
||||
: const SizedBox(height: 3),
|
||||
),
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isPreparing) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 56, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _prepareAndOpen,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (_useDesktopWebView) {
|
||||
return desktop.InAppWebView(
|
||||
initialSettings: desktop.InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
domStorageEnabled: true,
|
||||
isInspectable: true,
|
||||
cacheMode: desktop.CacheMode.LOAD_DEFAULT,
|
||||
supportMultipleWindows: true,
|
||||
transparentBackground: true,
|
||||
supportZoom: true,
|
||||
useHybridComposition: true,
|
||||
),
|
||||
onWebViewCreated: (controller) async {
|
||||
_desktopController = controller;
|
||||
if (!_isPreparing) {
|
||||
await _loadOrigin();
|
||||
}
|
||||
},
|
||||
onProgressChanged: (_, progress) {
|
||||
if (!mounted) return;
|
||||
setState(() => _progress = progress);
|
||||
},
|
||||
onLoadStop: (_, url) => _injectSessionAndOpenIfNeeded(),
|
||||
onReceivedError: (_, request, error) {
|
||||
if (!mounted || request.isForMainFrame != true) return;
|
||||
setState(() => _error = '${error.type}: ${error.description}');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return mobile.WebViewWidget(controller: _mobileController!);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import 'package:highlight/highlight.dart';
|
||||
import 'package:highlight/languages/all.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
import '../../../core/utils/file_type_utils.dart';
|
||||
|
||||
@@ -62,6 +63,7 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
if (urls.isEmpty) throw Exception('获取URL为空');
|
||||
final urlData = urls[0] as Map<String, dynamic>;
|
||||
final url = urlData['url'] as String;
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||
|
||||
final responseContent = await http.get(Uri.parse(url));
|
||||
if (responseContent.statusCode != 200) {
|
||||
@@ -93,7 +95,8 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
_codeController = CodeController(text: _content, language: _languageMode);
|
||||
}
|
||||
|
||||
int _countLines(String text) => text.isEmpty ? 0 : '\n'.allMatches(text).length + 1;
|
||||
int _countLines(String text) =>
|
||||
text.isEmpty ? 0 : '\n'.allMatches(text).length + 1;
|
||||
|
||||
Mode _detectLanguageMode(String fileName) {
|
||||
final ext = FileTypeUtils.getExtension(fileName);
|
||||
@@ -109,7 +112,7 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
|
||||
// 自动初始化布局逻辑
|
||||
if (!_hasInitializedLayout && !_isLoading) {
|
||||
_showLineNumbers = screenWidth >= 600;
|
||||
@@ -122,7 +125,7 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
// 计算行数的位数,并根据当前字体大小分配宽度
|
||||
int digits = _lineCount.toString().length;
|
||||
// 这里的 0.7 是字体宽高的约数比例,20 是左右边距预留
|
||||
lineNumberWidth = (digits * (_fontSize * 0.7)) + 15;
|
||||
lineNumberWidth = (digits * (_fontSize * 0.7)) + 15;
|
||||
if (lineNumberWidth < 35) lineNumberWidth = 35;
|
||||
}
|
||||
|
||||
@@ -131,16 +134,21 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
appBar: AppBar(
|
||||
backgroundColor: _backgroundColor,
|
||||
elevation: 0,
|
||||
iconTheme: const IconThemeData(
|
||||
color: Colors.white,
|
||||
),
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
title: Column(
|
||||
// crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(widget.file.name, style: const TextStyle(color: Colors.white, fontSize: 15), textAlign: TextAlign.center,),
|
||||
if (!_isLoading)
|
||||
Text('$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
|
||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12), textAlign: TextAlign.center,),
|
||||
Text(
|
||||
widget.file.name,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 15),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (!_isLoading)
|
||||
Text(
|
||||
'$_languageName · $_lineCount 行 · 字号: ${_fontSize.toInt()}',
|
||||
style: TextStyle(color: Colors.grey.shade400, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
@@ -159,60 +167,64 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
|
||||
/// 现在是一次性渲染的, 所以代码行数太多会有性能问题, 渲染会耗时较长, 而且会卡顿
|
||||
Widget _buildCodeEditor(double lineNumberWidth) {
|
||||
// 核心改进:根据当前字号,动态计算一个更宽松的行号宽度
|
||||
// 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距
|
||||
int digits = _lineCount.toString().length;
|
||||
// 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场)
|
||||
double stableWidth = _showLineNumbers
|
||||
? (digits * (_fontSize * 0.75)) + 46
|
||||
: 0;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: _backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
// 核心改进:根据当前字号,动态计算一个更宽松的行号宽度
|
||||
// 13号字大概每个数字占 8-9 像素,我们按 10 像素算并加上边距
|
||||
int digits = _lineCount.toString().length;
|
||||
// 这里的 12.0 是根据 13-15号字体的平均宽度预留,46 是基础间距 (调试火葬场)
|
||||
double stableWidth = _showLineNumbers
|
||||
? (digits * (_fontSize * 0.75)) + 46
|
||||
: 0;
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2, vertical: 2),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: _backgroundColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
|
||||
),
|
||||
child: ScrollConfiguration(
|
||||
behavior: ScrollConfiguration.of(context).copyWith(
|
||||
dragDevices: {PointerDeviceKind.touch, PointerDeviceKind.mouse},
|
||||
),
|
||||
child: CodeTheme(
|
||||
data: CodeThemeData(styles: {...atomOneDarkTheme}),
|
||||
child: Scrollbar(
|
||||
child: CodeTheme(
|
||||
data: CodeThemeData(styles: {...atomOneDarkTheme}),
|
||||
child: Scrollbar(
|
||||
controller: _customCodeScrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _customCodeScrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _customCodeScrollController,
|
||||
child: CodeField(
|
||||
controller: _codeController!,
|
||||
child: CodeField(
|
||||
controller: _codeController!,
|
||||
textStyle: TextStyle(
|
||||
fontFamily: 'SourceCodePro',
|
||||
fontSize: _fontSize,
|
||||
height: 1.5,
|
||||
),
|
||||
enabled: false,
|
||||
readOnly: true,
|
||||
background: Colors.transparent,
|
||||
// 关键点 1:调整行号样式
|
||||
lineNumberStyle: LineNumberStyle(
|
||||
width: stableWidth,
|
||||
textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯
|
||||
margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行
|
||||
textStyle: TextStyle(
|
||||
fontFamily: 'SourceCodePro',
|
||||
fontSize: _fontSize,
|
||||
height: 1.5,
|
||||
),
|
||||
enabled: false,
|
||||
readOnly: true,
|
||||
background: Colors.transparent,
|
||||
// 关键点 1:调整行号样式
|
||||
lineNumberStyle: LineNumberStyle(
|
||||
width: stableWidth,
|
||||
textAlign: TextAlign.right, // 回归右对齐,更符合代码习惯
|
||||
margin: _showLineNumbers ? 12 : 0, // 增加间距防止数字贴边触发换行
|
||||
textStyle: TextStyle(
|
||||
color: _showLineNumbers ? Colors.grey.shade600 : Colors.transparent,
|
||||
fontSize: _fontSize * 0.8,
|
||||
height: 1.5, // 必须和正文高度完全一致
|
||||
// 核心修复:通过强制单词不换行来防止数字断裂
|
||||
fontFeatures: const [FontFeature.tabularFigures()], // 使用等宽数字
|
||||
),
|
||||
color: _showLineNumbers
|
||||
? Colors.grey.shade600
|
||||
: Colors.transparent,
|
||||
fontSize: _fontSize * 0.8,
|
||||
height: 1.5, // 必须和正文高度完全一致
|
||||
// 核心修复:通过强制单词不换行来防止数字断裂
|
||||
fontFeatures: const [
|
||||
FontFeature.tabularFigures(),
|
||||
], // 使用等宽数字
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 构建功能组合按钮
|
||||
Widget _buildExpandableFab() {
|
||||
@@ -225,7 +237,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
heroTag: 'font_up',
|
||||
mini: true,
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
onPressed: () => setState(() { if(_fontSize < 30) _fontSize++; }),
|
||||
onPressed: () => setState(() {
|
||||
if (_fontSize < 30) _fontSize++;
|
||||
}),
|
||||
child: const Icon(Icons.add, color: Colors.white, size: 20),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -234,7 +248,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
heroTag: 'font_down',
|
||||
mini: true,
|
||||
backgroundColor: Colors.grey.shade800,
|
||||
onPressed: () => setState(() { if(_fontSize > 8) _fontSize--; }),
|
||||
onPressed: () => setState(() {
|
||||
if (_fontSize > 8) _fontSize--;
|
||||
}),
|
||||
child: const Icon(Icons.remove, color: Colors.white, size: 20),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
@@ -242,7 +258,9 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
FloatingActionButton(
|
||||
heroTag: 'line_toggle',
|
||||
mini: true,
|
||||
backgroundColor: _showLineNumbers ? Colors.blue : Colors.grey.shade800,
|
||||
backgroundColor: _showLineNumbers
|
||||
? Colors.blue
|
||||
: Colors.grey.shade800,
|
||||
onPressed: () => setState(() => _showLineNumbers = !_showLineNumbers),
|
||||
child: Icon(
|
||||
_showLineNumbers ? Icons.format_list_numbered : Icons.short_text,
|
||||
@@ -253,4 +271,4 @@ class _DocumentPreviewPageState extends State<DocumentPreviewPage> {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
import '../../../services/cache_manager_service.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
@@ -44,6 +45,7 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
|
||||
if (urls.isNotEmpty) {
|
||||
final urlData = urls[0] as Map<String, dynamic>;
|
||||
final url = urlData['url'] as String;
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -197,7 +199,7 @@ class _ImagePreviewPageState extends State<ImagePreviewPage> {
|
||||
// 2. 编写平滑缩放函数
|
||||
void _smoothScale(PhotoViewController photoController, double delta) {
|
||||
// 如果正在动画中,忽略新的滚轮脉冲,防止冲突
|
||||
if (_isAnimating) return;
|
||||
if (_isAnimating) return;
|
||||
_isAnimating = true;
|
||||
|
||||
// 计算目标缩放值
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:markdown_widget/widget/blocks/leaf/link.dart';
|
||||
import 'package:markdown_widget/widget/markdown.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
|
||||
class MarkdownPreviewPage extends StatefulWidget {
|
||||
@@ -30,7 +31,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
final _tocController = TocController();
|
||||
bool _isDarkMode = false;
|
||||
|
||||
|
||||
// 控制目录是否显示
|
||||
bool _isTocVisible = false;
|
||||
// 标记是否已经根据屏幕宽度进行了初始化
|
||||
@@ -62,6 +63,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
|
||||
final urlData = urls[0] as Map<String, dynamic>;
|
||||
final url = urlData['url'] as String;
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||
|
||||
final responseContent = await http.get(Uri.parse(url));
|
||||
if (responseContent.statusCode != 200) {
|
||||
@@ -104,7 +106,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
backgroundColor: bgColor,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: isDark ? Colors.white : Colors.black87),
|
||||
icon: Icon(
|
||||
Icons.arrow_back,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Column(
|
||||
@@ -120,13 +125,19 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (!_isLoading && _error == null)
|
||||
Text('Markdown 预览', style: TextStyle(color: Colors.grey, fontSize: 12)),
|
||||
Text(
|
||||
'Markdown 预览',
|
||||
style: TextStyle(color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
if (!_isLoading && _error == null)
|
||||
IconButton(
|
||||
icon: Icon(Icons.copy, color: isDark ? Colors.white : Colors.black87),
|
||||
icon: Icon(
|
||||
Icons.copy,
|
||||
color: isDark ? Colors.white : Colors.black87,
|
||||
),
|
||||
onPressed: () => Clipboard.setData(ClipboardData(text: _content)),
|
||||
),
|
||||
],
|
||||
@@ -134,8 +145,8 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _buildErrorWidget()
|
||||
: _buildResponsiveBody(isWideScreen, screenWidth),
|
||||
? _buildErrorWidget()
|
||||
: _buildResponsiveBody(isWideScreen, screenWidth),
|
||||
floatingActionButton: _buildFAB(isDark),
|
||||
);
|
||||
}
|
||||
@@ -143,7 +154,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
// 构建响应式主体
|
||||
Widget _buildResponsiveBody(bool isWideScreen, double screenWidth) {
|
||||
final double tocWidth = isWideScreen ? 300 : screenWidth * 0.7;
|
||||
|
||||
|
||||
// 预定义暗色/亮色下的文字颜色
|
||||
final Color textColor = _isDarkMode ? Colors.white : Colors.black87;
|
||||
final Color subTextColor = _isDarkMode ? Colors.white70 : Colors.black54;
|
||||
@@ -159,10 +170,14 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: _isDarkMode ? Colors.white10 : Colors.grey.withValues(alpha: 0.2)
|
||||
color: _isDarkMode
|
||||
? Colors.white10
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
color: _isDarkMode ? const Color(0xFF252525) : Colors.grey.shade50,
|
||||
color: _isDarkMode
|
||||
? const Color(0xFF252525)
|
||||
: Colors.grey.shade50,
|
||||
),
|
||||
child: ClipRect(
|
||||
child: Column(
|
||||
@@ -178,7 +193,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Divider(height: 1, color: _isDarkMode ? Colors.white10 : Colors.grey.shade300),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: _isDarkMode ? Colors.white10 : Colors.grey.shade300,
|
||||
),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
@@ -198,9 +216,7 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
bodySmall: TextStyle(color: subTextColor),
|
||||
),
|
||||
),
|
||||
child: TocWidget(
|
||||
controller: _tocController,
|
||||
),
|
||||
child: TocWidget(controller: _tocController),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -228,8 +244,11 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
}
|
||||
|
||||
Widget _buildMarkdownWidget(String content) {
|
||||
final config = _isDarkMode ? MarkdownConfig.darkConfig : MarkdownConfig.defaultConfig;
|
||||
CodeWrapperWidget codeWrapper(child, text, language) => CodeWrapperWidget(child, text, language);
|
||||
final config = _isDarkMode
|
||||
? MarkdownConfig.darkConfig
|
||||
: MarkdownConfig.defaultConfig;
|
||||
CodeWrapperWidget codeWrapper(child, text, language) =>
|
||||
CodeWrapperWidget(child, text, language);
|
||||
|
||||
return MarkdownWidget(
|
||||
data: content,
|
||||
@@ -237,7 +256,10 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
config: config.copy(
|
||||
configs: [
|
||||
_isDarkMode
|
||||
? PreConfig.darkConfig.copy(theme: a11yLightTheme, wrapper: codeWrapper)
|
||||
? PreConfig.darkConfig.copy(
|
||||
theme: a11yLightTheme,
|
||||
wrapper: codeWrapper,
|
||||
)
|
||||
: PreConfig(theme: atomOneDarkTheme).copy(wrapper: codeWrapper),
|
||||
LinkConfig(
|
||||
style: TextStyle(
|
||||
@@ -266,12 +288,14 @@ class _MarkdownPreviewPageState extends State<MarkdownPreviewPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFAB(bool isDark) {
|
||||
Widget _buildFAB(bool isDark) {
|
||||
if (_isLoading || _error != null) return const SizedBox.shrink();
|
||||
|
||||
|
||||
// 定义统一的按钮背景颜色,增加视觉一致性
|
||||
final Color activeColor = isDark ? Colors.blue.shade700 : Colors.blue;
|
||||
final Color inactiveColor = isDark ? Colors.grey.shade800 : Colors.grey.shade200;
|
||||
final Color inactiveColor = isDark
|
||||
? Colors.grey.shade800
|
||||
: Colors.grey.shade200;
|
||||
final Color iconColor = isDark ? Colors.white : Colors.black87;
|
||||
|
||||
return Column(
|
||||
@@ -285,7 +309,7 @@ Widget _buildFAB(bool isDark) {
|
||||
elevation: 2, // 稍微降低阴影,看起来更精致
|
||||
onPressed: () => setState(() => _isDarkMode = !_isDarkMode),
|
||||
child: Icon(
|
||||
isDark ? Icons.light_mode : Icons.dark_mode,
|
||||
isDark ? Icons.light_mode : Icons.dark_mode,
|
||||
color: iconColor,
|
||||
size: 20, // 微调图标大小
|
||||
),
|
||||
@@ -300,7 +324,7 @@ Widget _buildFAB(bool isDark) {
|
||||
elevation: 2,
|
||||
onPressed: () => setState(() => _isTocVisible = !_isTocVisible),
|
||||
child: Icon(
|
||||
Icons.format_list_bulleted,
|
||||
Icons.format_list_bulleted,
|
||||
color: _isTocVisible ? Colors.white : iconColor,
|
||||
size: 20,
|
||||
),
|
||||
@@ -308,4 +332,4 @@ Widget _buildFAB(bool isDark) {
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:pdfrx/pdfrx.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
|
||||
/// PDF预览页面
|
||||
@@ -37,6 +38,7 @@ class _PdfPreviewPageState extends State<PdfPreviewPage> {
|
||||
if (urls.isNotEmpty) {
|
||||
final urlData = urls[0] as Map<String, dynamic>;
|
||||
final url = urlData['url'] as String;
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -114,9 +116,12 @@ class _PdfPreviewPageState extends State<PdfPreviewPage> {
|
||||
initialPageNumber: 1,
|
||||
params: const PdfViewerParams(
|
||||
activeMatchTextColor: Colors.yellow,
|
||||
annotationRenderingMode: PdfAnnotationRenderingMode.annotationAndForms,
|
||||
maxScale: 4.0,
|
||||
minScale: 0.8, // Allow 300% zoom
|
||||
annotationRenderingMode:
|
||||
PdfAnnotationRenderingMode.annotationAndForms,
|
||||
sizeDelegateProvider: PdfViewerSizeDelegateProviderLegacy(
|
||||
maxScale: 4.0,
|
||||
minScale: 0.8,
|
||||
),
|
||||
scaleEnabled: true,
|
||||
textSelectionParams: PdfTextSelectionParams(
|
||||
enabled: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
import 'package:media_kit_video/media_kit_video.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/custom_line_service.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
import 'widgets/video_controls_overlay.dart';
|
||||
|
||||
@@ -45,7 +46,11 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
|
||||
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
player.open(Media(url), play: true);
|
||||
await CustomLineService.instance.configureMediaPlayerProxy(
|
||||
player,
|
||||
url,
|
||||
);
|
||||
await player.open(Media(url), play: true);
|
||||
}
|
||||
} else {
|
||||
if (mounted) {
|
||||
@@ -78,37 +83,42 @@ class _VideoPreviewPageState extends State<VideoPreviewPage> {
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: Colors.white))
|
||||
: _errorMessage != null
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 64, color: Colors.white),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_loadVideoUrl();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: ExcludeSemantics(
|
||||
child: Video(
|
||||
controller: controller,
|
||||
controls: (state) => VideoControlsOverlay(state: state, title: widget.file.name),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
_loadVideoUrl();
|
||||
},
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ExcludeSemantics(
|
||||
child: Video(
|
||||
controller: controller,
|
||||
controls: (state) =>
|
||||
VideoControlsOverlay(state: state, title: widget.file.name),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,70 @@
|
||||
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
|
||||
import 'package:cloudreve4_flutter/router/app_router.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class _QuickFunction {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String route;
|
||||
final String? route;
|
||||
final void Function(BuildContext context)? onTap;
|
||||
|
||||
const _QuickFunction({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.route,
|
||||
this.route,
|
||||
this.onTap,
|
||||
});
|
||||
}
|
||||
|
||||
class QuickFunctionsSection extends StatelessWidget {
|
||||
const QuickFunctionsSection({super.key});
|
||||
|
||||
static const _functions = [
|
||||
_QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share),
|
||||
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav),
|
||||
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload),
|
||||
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin),
|
||||
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings),
|
||||
static final _functions = [
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.share2,
|
||||
label: '我的分享',
|
||||
route: RouteNames.share,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.cloud,
|
||||
label: 'WebDAV',
|
||||
route: RouteNames.webdav,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.download,
|
||||
label: '离线下载',
|
||||
route: RouteNames.remoteDownload,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.trash2,
|
||||
label: '回收站',
|
||||
route: RouteNames.recycleBin,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.refreshCw,
|
||||
label: '文件同步',
|
||||
onTap: (ctx) {
|
||||
final nav = ctx.read<NavigationProvider>();
|
||||
// 桌面端或 Android 平板(宽屏)有同步 Tab,直接切换;手机端跳转同步详情页
|
||||
final isDesktop =
|
||||
defaultTargetPlatform != TargetPlatform.android &&
|
||||
defaultTargetPlatform != TargetPlatform.iOS;
|
||||
final isWideScreen = MediaQuery.of(ctx).size.width >= 800;
|
||||
if (isDesktop || isWideScreen) {
|
||||
nav.setIndex(3);
|
||||
} else {
|
||||
Navigator.of(ctx).pushNamed(RouteNames.syncStatus);
|
||||
}
|
||||
},
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.settings,
|
||||
label: '设置',
|
||||
route: RouteNames.settings,
|
||||
),
|
||||
];
|
||||
|
||||
static const double _spacing = 12;
|
||||
@@ -43,9 +85,12 @@ class QuickFunctionsSection extends StatelessWidget {
|
||||
children: [
|
||||
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text('快捷功能',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
'快捷功能',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -59,7 +104,8 @@ class QuickFunctionsSection extends StatelessWidget {
|
||||
if (itemWidth < _minItemWidth) break;
|
||||
perRow = next;
|
||||
}
|
||||
final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow;
|
||||
final itemWidth =
|
||||
(availableWidth - _spacing * (perRow - 1)) / perRow;
|
||||
|
||||
return Wrap(
|
||||
spacing: _spacing,
|
||||
@@ -70,7 +116,13 @@ class QuickFunctionsSection extends StatelessWidget {
|
||||
child: _QuickFunctionCard(
|
||||
icon: fn.icon,
|
||||
label: fn.label,
|
||||
onTap: () => Navigator.of(context).pushNamed(fn.route),
|
||||
onTap: () {
|
||||
if (fn.onTap != null) {
|
||||
fn.onTap!(context);
|
||||
} else if (fn.route != null) {
|
||||
Navigator.of(context).pushNamed(fn.route!);
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
@@ -106,9 +158,7 @@ class _QuickFunctionCardState extends State<_QuickFunctionCard> {
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
color: _hovered
|
||||
? colorScheme.surfaceContainerHighest
|
||||
: null,
|
||||
color: _hovered ? colorScheme.surfaceContainerHighest : null,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:logger/logger.dart' show Level;
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../core/constants/storage_keys.dart';
|
||||
import '../../../core/utils/app_logger.dart';
|
||||
@@ -38,6 +39,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
String _logFilePath = '';
|
||||
int? _logFileSize;
|
||||
String _cacheDirPath = '';
|
||||
Level _logLevel = Level.info;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -46,6 +48,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
_loadWifiOnlySetting();
|
||||
_loadGravatarMirrorSetting();
|
||||
_loadLogInfo();
|
||||
_loadLogLevel();
|
||||
}
|
||||
|
||||
Future<void> _loadCacheSettings() async {
|
||||
@@ -127,6 +130,15 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLogLevel() async {
|
||||
final saved = await StorageService.instance.getString(StorageKeys.logLevel);
|
||||
if (saved != null && mounted) {
|
||||
setState(() {
|
||||
_logLevel = _parseLogLevel(saved);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -295,6 +307,13 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
_buildSection(
|
||||
title: '日志管理',
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tune),
|
||||
title: const Text('日志级别'),
|
||||
subtitle: Text(_logLevelLabel(_logLevel)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _pickLogLevel(),
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('日志文件路径'),
|
||||
subtitle: Text(
|
||||
@@ -883,4 +902,70 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
if (mounted) ToastHelper.success('日志已清空');
|
||||
}
|
||||
}
|
||||
|
||||
String _logLevelLabel(Level level) {
|
||||
return switch (level) {
|
||||
Level.error => 'Error — 仅错误',
|
||||
Level.warning => 'Warning — 错误 + 警告',
|
||||
Level.info => 'Info — 常规信息',
|
||||
Level.debug => 'Debug — 调试信息(含FFI交互)',
|
||||
Level.trace => 'Trace — 全量追踪',
|
||||
_ => level.name,
|
||||
};
|
||||
}
|
||||
|
||||
Level _parseLogLevel(String level) {
|
||||
return switch (level) {
|
||||
'error' => Level.error,
|
||||
'warning' => Level.warning,
|
||||
'info' => Level.info,
|
||||
'debug' => Level.debug,
|
||||
'trace' => Level.trace,
|
||||
_ => Level.info,
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _pickLogLevel() async {
|
||||
final levels = [
|
||||
(Level.error, 'Error — 仅错误'),
|
||||
(Level.warning, 'Warning — 错误 + 警告'),
|
||||
(Level.info, 'Info — 常规信息'),
|
||||
(Level.debug, 'Debug — 调试信息(含FFI)'),
|
||||
(Level.trace, 'Trace — 全量追踪'),
|
||||
];
|
||||
|
||||
final result = await showDialog<Level>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: const Text('日志级别'),
|
||||
children: levels.map((e) {
|
||||
final isSelected = _logLevel == e.$1;
|
||||
return SimpleDialogOption(
|
||||
onPressed: () => Navigator.pop(ctx, e.$1),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isSelected ? Icons.radio_button_checked : Icons.radio_button_unchecked,
|
||||
size: 20,
|
||||
color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(child: Text(e.$2)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
|
||||
if (result != null && result != _logLevel) {
|
||||
setState(() => _logLevel = result);
|
||||
AppLogger.setLevel(result);
|
||||
await StorageService.instance.setString(
|
||||
StorageKeys.logLevel,
|
||||
result.name,
|
||||
);
|
||||
if (mounted) ToastHelper.success('日志级别已切换为 ${_logLevelLabel(result)}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,11 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import '../../../data/models/user_model.dart';
|
||||
import '../../../data/models/user_setting_model.dart';
|
||||
import '../../../services/update_service.dart';
|
||||
import '../../../services/user_setting_service.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/user_setting_provider.dart';
|
||||
@@ -16,6 +18,8 @@ import 'file_preferences_page.dart';
|
||||
import 'app_settings_page.dart';
|
||||
import 'credit_history_page.dart';
|
||||
import 'quick_access_settings_page.dart';
|
||||
import 'network_test_page.dart';
|
||||
import '../../../router/app_router.dart';
|
||||
|
||||
/// 设置主页
|
||||
class SettingsPage extends StatefulWidget {
|
||||
@@ -82,106 +86,121 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
),
|
||||
body: DesktopConstrained(
|
||||
child: ListView(
|
||||
children: [
|
||||
_buildProfileCard(context, user, settings, capacity),
|
||||
const SizedBox(height: 8),
|
||||
_buildSection(
|
||||
title: '账户与安全',
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.person_outline,
|
||||
title: '个人资料',
|
||||
subtitle: '修改昵称、头像',
|
||||
onTap: () => _navigateTo(context, const ProfileEditPage()),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.security_outlined,
|
||||
title: '安全设置',
|
||||
subtitle: _securitySubtitle(settings),
|
||||
onTap: () => _navigateTo(context, const SecuritySettingsPage()),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildSection(
|
||||
title: '偏好',
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.apps_outlined,
|
||||
title: '快捷入口',
|
||||
subtitle: '自定义概览页快捷目录',
|
||||
onTap: () => _navigateTo(context, const QuickAccessSettingsPage()),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.folder_outlined,
|
||||
title: '文件偏好',
|
||||
subtitle: '版本保留、视图同步、分享可见性',
|
||||
onTap: () => _navigateTo(context, const FilePreferencesPage()),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.tune,
|
||||
title: '应用设置',
|
||||
subtitle: '缓存、主题、语言',
|
||||
onTap: () => _navigateTo(context, const AppSettingsPage()),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 财务区域(有数据时才显示)
|
||||
if (settings != null) ..._buildProSections(context, settings),
|
||||
_buildSection(
|
||||
title: '关于',
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('应用名称'),
|
||||
subtitle: const Text('公云存储'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.tag),
|
||||
title: const Text('版本号'),
|
||||
subtitle: Text(_appVersion.isEmpty ? '加载中...' : _appVersion),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copyright),
|
||||
title: const Text('License'),
|
||||
subtitle: const Text('AGPL-3.0'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.code),
|
||||
title: const Text('二次开发'),
|
||||
subtitle: const Text('gongyun_app'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 16),
|
||||
onTap: () {
|
||||
launchUrl(
|
||||
Uri.parse('https://git.saont.net/gongyun/app'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.code),
|
||||
title: const Text('基于'),
|
||||
subtitle: const Text('cloudreve4_flutter'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 16),
|
||||
onTap: () {
|
||||
launchUrl(
|
||||
Uri.parse('https://github.com/LimoYuan/cloudreve4_flutter'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_buildLogoutButton(context, auth),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
_buildProfileCard(context, user, settings, capacity),
|
||||
const SizedBox(height: 8),
|
||||
_buildSection(
|
||||
title: '账户与安全',
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.person_outline,
|
||||
title: '个人资料',
|
||||
subtitle: '修改昵称、头像',
|
||||
onTap: () => _navigateTo(context, const ProfileEditPage()),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.security_outlined,
|
||||
title: '安全设置',
|
||||
subtitle: _securitySubtitle(settings),
|
||||
onTap: () =>
|
||||
_navigateTo(context, const SecuritySettingsPage()),
|
||||
),
|
||||
],
|
||||
),
|
||||
_buildSection(
|
||||
title: '偏好',
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.sync_outlined,
|
||||
title: '文件同步',
|
||||
subtitle: '本地与云端文件自动同步',
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(RouteNames.syncSettings),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.apps_outlined,
|
||||
title: '快捷入口',
|
||||
subtitle: '自定义概览页快捷目录',
|
||||
onTap: () =>
|
||||
_navigateTo(context, const QuickAccessSettingsPage()),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.folder_outlined,
|
||||
title: '文件偏好',
|
||||
subtitle: '版本保留、视图同步、分享可见性',
|
||||
onTap: () =>
|
||||
_navigateTo(context, const FilePreferencesPage()),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.tune,
|
||||
title: '应用设置',
|
||||
subtitle: '缓存、主题、语言',
|
||||
onTap: () => _navigateTo(context, const AppSettingsPage()),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.network_check,
|
||||
title: '网络测试',
|
||||
subtitle: '服务连通性测试与自定义线路(会员)',
|
||||
onTap: () => _openNetworkTest(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
// 财务区域(有数据时才显示)
|
||||
if (settings != null) ..._buildProSections(context, settings),
|
||||
_buildSection(
|
||||
title: '关于',
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('应用名称'),
|
||||
subtitle: const Text('公云存储'),
|
||||
),
|
||||
_buildVersionTile(),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.copyright),
|
||||
title: const Text('License'),
|
||||
subtitle: const Text('AGPL-3.0'),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.code),
|
||||
title: const Text('二次开发'),
|
||||
subtitle: const Text('gongyun_app'),
|
||||
trailing: const Icon(Icons.open_in_new, size: 16),
|
||||
onTap: () {
|
||||
_openExternalUrl(
|
||||
Uri.parse('https://git.saont.net/gongyun/app'),
|
||||
);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.code),
|
||||
title: const Text('基于'),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 财务区域(存储包、积分、会员)
|
||||
List<Widget> _buildProSections(BuildContext context, UserSettingModel settings) {
|
||||
List<Widget> _buildProSections(
|
||||
BuildContext context,
|
||||
UserSettingModel settings,
|
||||
) {
|
||||
final sections = <Widget>[];
|
||||
final hasStoragePacks = settings.storagePacks.isNotEmpty;
|
||||
final hasCredit = settings.credit > 0;
|
||||
@@ -190,40 +209,54 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
if (hasStoragePacks || hasCredit || hasMembership) {
|
||||
final children = <Widget>[];
|
||||
if (hasMembership) {
|
||||
children.add(ListTile(
|
||||
leading: const Icon(Icons.workspace_premium_outlined),
|
||||
title: const Text('会员'),
|
||||
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _cancelMembership(context),
|
||||
child: const Text('取消会员', style: TextStyle(color: Colors.red)),
|
||||
children.add(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.workspace_premium_outlined),
|
||||
title: const Text('会员'),
|
||||
subtitle: Text('到期: ${_formatDate(settings.groupExpires!)}'),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _cancelMembership(context),
|
||||
child: const Text('取消会员', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
if (hasStoragePacks) {
|
||||
children.add(ListTile(
|
||||
leading: const Icon(Icons.inventory_2_outlined),
|
||||
title: Text('存储包 (${settings.storagePacks.length})'),
|
||||
subtitle: Text(_storagePackSummary(settings.storagePacks)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showStoragePacks(context, settings.storagePacks),
|
||||
));
|
||||
children.add(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.inventory_2_outlined),
|
||||
title: Text('存储包 (${settings.storagePacks.length})'),
|
||||
subtitle: Text(_storagePackSummary(settings.storagePacks)),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showStoragePacks(context, settings.storagePacks),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (hasCredit) {
|
||||
children.add(ListTile(
|
||||
leading: const Icon(Icons.account_balance_wallet_outlined),
|
||||
title: const Text('积分'),
|
||||
subtitle: Text('${settings.credit} 积分'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _navigateTo(context, CreditHistoryPage(currentCredit: settings.credit)),
|
||||
));
|
||||
children.add(
|
||||
ListTile(
|
||||
leading: const Icon(Icons.account_balance_wallet_outlined),
|
||||
title: const Text('积分'),
|
||||
subtitle: Text('${settings.credit} 积分'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _navigateTo(
|
||||
context,
|
||||
CreditHistoryPage(currentCredit: settings.credit),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
sections.add(_buildSection(title: '财务', children: children));
|
||||
}
|
||||
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 colorScheme = theme.colorScheme;
|
||||
|
||||
@@ -246,17 +279,24 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
children: [
|
||||
Text(
|
||||
user?.nickname ?? '未登录',
|
||||
style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
user?.email ?? '',
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: colorScheme.onSurfaceVariant),
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
if (user?.group != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -309,9 +349,12 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('存储空间', style: theme.textTheme.bodySmall),
|
||||
Text('$usedText / $totalText', style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
)),
|
||||
Text(
|
||||
'$usedText / $totalText',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
@@ -327,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(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Column(
|
||||
@@ -353,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) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _confirmLogout(context, auth),
|
||||
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(
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
side: BorderSide(color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
side: BorderSide(
|
||||
color: Theme.of(context).colorScheme.error.withValues(alpha: 0.3),
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -400,33 +472,47 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: Text('存储包', style: Theme.of(ctx).textTheme.titleMedium),
|
||||
),
|
||||
...packs.map((pack) => Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(pack.name, style: Theme.of(ctx).textTheme.titleSmall),
|
||||
Text(_formatBytes(pack.size), style: Theme.of(ctx).textTheme.labelLarge),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'激活: ${_formatDate(pack.activeSince)}'
|
||||
'${pack.expireAt != null ? " · 到期: ${_formatDate(pack.expireAt!)}" : " · 永久"}',
|
||||
style: Theme.of(ctx).textTheme.bodySmall,
|
||||
),
|
||||
if (pack.isExpired) ...[
|
||||
...packs.map(
|
||||
(pack) => Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
pack.name,
|
||||
style: Theme.of(ctx).textTheme.titleSmall,
|
||||
),
|
||||
Text(
|
||||
_formatBytes(pack.size),
|
||||
style: Theme.of(ctx).textTheme.labelLarge,
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
],
|
||||
),
|
||||
@@ -442,10 +528,15 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
title: const Text('取消会员'),
|
||||
content: const Text('确定要取消当前会员吗?取消后将失去会员权益。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
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('确认取消'),
|
||||
),
|
||||
],
|
||||
@@ -472,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 {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -479,10 +623,15 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
title: const Text('退出登录'),
|
||||
content: const Text('确定要退出当前账号吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
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('退出'),
|
||||
),
|
||||
],
|
||||
@@ -499,7 +648,9 @@ class _SettingsPageState extends State<SettingsPage> {
|
||||
String _formatBytes(int bytes) {
|
||||
if (bytes < 1024) return '$bytes B';
|
||||
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';
|
||||
}
|
||||
|
||||
@@ -508,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 {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,52 @@
|
||||
import 'package:animations/animations.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/admin_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/auth_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/download_manager_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/sync_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/user_setting_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/announcement_dialog.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart';
|
||||
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/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../router/app_router.dart';
|
||||
import '../files/files_page.dart';
|
||||
import '../overview/overview_page.dart';
|
||||
import '../sync/sync_page.dart';
|
||||
import '../tasks/tasks_page.dart';
|
||||
import '../profile/profile_page.dart';
|
||||
|
||||
class _ShellPageSlot extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const _ShellPageSlot({required this.child});
|
||||
|
||||
@override
|
||||
State<_ShellPageSlot> createState() => _ShellPageSlotState();
|
||||
}
|
||||
|
||||
class _ShellPageSlotState extends State<_ShellPageSlot>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
class AppShell extends StatefulWidget {
|
||||
const AppShell({super.key});
|
||||
|
||||
@@ -23,30 +54,232 @@ class AppShell extends StatefulWidget {
|
||||
State<AppShell> createState() => _AppShellState();
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
class _AppShellState extends State<AppShell>
|
||||
with GestureHandlerMixin, TickerProviderStateMixin, WidgetsBindingObserver {
|
||||
final Set<int> _visitedPageIndexes = <int>{0};
|
||||
late AnimationController _syncSpinController;
|
||||
String? _lastClipboardShareId;
|
||||
String? _lastUserId;
|
||||
bool _cachedShowSyncTab = false;
|
||||
|
||||
static bool _shouldShowSyncTab(double screenWidth) {
|
||||
if (defaultTargetPlatform != TargetPlatform.android &&
|
||||
defaultTargetPlatform != TargetPlatform.iOS) {
|
||||
return true;
|
||||
}
|
||||
return screenWidth >= 800;
|
||||
}
|
||||
|
||||
List<Widget> _pages(bool showSyncTab) => showSyncTab
|
||||
? [
|
||||
const OverviewPage(),
|
||||
const FilesPage(),
|
||||
const TasksPage(),
|
||||
const SyncPage(),
|
||||
const ProfilePage(),
|
||||
]
|
||||
: [
|
||||
const OverviewPage(),
|
||||
const FilesPage(),
|
||||
const TasksPage(),
|
||||
const ProfilePage(),
|
||||
];
|
||||
|
||||
int _clampedIndex(int index) {
|
||||
final maxIndex = _pages(_cachedShowSyncTab).length - 1;
|
||||
if (index < 0) return 0;
|
||||
if (index > maxIndex) return maxIndex;
|
||||
return index;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_syncSpinController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showPostLoginAnnouncement();
|
||||
_checkClipboardShareLink();
|
||||
_lastUserId = context.read<AuthProvider>().user?.id;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_syncSpinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_checkClipboardShareLink();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTabSelected(int index) {
|
||||
final nav = Provider.of<NavigationProvider>(context, listen: false);
|
||||
nav.setIndex(index);
|
||||
|
||||
final userSetting = Provider.of<UserSettingProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
if (index == 0 || index == _pages(_cachedShowSyncTab).length - 1) {
|
||||
userSetting.loadCapacity();
|
||||
}
|
||||
}
|
||||
|
||||
void _checkUserChange(AuthProvider auth) {
|
||||
final currentUserId = auth.user?.id;
|
||||
if (_lastUserId != null && currentUserId != _lastUserId) {
|
||||
_lastUserId = currentUserId;
|
||||
Future.microtask(() {
|
||||
if (mounted) _resetProvidersOnUserChange();
|
||||
});
|
||||
} else if (_lastUserId == null && currentUserId != null) {
|
||||
_lastUserId = currentUserId;
|
||||
}
|
||||
}
|
||||
|
||||
void _resetProvidersOnUserChange() {
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final userSetting = Provider.of<UserSettingProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final admin = Provider.of<AdminProvider>(context, listen: false);
|
||||
final sync = Provider.of<SyncProvider>(context, listen: false);
|
||||
|
||||
fileManager.clearFiles();
|
||||
userSetting.clear();
|
||||
admin.clear();
|
||||
|
||||
if (sync.engineInitialized) {
|
||||
sync.resetSync();
|
||||
}
|
||||
|
||||
final nav = Provider.of<NavigationProvider>(context, listen: false);
|
||||
nav.setIndex(0);
|
||||
userSetting.loadCapacity();
|
||||
}
|
||||
|
||||
Future<void> _showPostLoginAnnouncement() async {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
if (!authProvider.isAuthenticated) return;
|
||||
|
||||
try {
|
||||
final service = AnnouncementService.instance;
|
||||
final announcement = await service.getChangedSiteNotice();
|
||||
if (!mounted || announcement == null) return;
|
||||
|
||||
await DialogQueueService.instance.enqueue<void>(() async {
|
||||
if (!mounted) return;
|
||||
|
||||
await AnnouncementDialog.show(
|
||||
context,
|
||||
title: announcement.title,
|
||||
html: announcement.html,
|
||||
baseUrl: announcement.baseUrl,
|
||||
);
|
||||
|
||||
await service.markDismissed(announcement);
|
||||
});
|
||||
} catch (_) {
|
||||
// Announcement checks should never block the shell.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkClipboardShareLink() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 650));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final candidate = ShareLinkService.instance.parseShareLink(data?.text);
|
||||
|
||||
if (candidate == null) return;
|
||||
if (_lastClipboardShareId == candidate.id) return;
|
||||
|
||||
_lastClipboardShareId = candidate.id;
|
||||
|
||||
await DialogQueueService.instance.enqueue<void>(() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final open = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('检测到分享链接'),
|
||||
content: Text(
|
||||
'是否打开这个文件分享?\n\n${candidate.url}',
|
||||
maxLines: 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('忽略'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('打开'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (open == true && mounted) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ShareLinkPage(candidate: candidate),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Clipboard access failures should not affect the shell.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final isDesktop = screenWidth >= 1000;
|
||||
_cachedShowSyncTab = _shouldShowSyncTab(screenWidth);
|
||||
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) async {
|
||||
if (!didPop) {
|
||||
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final navProvider = Provider.of<NavigationProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
if (navProvider.currentIndex == 1 && fileManager.currentPath != '/') {
|
||||
await fileManager.goBack();
|
||||
} else if (navProvider.currentIndex != 0 && navProvider.currentIndex != 1) {
|
||||
} else if (navProvider.currentIndex != 0 &&
|
||||
navProvider.currentIndex != 1) {
|
||||
navProvider.setIndex(0);
|
||||
} else {
|
||||
await checkExitApp(context);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Consumer<NavigationProvider>(
|
||||
builder: (context, navProvider, _) {
|
||||
child: Consumer2<AuthProvider, NavigationProvider>(
|
||||
builder: (context, auth, navProvider, _) {
|
||||
_checkUserChange(auth);
|
||||
if (isDesktop) {
|
||||
return _buildDesktopLayout(context, navProvider);
|
||||
}
|
||||
@@ -57,43 +290,75 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
}
|
||||
|
||||
Widget _buildPageContent(BuildContext context, int currentIndex) {
|
||||
const pages = [
|
||||
OverviewPage(),
|
||||
FilesPage(),
|
||||
TasksPage(),
|
||||
ProfilePage(),
|
||||
];
|
||||
final pages = _pages(_cachedShowSyncTab);
|
||||
final visibleIndex = _clampedIndex(currentIndex);
|
||||
_visitedPageIndexes.add(visibleIndex);
|
||||
|
||||
return PageTransitionSwitcher(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
transitionBuilder: (child, animation, secondaryAnimation) {
|
||||
return SharedAxisTransition(
|
||||
animation: animation,
|
||||
secondaryAnimation: secondaryAnimation,
|
||||
transitionType: SharedAxisTransitionType.horizontal,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(currentIndex),
|
||||
child: pages[currentIndex],
|
||||
return RepaintBoundary(
|
||||
child: IndexedStack(
|
||||
index: visibleIndex,
|
||||
children: List.generate(pages.length, (index) {
|
||||
if (!_visitedPageIndexes.contains(index)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return _ShellPageSlot(child: pages[index]);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout(BuildContext context, NavigationProvider navProvider) {
|
||||
Widget _buildSyncIcon({required bool isSelected, required double size}) {
|
||||
return Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final hasWorkers = sync.activeWorkerCount > 0;
|
||||
|
||||
if (hasWorkers && !_syncSpinController.isAnimating) {
|
||||
_syncSpinController.repeat();
|
||||
} else if (!hasWorkers && _syncSpinController.isAnimating) {
|
||||
_syncSpinController.stop();
|
||||
_syncSpinController.value = 0;
|
||||
}
|
||||
|
||||
final icon = Icon(
|
||||
LucideIcons.refreshCw,
|
||||
size: size,
|
||||
weight: isSelected ? 700 : 400,
|
||||
);
|
||||
|
||||
if (!hasWorkers) return icon;
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: _syncSpinController,
|
||||
builder: (context, child) {
|
||||
return Transform.rotate(
|
||||
angle: _syncSpinController.value * 2 * 3.14159265,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: icon,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileLayout(
|
||||
BuildContext context,
|
||||
NavigationProvider navProvider,
|
||||
) {
|
||||
return Scaffold(
|
||||
body: _buildPageContent(context, navProvider.currentIndex),
|
||||
bottomNavigationBar: GlassmorphismContainer(
|
||||
borderRadius: 0,
|
||||
child: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
|
||||
builder: (context, uploadManager, downloadManager, _) {
|
||||
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount;
|
||||
final activeCount =
|
||||
uploadManager.activeTasks.length +
|
||||
downloadManager.downloadingCount;
|
||||
|
||||
return NavigationBar(
|
||||
height: 64,
|
||||
selectedIndex: navProvider.currentIndex,
|
||||
onDestinationSelected: (i) => navProvider.setIndex(i),
|
||||
selectedIndex: _clampedIndex(navProvider.currentIndex),
|
||||
onDestinationSelected: _handleTabSelected,
|
||||
destinations: [
|
||||
const NavigationDestination(
|
||||
icon: Icon(LucideIcons.layoutDashboard),
|
||||
@@ -118,6 +383,30 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
),
|
||||
label: '任务',
|
||||
),
|
||||
if (_cachedShowSyncTab)
|
||||
NavigationDestination(
|
||||
icon: Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final count = sync.activeWorkerCount;
|
||||
return Badge(
|
||||
isLabelVisible: count > 0,
|
||||
label: Text('$count'),
|
||||
child: _buildSyncIcon(isSelected: false, size: 24),
|
||||
);
|
||||
},
|
||||
),
|
||||
selectedIcon: Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final count = sync.activeWorkerCount;
|
||||
return Badge(
|
||||
isLabelVisible: count > 0,
|
||||
label: Text('$count'),
|
||||
child: _buildSyncIcon(isSelected: true, size: 24),
|
||||
);
|
||||
},
|
||||
),
|
||||
label: '同步',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(LucideIcons.user),
|
||||
selectedIcon: Icon(LucideIcons.user, weight: 700),
|
||||
@@ -131,7 +420,10 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDesktopLayout(BuildContext context, NavigationProvider navProvider) {
|
||||
Widget _buildDesktopLayout(
|
||||
BuildContext context,
|
||||
NavigationProvider navProvider,
|
||||
) {
|
||||
final theme = Theme.of(context);
|
||||
final authProvider = context.watch<AuthProvider>();
|
||||
final user = authProvider.user;
|
||||
@@ -141,16 +433,19 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
body: Row(
|
||||
children: [
|
||||
NavigationRail(
|
||||
selectedIndex: navProvider.currentIndex,
|
||||
onDestinationSelected: (i) => navProvider.setIndex(i),
|
||||
selectedIndex: _clampedIndex(navProvider.currentIndex),
|
||||
onDestinationSelected: _handleTabSelected,
|
||||
leading: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: GestureDetector(
|
||||
onTap: () => navProvider.setIndex(3),
|
||||
onTap: () =>
|
||||
navProvider.setIndex(_pages(_cachedShowSyncTab).length - 1),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: navProvider.currentIndex == 3
|
||||
border:
|
||||
navProvider.currentIndex ==
|
||||
_pages(_cachedShowSyncTab).length - 1
|
||||
? Border.all(
|
||||
color: theme.colorScheme.primary,
|
||||
width: 2.5,
|
||||
@@ -180,7 +475,9 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
NavigationRailDestination(
|
||||
icon: Consumer2<UploadManagerProvider, DownloadManagerProvider>(
|
||||
builder: (context, uploadManager, downloadManager, _) {
|
||||
final activeCount = uploadManager.activeTasks.length + downloadManager.downloadingCount;
|
||||
final activeCount =
|
||||
uploadManager.activeTasks.length +
|
||||
downloadManager.downloadingCount;
|
||||
return Badge(
|
||||
isLabelVisible: activeCount > 0,
|
||||
label: Text('$activeCount'),
|
||||
@@ -191,6 +488,30 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
selectedIcon: const Icon(LucideIcons.listChecks, weight: 700),
|
||||
label: const Text('任务'),
|
||||
),
|
||||
if (_cachedShowSyncTab)
|
||||
NavigationRailDestination(
|
||||
icon: Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final count = sync.activeWorkerCount;
|
||||
return Badge(
|
||||
isLabelVisible: count > 0,
|
||||
label: Text('$count'),
|
||||
child: _buildSyncIcon(isSelected: false, size: 24),
|
||||
);
|
||||
},
|
||||
),
|
||||
selectedIcon: Consumer<SyncProvider>(
|
||||
builder: (context, sync, _) {
|
||||
final count = sync.activeWorkerCount;
|
||||
return Badge(
|
||||
isLabelVisible: count > 0,
|
||||
label: Text('$count'),
|
||||
child: _buildSyncIcon(isSelected: true, size: 24),
|
||||
);
|
||||
},
|
||||
),
|
||||
label: const Text('同步'),
|
||||
),
|
||||
const NavigationRailDestination(
|
||||
icon: Icon(LucideIcons.user),
|
||||
selectedIcon: Icon(LucideIcons.user, weight: 700),
|
||||
@@ -206,32 +527,38 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
context,
|
||||
icon: LucideIcons.share2,
|
||||
label: '我的分享',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.share),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(RouteNames.share),
|
||||
),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
icon: LucideIcons.cloud,
|
||||
label: 'WebDAV',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.webdav),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(RouteNames.webdav),
|
||||
),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
icon: LucideIcons.download,
|
||||
label: '离线下载',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.remoteDownload),
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteNames.remoteDownload),
|
||||
),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
icon: LucideIcons.trash2,
|
||||
label: '回收站',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.recycleBin),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(RouteNames.recycleBin),
|
||||
),
|
||||
const Divider(indent: 12, endIndent: 12),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
icon: LucideIcons.settings,
|
||||
label: '设置',
|
||||
onTap: () => Navigator.of(context).pushNamed(RouteNames.settings),
|
||||
onTap: () =>
|
||||
Navigator.of(context).pushNamed(RouteNames.settings),
|
||||
),
|
||||
_buildSecondaryNavItem(
|
||||
context,
|
||||
@@ -245,9 +572,7 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
),
|
||||
),
|
||||
const VerticalDivider(thickness: 1, width: 1),
|
||||
Expanded(
|
||||
child: _buildPageContent(context, navProvider.currentIndex),
|
||||
),
|
||||
Expanded(child: _buildPageContent(context, navProvider.currentIndex)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -278,7 +603,10 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
|
||||
Future<void> _handleLogout(BuildContext context) async {
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@@ -302,7 +630,9 @@ class _AppShellState extends State<AppShell> with GestureHandlerMixin {
|
||||
await authProvider.logout();
|
||||
fileManager.clearFiles();
|
||||
if (context.mounted) {
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamedAndRemoveUntil(RouteNames.login, (route) => false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../../../services/api_service.dart';
|
||||
import '../../../services/server_service.dart';
|
||||
import '../../../services/storage_service.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/sync_provider.dart';
|
||||
|
||||
/// 启动页
|
||||
class SplashPage extends StatefulWidget {
|
||||
@@ -45,6 +46,16 @@ class _SplashPageState extends State<SplashPage> {
|
||||
if (!mounted) return;
|
||||
|
||||
if (authProvider.isAuthenticated) {
|
||||
// 自动恢复同步(如果之前处于同步状态)
|
||||
if (!mounted) return;
|
||||
final syncProvider = Provider.of<SyncProvider>(context, listen: false);
|
||||
final token = authProvider.token;
|
||||
await syncProvider.autoResumeIfNeeded(
|
||||
currentAccessToken: token?.accessToken,
|
||||
currentRefreshToken: token?.refreshToken,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacementNamed(RouteNames.home);
|
||||
} else {
|
||||
Navigator.of(context).pushReplacementNamed(RouteNames.login);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
|
||||
/// 同步引擎日志预览页面
|
||||
class SyncLogViewerPage extends StatefulWidget {
|
||||
const SyncLogViewerPage({super.key});
|
||||
|
||||
@override
|
||||
State<SyncLogViewerPage> createState() => _SyncLogViewerPageState();
|
||||
}
|
||||
|
||||
class _SyncLogViewerPageState extends State<SyncLogViewerPage> {
|
||||
String _logContent = '';
|
||||
bool _isLoading = true;
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLog();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<String> _getSyncLogPath() async {
|
||||
final appDir = await getApplicationSupportDirectory();
|
||||
return '${appDir.path}${Platform.pathSeparator}sync_core${Platform.pathSeparator}logs${Platform.pathSeparator}sync_log.txt';
|
||||
}
|
||||
|
||||
Future<void> _loadLog() async {
|
||||
setState(() => _isLoading = true);
|
||||
try {
|
||||
final path = await _getSyncLogPath();
|
||||
final file = File(path);
|
||||
if (!await file.exists()) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logContent = '';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final lines = await file.readAsLines();
|
||||
const maxLines = 1000;
|
||||
String content;
|
||||
if (lines.length <= maxLines) {
|
||||
content = lines.join('\n');
|
||||
} else {
|
||||
content = '... (仅显示最近 $maxLines 行)\n\n'
|
||||
'${lines.sublist(lines.length - maxLines).join('\n')}';
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logContent = content;
|
||||
_isLoading = false;
|
||||
});
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_scrollController.hasClients) {
|
||||
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
ToastHelper.error('读取同步日志失败:$e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isDark = theme.brightness == Brightness.dark;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('同步日志'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: _loadLog,
|
||||
tooltip: '刷新',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _logContent.isEmpty
|
||||
? Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.description_outlined,
|
||||
size: 48, color: theme.hintColor.withValues(alpha: 0.4)),
|
||||
const SizedBox(height: 16),
|
||||
Text('暂无同步日志', style: TextStyle(color: theme.hintColor)),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
color: isDark ? const Color(0xFF1E1E1E) : const Color(0xFFF5F5F5),
|
||||
child: Scrollbar(
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: SelectableText(
|
||||
_logContent,
|
||||
style: TextStyle(
|
||||
fontFamily: 'SourceCodePro',
|
||||
fontSize: 13,
|
||||
height: 1.5,
|
||||
color: isDark ? Colors.grey[300] : Colors.grey[900],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,795 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../../data/models/sync_task_model.dart';
|
||||
import '../../providers/sync_provider.dart';
|
||||
import '../../widgets/sync_stats_card.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
import 'sync_settings_page.dart';
|
||||
|
||||
/// 同步 Tab 页 - 展示实时同步状态、活跃任务和已完成任务
|
||||
class SyncPage extends StatefulWidget {
|
||||
const SyncPage({super.key});
|
||||
|
||||
@override
|
||||
State<SyncPage> createState() => _SyncPageState();
|
||||
}
|
||||
|
||||
class _SyncPageState extends State<SyncPage> {
|
||||
// 展开的任务 ID
|
||||
final Set<String> _expandedTasks = {};
|
||||
// 正在加载详情的任务 ID
|
||||
final Set<String> _loadingDetails = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SyncProvider>().loadRecentTasks();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sync = context.watch<SyncProvider>();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('文件同步'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => _navigateToSettings(),
|
||||
tooltip: '同步设置',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
final sync = context.read<SyncProvider>();
|
||||
sync.invalidateAllTaskDetails();
|
||||
await sync.loadRecentTasks();
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= 800;
|
||||
final statsCard = SyncStatsCard(
|
||||
uploaded: sync.cumUploaded,
|
||||
downloaded: sync.cumDownloaded,
|
||||
renamed: sync.cumRenamed,
|
||||
moved: sync.cumMoved,
|
||||
conflicts: sync.cumConflicts,
|
||||
failed: sync.cumFailed,
|
||||
deletedLocal: sync.cumDeletedLocal,
|
||||
deletedRemote: sync.cumDeletedRemote,
|
||||
skipped: sync.cumSkipped,
|
||||
);
|
||||
|
||||
if (isWide) {
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(child: _buildStatusHeaderCard(sync, theme)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: statsCard),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
_buildStatusHeaderCard(sync, theme),
|
||||
const SizedBox(height: 8),
|
||||
statsCard,
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildActiveTasksSection(sync, theme),
|
||||
const SizedBox(height: 8),
|
||||
_buildCompletedTasksSection(sync, theme),
|
||||
const SizedBox(height: 32),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToSettings() {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SyncSettingsPage()));
|
||||
}
|
||||
|
||||
Widget _buildStatusHeaderCard(SyncProvider sync, ThemeData theme) {
|
||||
final isActive = sync.isActive;
|
||||
final isPaused = sync.isPaused;
|
||||
final hasError = sync.hasError;
|
||||
|
||||
Color statusColor;
|
||||
String statusText;
|
||||
|
||||
if (isActive) {
|
||||
statusColor = theme.colorScheme.primary;
|
||||
statusText = _syncModeLabel(sync);
|
||||
} else if (isPaused) {
|
||||
statusColor = Colors.orange;
|
||||
statusText = '已暂停';
|
||||
} else if (hasError) {
|
||||
statusColor = theme.colorScheme.error;
|
||||
statusText = '同步错误';
|
||||
} else if (sync.state == SyncState.stopped) {
|
||||
statusColor = theme.disabledColor;
|
||||
statusText = '已停止';
|
||||
} else {
|
||||
statusColor = theme.disabledColor;
|
||||
statusText = '未启动';
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
statusText,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (sync.errorMessage != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: Text(
|
||||
sync.errorMessage!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isActive)
|
||||
SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: CircularProgressIndicator(
|
||||
value: sync.activeTotalCount > 0
|
||||
? sync.activeProgress
|
||||
: null,
|
||||
strokeWidth: 6,
|
||||
strokeCap: StrokeCap.round,
|
||||
backgroundColor:
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
statusColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: CircularProgressIndicator(
|
||||
value: 0,
|
||||
strokeWidth: 6,
|
||||
backgroundColor:
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
statusColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isActive && sync.activeTotalCount > 0)
|
||||
Text(
|
||||
'${(sync.activeProgress * 100).toInt()}%',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isActive
|
||||
? (sync.state == SyncState.continuous
|
||||
? '持续同步'
|
||||
: '同步中')
|
||||
: isPaused
|
||||
? '已暂停'
|
||||
: hasError
|
||||
? '错误'
|
||||
: '未启动',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
IntrinsicWidth(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildStatRow(
|
||||
Icons.file_upload_outlined,
|
||||
'${sync.cumUploaded}',
|
||||
'已上传',
|
||||
Colors.blue,
|
||||
theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatRow(
|
||||
Icons.file_download_outlined,
|
||||
'${sync.cumDownloaded}',
|
||||
'已下载',
|
||||
Colors.green,
|
||||
theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatRow(
|
||||
Icons.warning_amber_outlined,
|
||||
'${sync.cumConflicts}',
|
||||
'冲突',
|
||||
Colors.orange,
|
||||
theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isActive && sync.currentFile != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
sync.currentFile!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
if (isActive && sync.activeTotalCount > 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${sync.activeCompletedCount} / ${sync.activeTotalCount} 文件',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
if (!sync.isActive && !sync.isPaused)
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _navigateToSettings(),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('开始同步'),
|
||||
),
|
||||
),
|
||||
if (sync.isActive)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => sync.pause(),
|
||||
icon: const Icon(Icons.pause, size: 18),
|
||||
label: const Text('暂停'),
|
||||
),
|
||||
),
|
||||
if (sync.isPaused)
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => sync.resume(),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('恢复'),
|
||||
),
|
||||
),
|
||||
if (sync.isActive || sync.isPaused) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _stopSync(sync),
|
||||
icon: const Icon(Icons.stop, size: 18),
|
||||
label: const Text('停止'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow(
|
||||
IconData icon,
|
||||
String value,
|
||||
String label,
|
||||
Color color,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _syncModeLabel(SyncProvider sync) {
|
||||
final mode = sync.persistedConfig?.syncMode ?? 'full';
|
||||
return switch (mode) {
|
||||
'full' => '全量同步中',
|
||||
'upload_only' => '仅上传中',
|
||||
'download_only' => '仅下载中',
|
||||
'album' => '相册同步中',
|
||||
'mirror_wcf' => '镜像同步中',
|
||||
_ => '同步中',
|
||||
};
|
||||
}
|
||||
|
||||
Widget _buildActiveTasksSection(SyncProvider sync, ThemeData theme) {
|
||||
final tasks = sync.activeTasks;
|
||||
|
||||
return _buildTaskSection(
|
||||
theme: theme,
|
||||
title: '正在同步',
|
||||
icon: LucideIcons.loader,
|
||||
tasks: tasks,
|
||||
emptyText: '暂无正在进行的同步任务',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCompletedTasksSection(SyncProvider sync, ThemeData theme) {
|
||||
final tasks = sync.recentTasks
|
||||
.where(
|
||||
(t) =>
|
||||
(t.status == 'completed' ||
|
||||
t.status == 'failed' ||
|
||||
t.status == 'cancelled') &&
|
||||
t.totalCount > 0,
|
||||
)
|
||||
.toList();
|
||||
|
||||
return _buildTaskSection(
|
||||
theme: theme,
|
||||
title: '已完成',
|
||||
icon: LucideIcons.checkCircle2,
|
||||
tasks: tasks,
|
||||
emptyText: '暂无已完成的同步任务',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskSection({
|
||||
required ThemeData theme,
|
||||
required String title,
|
||||
required IconData icon,
|
||||
required List<SyncTaskModel> tasks,
|
||||
required String emptyText,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (tasks.isEmpty)
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: Text(
|
||||
emptyText,
|
||||
style: TextStyle(color: theme.hintColor),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
...tasks.map((task) => _buildTaskCard(task, theme)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskCard(SyncTaskModel task, ThemeData theme) {
|
||||
final isExpanded = _expandedTasks.contains(task.id);
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => _toggleTaskExpand(task.id),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Clipboard.setData(ClipboardData(text: task.id));
|
||||
ToastHelper.success('已复制任务 ID');
|
||||
},
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 72,
|
||||
child: Text(
|
||||
task.shortId,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.copy, size: 14, color: theme.hintColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _statusColor(
|
||||
task.status,
|
||||
theme,
|
||||
).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
task.statusLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _statusColor(task.status, theme),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${task.completedCount}/${task.totalCount}',
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
if (task.failedCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'失败${task.failedCount}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 20,
|
||||
color: theme.hintColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isExpanded) _buildTaskDetailList(task, theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskDetailList(SyncTaskModel task, ThemeData theme) {
|
||||
final sync = context.watch<SyncProvider>();
|
||||
final items = sync.getCachedTaskDetail(task.id);
|
||||
|
||||
if (_loadingDetails.contains(task.id)) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (items == null || items.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('暂无任务项', style: TextStyle(color: theme.hintColor)),
|
||||
);
|
||||
}
|
||||
|
||||
final hasMore = sync.hasMoreTaskDetail(task.id);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
const Divider(height: 1, indent: 16, endIndent: 16),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Text(
|
||||
'操作',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'文件名',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 55,
|
||||
child: Text(
|
||||
'状态',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
'时间',
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
...items.map((item) => _buildTaskItemRow(item, theme)),
|
||||
if (hasMore)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _loadMoreDetail(task.id),
|
||||
icon: const Icon(Icons.expand_more, size: 16),
|
||||
label: const Text('加载更多'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskItemRow(SyncTaskItemModel item, ThemeData theme) {
|
||||
final timeStr = _formatTime(item.updatedAt);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 70,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _actionColor(
|
||||
item.actionType,
|
||||
theme,
|
||||
).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
child: Text(
|
||||
item.actionLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 11,
|
||||
color: _actionColor(item.actionType, theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.filename,
|
||||
style: theme.textTheme.bodySmall,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 55,
|
||||
child: Text(
|
||||
item.statusLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: _itemStatusColor(item.status, theme),
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
timeStr,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontFamily: 'monospace',
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Color _statusColor(String status, ThemeData theme) {
|
||||
return switch (status) {
|
||||
'running' => theme.colorScheme.primary,
|
||||
'completed' => Colors.green,
|
||||
'failed' => theme.colorScheme.error,
|
||||
'cancelled' => theme.hintColor,
|
||||
_ => theme.hintColor,
|
||||
};
|
||||
}
|
||||
|
||||
Color _actionColor(String actionType, ThemeData theme) {
|
||||
return switch (actionType) {
|
||||
'upload' => Colors.blue,
|
||||
'download' => Colors.green,
|
||||
'create_placeholder' => Colors.teal,
|
||||
'delete_local' || 'delete_remote' => theme.colorScheme.error,
|
||||
'rename' || 'move' => Colors.orange,
|
||||
'conflict_resolve' => Colors.purple,
|
||||
_ => theme.colorScheme.primary,
|
||||
};
|
||||
}
|
||||
|
||||
Color _itemStatusColor(String status, ThemeData theme) {
|
||||
return switch (status) {
|
||||
'completed' => Colors.green,
|
||||
'failed' => theme.colorScheme.error,
|
||||
'running' => theme.colorScheme.primary,
|
||||
_ => theme.hintColor,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatTime(String isoTime) {
|
||||
try {
|
||||
final dt = DateTime.parse(isoTime).toLocal();
|
||||
return '${dt.year}/${dt.month.toString().padLeft(2, '0')}/${dt.day.toString().padLeft(2, '0')} '
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}:${dt.second.toString().padLeft(2, '0')}';
|
||||
} catch (_) {
|
||||
return isoTime;
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleTaskExpand(String taskId) {
|
||||
final sync = context.read<SyncProvider>();
|
||||
if (_expandedTasks.contains(taskId)) {
|
||||
setState(() => _expandedTasks.remove(taskId));
|
||||
sync.unwatchTaskDetail(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _expandedTasks.add(taskId));
|
||||
sync.watchTaskDetail(taskId);
|
||||
|
||||
if (sync.getCachedTaskDetail(taskId) == null &&
|
||||
!_loadingDetails.contains(taskId)) {
|
||||
setState(() => _loadingDetails.add(taskId));
|
||||
sync.getTaskDetail(taskId).whenComplete(() {
|
||||
if (mounted) {
|
||||
setState(() => _loadingDetails.remove(taskId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _loadMoreDetail(String taskId) {
|
||||
final sync = context.read<SyncProvider>();
|
||||
sync.loadMoreTaskDetail(taskId);
|
||||
}
|
||||
|
||||
Future<void> _stopSync(SyncProvider sync) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('停止同步'),
|
||||
content: const Text('确定要停止文件同步吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
),
|
||||
child: const Text('停止'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await sync.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,768 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../data/models/sync_task_model.dart';
|
||||
import '../../providers/sync_provider.dart';
|
||||
import '../../widgets/sync_stats_card.dart';
|
||||
import 'sync_settings_page.dart';
|
||||
|
||||
/// 移动端同步详情页面 - 展示实时同步状态和任务列表
|
||||
class SyncPageAndroid extends StatefulWidget {
|
||||
const SyncPageAndroid({super.key});
|
||||
|
||||
@override
|
||||
State<SyncPageAndroid> createState() => _SyncPageAndroidState();
|
||||
}
|
||||
|
||||
class _SyncPageAndroidState extends State<SyncPageAndroid> {
|
||||
final Set<String> _expandedTasks = {};
|
||||
final Set<String> _loadingDetails = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SyncProvider>().loadRecentTasks();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sync = context.watch<SyncProvider>();
|
||||
final theme = Theme.of(context);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('文件同步'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => _navigateToSettings(),
|
||||
tooltip: '同步设置',
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
final sync = context.read<SyncProvider>();
|
||||
sync.invalidateAllTaskDetails();
|
||||
await sync.loadRecentTasks();
|
||||
},
|
||||
child: CustomScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
|
||||
child: _buildHeader(sync, theme),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||
child: SyncStatsCard(
|
||||
uploaded: sync.cumUploaded,
|
||||
downloaded: sync.cumDownloaded,
|
||||
renamed: sync.cumRenamed,
|
||||
moved: sync.cumMoved,
|
||||
conflicts: sync.cumConflicts,
|
||||
failed: sync.cumFailed,
|
||||
deletedLocal: sync.cumDeletedLocal,
|
||||
deletedRemote: sync.cumDeletedRemote,
|
||||
skipped: sync.cumSkipped,
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sync_outlined,
|
||||
size: 18,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'同步任务',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildTaskList(sync, theme),
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 100)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToSettings() {
|
||||
Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute(builder: (_) => const SyncSettingsPage()));
|
||||
}
|
||||
|
||||
Widget _buildHeader(SyncProvider sync, ThemeData theme) {
|
||||
final isActive = sync.isActive;
|
||||
final isPaused = sync.isPaused;
|
||||
final hasError = sync.hasError;
|
||||
|
||||
Color statusColor;
|
||||
String statusText;
|
||||
|
||||
if (isActive) {
|
||||
statusColor = theme.colorScheme.primary;
|
||||
statusText = _syncModeLabel(sync);
|
||||
} else if (isPaused) {
|
||||
statusColor = Colors.orange;
|
||||
statusText = '已暂停';
|
||||
} else if (hasError) {
|
||||
statusColor = theme.colorScheme.error;
|
||||
statusText = '同步错误';
|
||||
} else if (sync.state == SyncState.stopped) {
|
||||
statusColor = theme.disabledColor;
|
||||
statusText = '已停止';
|
||||
} else {
|
||||
statusColor = theme.disabledColor;
|
||||
statusText = '未启动';
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(24, 16, 24, 28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 顶部:状态文字居中
|
||||
Center(
|
||||
child: Text(
|
||||
statusText,
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 中间:左侧旋转圆 + 右侧统计
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 左侧:旋转圆形指示器
|
||||
SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
if (isActive)
|
||||
SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: CircularProgressIndicator(
|
||||
value: sync.activeTotalCount > 0
|
||||
? sync.activeProgress
|
||||
: null,
|
||||
strokeWidth: 6,
|
||||
strokeCap: StrokeCap.round,
|
||||
backgroundColor:
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
statusColor,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SizedBox(
|
||||
width: 96,
|
||||
height: 96,
|
||||
child: CircularProgressIndicator(
|
||||
value: 0,
|
||||
strokeWidth: 6,
|
||||
backgroundColor:
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
statusColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 中心文字
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (isActive && sync.activeTotalCount > 0)
|
||||
Text(
|
||||
'${(sync.activeProgress * 100).toInt()}%',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
isActive
|
||||
? (sync.state == SyncState.continuous
|
||||
? '持续同步'
|
||||
: '同步中')
|
||||
: isPaused
|
||||
? '已暂停'
|
||||
: hasError
|
||||
? '错误'
|
||||
: '未启动',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 24),
|
||||
// 右侧:统计行
|
||||
IntrinsicWidth(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildStatRow(
|
||||
Icons.file_upload_outlined,
|
||||
'${sync.cumUploaded}',
|
||||
'已上传',
|
||||
Colors.blue,
|
||||
theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatRow(
|
||||
Icons.file_download_outlined,
|
||||
'${sync.cumDownloaded}',
|
||||
'已下载',
|
||||
Colors.green,
|
||||
theme,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildStatRow(
|
||||
Icons.warning_amber_outlined,
|
||||
'${sync.cumConflicts}',
|
||||
'冲突',
|
||||
Colors.orange,
|
||||
theme,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isActive && sync.currentFile != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
sync.currentFile!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
],
|
||||
if (isActive && sync.activeTotalCount > 0) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${sync.activeCompletedCount} / ${sync.activeTotalCount} 文件',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
// 操作按钮
|
||||
Row(
|
||||
children: [
|
||||
if (!sync.isActive && !sync.isPaused)
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => _navigateToSettings(),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('开始同步'),
|
||||
),
|
||||
),
|
||||
if (sync.isActive)
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => sync.pause(),
|
||||
icon: const Icon(Icons.pause, size: 18),
|
||||
label: const Text('暂停'),
|
||||
),
|
||||
),
|
||||
if (sync.isPaused)
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: () => sync.resume(),
|
||||
icon: const Icon(Icons.play_arrow, size: 18),
|
||||
label: const Text('恢复'),
|
||||
),
|
||||
),
|
||||
if (sync.isActive || isPaused) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _stopSync(sync),
|
||||
icon: const Icon(Icons.stop, size: 18),
|
||||
label: const Text('停止'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatRow(
|
||||
IconData icon,
|
||||
String value,
|
||||
String label,
|
||||
Color color,
|
||||
ThemeData theme,
|
||||
) {
|
||||
return Row(
|
||||
children: [
|
||||
Icon(icon, size: 16, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
value,
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.hintColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskList(SyncProvider sync, ThemeData theme) {
|
||||
// 活跃任务 + 已完成任务
|
||||
final activeTasks = sync.activeTasks;
|
||||
final completedTasks = sync.recentTasks
|
||||
.where(
|
||||
(t) =>
|
||||
(t.status == 'completed' ||
|
||||
t.status == 'failed' ||
|
||||
t.status == 'cancelled') &&
|
||||
t.totalCount > 0,
|
||||
)
|
||||
.toList();
|
||||
final allTasks = [...activeTasks, ...completedTasks];
|
||||
|
||||
if (allTasks.isEmpty) {
|
||||
return SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.cloud_off, size: 48, color: theme.hintColor),
|
||||
const SizedBox(height: 12),
|
||||
Text('暂无同步任务', style: TextStyle(color: theme.hintColor)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) => _buildTaskCard(allTasks[index], sync, theme),
|
||||
childCount: allTasks.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskCard(
|
||||
SyncTaskModel task,
|
||||
SyncProvider sync,
|
||||
ThemeData theme,
|
||||
) {
|
||||
final isExpanded = _expandedTasks.contains(task.id);
|
||||
final isRunning = task.status == 'running';
|
||||
final isFailed = task.status == 'failed';
|
||||
final isCompleted = task.status == 'completed';
|
||||
|
||||
Color statusColor = switch (task.status) {
|
||||
'running' => theme.colorScheme.primary,
|
||||
'completed' => Colors.green,
|
||||
'failed' => theme.colorScheme.error,
|
||||
'cancelled' => theme.hintColor,
|
||||
_ => theme.hintColor,
|
||||
};
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.03),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => _toggleTaskExpand(task.id),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
// 状态图标
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
isRunning
|
||||
? Icons.sync
|
||||
: isFailed
|
||||
? Icons.error_outline
|
||||
: isCompleted
|
||||
? Icons.check_circle_outline
|
||||
: Icons.cloud_off,
|
||||
color: statusColor,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
// 任务信息
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
task.statusLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
task.triggerLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 进度条
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: LinearProgressIndicator(
|
||||
value: isRunning && task.totalCount > 0
|
||||
? task.progress
|
||||
: (isCompleted ? 1.0 : null),
|
||||
minHeight: 4,
|
||||
backgroundColor:
|
||||
theme.colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
statusColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'${task.completedCount}/${task.totalCount}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 11,
|
||||
),
|
||||
),
|
||||
if (task.failedCount > 0)
|
||||
Text(
|
||||
'失败${task.failedCount}',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 11,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 20,
|
||||
color: theme.hintColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isExpanded) _buildTaskDetailList(task, sync, theme),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskDetailList(
|
||||
SyncTaskModel task,
|
||||
SyncProvider sync,
|
||||
ThemeData theme,
|
||||
) {
|
||||
final items = sync.getCachedTaskDetail(task.id);
|
||||
|
||||
if (_loadingDetails.contains(task.id)) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (items == null || items.isEmpty) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text('暂无任务项', style: TextStyle(color: theme.hintColor)),
|
||||
);
|
||||
}
|
||||
|
||||
final hasMore = sync.hasMoreTaskDetail(task.id);
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Divider(
|
||||
height: 1,
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
color: theme.dividerColor,
|
||||
),
|
||||
...items.map((item) => _buildTaskItemTile(item, theme)),
|
||||
if (hasMore)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: TextButton.icon(
|
||||
onPressed: () => _loadMoreDetail(task.id),
|
||||
icon: const Icon(Icons.expand_more, size: 16),
|
||||
label: const Text('加载更多'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskItemTile(SyncTaskItemModel item, ThemeData theme) {
|
||||
Color actionColor = switch (item.actionType) {
|
||||
'upload' => Colors.blue,
|
||||
'download' => Colors.green,
|
||||
'create_placeholder' => Colors.teal,
|
||||
'delete_local' || 'delete_remote' => theme.colorScheme.error,
|
||||
'rename' || 'move' => Colors.orange,
|
||||
'conflict_resolve' => Colors.purple,
|
||||
_ => theme.colorScheme.primary,
|
||||
};
|
||||
|
||||
Color itemStatusColor = switch (item.status) {
|
||||
'completed' => Colors.green,
|
||||
'failed' => theme.colorScheme.error,
|
||||
'running' => theme.colorScheme.primary,
|
||||
_ => theme.hintColor,
|
||||
};
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
// 操作类型图标
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: actionColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(
|
||||
item.actionType == 'upload'
|
||||
? Icons.file_upload_outlined
|
||||
: item.actionType == 'download'
|
||||
? Icons.file_download_outlined
|
||||
: item.actionType == 'delete_local' ||
|
||||
item.actionType == 'delete_remote'
|
||||
? Icons.delete_outline
|
||||
: item.actionType == 'rename'
|
||||
? Icons.edit_outlined
|
||||
: item.actionType == 'move'
|
||||
? Icons.drive_file_move_outline
|
||||
: Icons.sync_outlined,
|
||||
color: actionColor,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
// 文件名 + 状态
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
item.filename,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
if (item.errorMessage != null)
|
||||
Text(
|
||||
item.errorMessage!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 10,
|
||||
color: theme.colorScheme.error,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 状态标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: itemStatusColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
item.statusLabel,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
fontSize: 10,
|
||||
color: itemStatusColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _syncModeLabel(SyncProvider sync) {
|
||||
final mode = sync.persistedConfig?.syncMode ?? 'full';
|
||||
return switch (mode) {
|
||||
'full' => '全量同步中',
|
||||
'upload_only' => '仅上传中',
|
||||
'download_only' => '仅下载中',
|
||||
'album_upload' => '相册上传中',
|
||||
'album_download' => '相册下载中',
|
||||
'mirror_wcf' => '镜像同步中',
|
||||
_ => '同步中',
|
||||
};
|
||||
}
|
||||
|
||||
void _toggleTaskExpand(String taskId) {
|
||||
final sync = context.read<SyncProvider>();
|
||||
if (_expandedTasks.contains(taskId)) {
|
||||
setState(() => _expandedTasks.remove(taskId));
|
||||
sync.unwatchTaskDetail(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _expandedTasks.add(taskId));
|
||||
sync.watchTaskDetail(taskId);
|
||||
|
||||
if (sync.getCachedTaskDetail(taskId) == null &&
|
||||
!_loadingDetails.contains(taskId)) {
|
||||
setState(() => _loadingDetails.add(taskId));
|
||||
sync.getTaskDetail(taskId).whenComplete(() {
|
||||
if (mounted) {
|
||||
setState(() => _loadingDetails.remove(taskId));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _loadMoreDetail(String taskId) {
|
||||
context.read<SyncProvider>().loadMoreTaskDetail(taskId);
|
||||
}
|
||||
|
||||
Future<void> _stopSync(SyncProvider sync) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('停止同步'),
|
||||
content: const Text('确定要停止文件同步吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(ctx).colorScheme.error,
|
||||
),
|
||||
child: const Text('停止'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed == true) {
|
||||
await sync.stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,10 +63,7 @@ class AdminProvider extends ChangeNotifier {
|
||||
Future<void> loadAll() async {
|
||||
_setState(AdminState.loading);
|
||||
try {
|
||||
await Future.wait([
|
||||
loadGroups(),
|
||||
loadUsers(),
|
||||
]);
|
||||
await Future.wait([loadGroups(), loadUsers()]);
|
||||
_setState(AdminState.idle);
|
||||
} catch (e) {
|
||||
_errorMessage = e.toString();
|
||||
@@ -213,6 +210,19 @@ class AdminProvider extends ChangeNotifier {
|
||||
|
||||
bool isUserSelected(int userId) => _selectedUserIds.contains(userId);
|
||||
|
||||
/// 清除管理员数据(切换账号时调用)
|
||||
void clear() {
|
||||
_groups = [];
|
||||
_users = [];
|
||||
_groupsPagination = null;
|
||||
_usersPagination = null;
|
||||
_selectedUserIds.clear();
|
||||
_isSelectingUsers = false;
|
||||
_errorMessage = null;
|
||||
_state = AdminState.idle;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setState(AdminState state) {
|
||||
_state = state;
|
||||
notifyListeners();
|
||||
|
||||
@@ -3,7 +3,10 @@ import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../../data/models/file_model.dart';
|
||||
import '../../services/file_service.dart';
|
||||
import '../../services/storage_service.dart';
|
||||
import '../../services/thumbnail_service.dart';
|
||||
import '../../core/constants/sort_options.dart';
|
||||
import '../../core/constants/storage_keys.dart';
|
||||
import '../../core/utils/app_logger.dart';
|
||||
import '../../core/utils/file_utils.dart';
|
||||
|
||||
@@ -15,7 +18,11 @@ class RefreshResult {
|
||||
final int added;
|
||||
final int removed;
|
||||
final int updated;
|
||||
const RefreshResult({required this.added, required this.removed, required this.updated});
|
||||
const RefreshResult({
|
||||
required this.added,
|
||||
required this.removed,
|
||||
required this.updated,
|
||||
});
|
||||
bool get isUnchanged => added == 0 && removed == 0 && updated == 0;
|
||||
}
|
||||
|
||||
@@ -25,8 +32,11 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
List<FileModel> _files = [];
|
||||
List<String> _selectedFiles = [];
|
||||
FileViewType _viewType = FileViewType.list;
|
||||
SortOption _sortOption = SortOption.default_;
|
||||
bool _isLoading = false;
|
||||
bool _isLoadingMore = false;
|
||||
bool _hasMore = true;
|
||||
String? _nextPageToken;
|
||||
String? _errorMessage;
|
||||
String? _contextHint;
|
||||
String? _highlightPath;
|
||||
@@ -36,15 +46,21 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
List<FileModel> get files => _files;
|
||||
List<String> get selectedFiles => _selectedFiles;
|
||||
FileViewType get viewType => _viewType;
|
||||
SortOption get sortOption => _sortOption;
|
||||
bool get isLoading => _isLoading;
|
||||
bool get isLoadingMore => _isLoadingMore;
|
||||
bool get hasMore => _hasMore;
|
||||
String? get nextPageToken => _nextPageToken;
|
||||
String? get errorMessage => _errorMessage;
|
||||
String? get contextHint => _contextHint;
|
||||
bool get hasSelection => _selectedFiles.isNotEmpty;
|
||||
String? get highlightPath => _highlightPath;
|
||||
|
||||
/// 加载文件列表
|
||||
Future<void> loadFiles({bool refresh = false, Duration timeout = const Duration(seconds: 5)}) async {
|
||||
Future<void> loadFiles({
|
||||
bool refresh = false,
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
if (refresh) {
|
||||
_selectedFiles.clear();
|
||||
}
|
||||
@@ -52,13 +68,18 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_nextPageToken = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await FileService().listFiles(
|
||||
uri: _currentPath,
|
||||
pageSize: 50,
|
||||
).timeout(timeout);
|
||||
final response = await FileService()
|
||||
.listFiles(
|
||||
uri: _currentPath,
|
||||
pageSize: 50,
|
||||
orderBy: _sortOption.field.apiKey,
|
||||
orderDirection: _sortOption.direction.apiKey,
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
|
||||
final pagination = response['pagination'] as Map<String, dynamic>? ?? {};
|
||||
@@ -67,7 +88,8 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
_files = filesData
|
||||
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
|
||||
.toList();
|
||||
_hasMore = pagination['next_token'] != null;
|
||||
_nextPageToken = pagination['next_token'] as String?;
|
||||
_hasMore = _nextPageToken != null;
|
||||
_contextHint = response['context_hint'] as String?;
|
||||
});
|
||||
} on TimeoutException {
|
||||
@@ -87,12 +109,62 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// 加载更多文件(分页)
|
||||
Future<void> loadMoreFiles({
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
if (_isLoadingMore || _nextPageToken == null) return;
|
||||
|
||||
setState(() {
|
||||
_isLoadingMore = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await FileService()
|
||||
.listFiles(
|
||||
uri: _currentPath,
|
||||
pageSize: 50,
|
||||
orderBy: _sortOption.field.apiKey,
|
||||
orderDirection: _sortOption.direction.apiKey,
|
||||
nextPageToken: _nextPageToken,
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
|
||||
final pagination = response['pagination'] as Map<String, dynamic>? ?? {};
|
||||
final newFiles = filesData
|
||||
.map((f) => FileModel.fromJson(f as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
setState(() {
|
||||
final existingIds = _files.map((e) => e.id).toSet();
|
||||
_files.addAll(newFiles.where((f) => !existingIds.contains(f.id)));
|
||||
_nextPageToken = pagination['next_token'] as String?;
|
||||
_hasMore = _nextPageToken != null;
|
||||
});
|
||||
} on TimeoutException {
|
||||
setState(() {
|
||||
_errorMessage = '加载更多超时,请重试';
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
_errorMessage = e.toString();
|
||||
});
|
||||
} finally {
|
||||
setState(() {
|
||||
_isLoadingMore = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 进入文件夹
|
||||
Future<void> enterFolder(String path) async {
|
||||
_currentPath = path;
|
||||
_selectedFiles.clear();
|
||||
_highlightPath = null;
|
||||
_highlightTimer?.cancel();
|
||||
_nextPageToken = null;
|
||||
ThumbnailService.instance.clearAll();
|
||||
await loadFiles();
|
||||
}
|
||||
@@ -111,6 +183,7 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
_selectedFiles.clear();
|
||||
_highlightPath = null;
|
||||
_highlightTimer?.cancel();
|
||||
_nextPageToken = null;
|
||||
ThumbnailService.instance.clearAll();
|
||||
notifyListeners();
|
||||
await loadFiles();
|
||||
@@ -144,6 +217,30 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 设置排序选项并重新加载
|
||||
Future<void> setSortOption(SortOption option) async {
|
||||
if (_sortOption == option) return;
|
||||
_sortOption = option;
|
||||
notifyListeners();
|
||||
await StorageService.instance.setString(
|
||||
StorageKeys.fileSortOption,
|
||||
option.toKey(),
|
||||
);
|
||||
await loadFiles(refresh: true);
|
||||
}
|
||||
|
||||
/// 从持久化恢复排序偏好
|
||||
Future<void> restoreSortOption() async {
|
||||
final key = await StorageService.instance.getString(
|
||||
StorageKeys.fileSortOption,
|
||||
);
|
||||
final option = SortOption.fromKey(key);
|
||||
if (option != _sortOption) {
|
||||
_sortOption = option;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 设置错误信息
|
||||
void setErrorMessage(String? message) {
|
||||
_errorMessage = message;
|
||||
@@ -224,9 +321,13 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
}
|
||||
|
||||
/// 移动文件(增量更新)
|
||||
Future<String?> moveFiles(List<String> uris, String destination, {bool copy = false}) async {
|
||||
Future<String?> moveFiles(
|
||||
List<String> uris,
|
||||
String destination, {
|
||||
bool copy = false,
|
||||
}) async {
|
||||
try {
|
||||
await FileService().moveFiles(uris: uris, dst: destination);
|
||||
await FileService().moveFiles(uris: uris, dst: destination, copy: copy);
|
||||
clearSelection();
|
||||
|
||||
if (!copy) {
|
||||
@@ -253,7 +354,10 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
/// 重命名文件(原地更新,不刷新列表)
|
||||
Future<String?> renameFile(String path, String newName) async {
|
||||
try {
|
||||
final response = await FileService().renameFile(uri: path, newName: newName);
|
||||
final response = await FileService().renameFile(
|
||||
uri: path,
|
||||
newName: newName,
|
||||
);
|
||||
if (response.isEmpty) {
|
||||
await loadFiles();
|
||||
return null;
|
||||
@@ -319,21 +423,29 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
_selectedFiles = [];
|
||||
_currentPath = '/';
|
||||
_errorMessage = null;
|
||||
_nextPageToken = null;
|
||||
_hasMore = true;
|
||||
});
|
||||
}
|
||||
|
||||
/// 智能刷新 - 只更新差异部分
|
||||
Future<RefreshResult> refreshFiles({Duration timeout = const Duration(seconds: 5)}) async {
|
||||
/// 智能刷新 - 只更新差异部分(仅刷新首页)
|
||||
Future<RefreshResult> refreshFiles({
|
||||
Duration timeout = const Duration(seconds: 5),
|
||||
}) async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final response = await FileService().listFiles(
|
||||
uri: _currentPath,
|
||||
pageSize: 50,
|
||||
).timeout(timeout);
|
||||
final response = await FileService()
|
||||
.listFiles(
|
||||
uri: _currentPath,
|
||||
pageSize: 50,
|
||||
orderBy: _sortOption.field.apiKey,
|
||||
orderDirection: _sortOption.direction.apiKey,
|
||||
)
|
||||
.timeout(timeout);
|
||||
|
||||
final List<dynamic> filesData = response['files'] as List<dynamic>? ?? [];
|
||||
final newFiles = filesData
|
||||
@@ -378,9 +490,11 @@ class FileManagerProvider extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
final pagination = response['pagination'] as Map<String, dynamic>?;
|
||||
setState(() {
|
||||
_files = updatedFiles;
|
||||
_hasMore = response['pagination']?['next_token'] != null;
|
||||
_nextPageToken = pagination?['next_token'] as String?;
|
||||
_hasMore = _nextPageToken != null;
|
||||
_contextHint = response['context_hint'] as String?;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,882 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../core/utils/app_logger.dart';
|
||||
import '../../../data/models/sync_config_model.dart';
|
||||
import '../../../data/models/sync_event_model.dart';
|
||||
import '../../../data/models/sync_task_model.dart';
|
||||
import '../../../services/storage_service.dart';
|
||||
import '../../../services/sync_service.dart';
|
||||
|
||||
enum SyncState {
|
||||
idle,
|
||||
initializing,
|
||||
initialSync,
|
||||
continuous,
|
||||
paused,
|
||||
error,
|
||||
stopped,
|
||||
}
|
||||
|
||||
class SyncProvider extends ChangeNotifier {
|
||||
SyncState _state = SyncState.idle;
|
||||
String? _errorMessage;
|
||||
SyncSummaryModel? _lastSummary;
|
||||
int _syncedFiles = 0;
|
||||
int _totalFiles = 0;
|
||||
int _conflictCount = 0;
|
||||
int _uploadingCount = 0;
|
||||
int _downloadingCount = 0;
|
||||
String? _currentFile;
|
||||
StreamSubscription<SyncEventModel>? _eventSub;
|
||||
Timer? _pollTimer;
|
||||
int _pollIntervalSeconds = 1; // 动态调节:活跃1s,空闲3s
|
||||
|
||||
// 任务列表
|
||||
List<SyncTaskModel> _activeTasks = [];
|
||||
List<SyncTaskModel> _recentTasks = [];
|
||||
int _activeWorkerCount = 0;
|
||||
// 任务详情缓存: taskId -> items
|
||||
final Map<String, List<SyncTaskItemModel>> _taskDetailCache = {};
|
||||
// 任务详情是否有更多: taskId -> hasMore
|
||||
final Map<String, bool> _taskDetailHasMore = {};
|
||||
// 需要实时刷新详情的任务ID(UI 展开中的任务)
|
||||
final Set<String> _watchedTaskIds = {};
|
||||
bool _recentTasksLoaded = false;
|
||||
|
||||
// 持久化的同步配置
|
||||
SyncConfigModel? _persistedConfig;
|
||||
|
||||
// 累积统计(跨所有 Worker 实时汇总,不仅仅是 lastSummary)
|
||||
int _cumUploaded = 0;
|
||||
int _cumDownloaded = 0;
|
||||
int _cumRenamed = 0;
|
||||
int _cumMoved = 0;
|
||||
int _cumFailed = 0;
|
||||
int _cumConflicts = 0;
|
||||
int _cumDeletedLocal = 0;
|
||||
int _cumDeletedRemote = 0;
|
||||
int _cumSkipped = 0;
|
||||
|
||||
SyncState get state => _state;
|
||||
String? get errorMessage => _errorMessage;
|
||||
SyncSummaryModel? get lastSummary => _lastSummary;
|
||||
int get syncedFiles => _syncedFiles;
|
||||
int get totalFiles => _totalFiles;
|
||||
int get conflictCount => _conflictCount;
|
||||
int get uploadingCount => _uploadingCount;
|
||||
int get downloadingCount => _downloadingCount;
|
||||
String? get currentFile => _currentFile;
|
||||
SyncConfigModel? get persistedConfig => _persistedConfig;
|
||||
List<SyncTaskModel> get activeTasks => _activeTasks;
|
||||
List<SyncTaskModel> get recentTasks => _recentTasks;
|
||||
int get activeWorkerCount => _activeWorkerCount;
|
||||
|
||||
/// 累积上传数(所有 Worker 汇总)
|
||||
int get cumUploaded => _cumUploaded;
|
||||
|
||||
/// 累积下载数(所有 Worker 汇总)
|
||||
int get cumDownloaded => _cumDownloaded;
|
||||
|
||||
/// 累积重命名数
|
||||
int get cumRenamed => _cumRenamed;
|
||||
|
||||
/// 累积移动数
|
||||
int get cumMoved => _cumMoved;
|
||||
|
||||
/// 累积失败数
|
||||
int get cumFailed => _cumFailed;
|
||||
|
||||
/// 累积冲突数
|
||||
int get cumConflicts => _cumConflicts;
|
||||
|
||||
/// 累积删本地数
|
||||
int get cumDeletedLocal => _cumDeletedLocal;
|
||||
|
||||
/// 累积删远程数
|
||||
int get cumDeletedRemote => _cumDeletedRemote;
|
||||
|
||||
/// 累积跳过数
|
||||
int get cumSkipped => _cumSkipped;
|
||||
|
||||
/// 累积总计操作数
|
||||
int get cumTotal =>
|
||||
_cumUploaded +
|
||||
_cumDownloaded +
|
||||
_cumRenamed +
|
||||
_cumMoved +
|
||||
_cumFailed +
|
||||
_cumConflicts +
|
||||
_cumDeletedLocal +
|
||||
_cumDeletedRemote +
|
||||
_cumSkipped;
|
||||
|
||||
/// 从活跃任务聚合的已完成数
|
||||
int get activeCompletedCount {
|
||||
int sum = 0;
|
||||
for (final t in _activeTasks) {
|
||||
sum += t.completedCount;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/// 从活跃任务聚合的总数
|
||||
int get activeTotalCount {
|
||||
int sum = 0;
|
||||
for (final t in _activeTasks) {
|
||||
sum += t.totalCount;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/// 从活跃任务聚合的进度(0.0~1.0)
|
||||
double get activeProgress {
|
||||
final total = activeTotalCount;
|
||||
if (total == 0) return 0.0;
|
||||
return activeCompletedCount / total;
|
||||
}
|
||||
|
||||
bool get isActive =>
|
||||
_state == SyncState.initializing ||
|
||||
_state == SyncState.initialSync ||
|
||||
_state == SyncState.continuous;
|
||||
|
||||
bool get isPaused => _state == SyncState.paused;
|
||||
|
||||
bool get hasError => _state == SyncState.error;
|
||||
|
||||
/// Rust 同步引擎是否已初始化(点过"开始同步"后为 true)
|
||||
bool _engineInitialized = false;
|
||||
bool get engineInitialized => _engineInitialized;
|
||||
|
||||
double get progress => _totalFiles > 0 ? _syncedFiles / _totalFiles : 0.0;
|
||||
|
||||
/// 从持久化存储恢复同步配置和状态
|
||||
Future<void> restoreFromStorage() async {
|
||||
final configMap = await StorageService.instance.getSyncConfig();
|
||||
if (configMap != null) {
|
||||
try {
|
||||
_persistedConfig = SyncConfigModel(
|
||||
baseUrl: configMap['baseUrl'] as String? ?? '',
|
||||
accessToken: configMap['accessToken'] as String? ?? '',
|
||||
refreshToken: configMap['refreshToken'] as String? ?? '',
|
||||
localRoot: configMap['localRoot'] as String? ?? '',
|
||||
remoteRoot: configMap['remoteRoot'] as String? ?? 'cloudreve://my',
|
||||
// 旧版 'album' 迁移为 'album_upload'
|
||||
syncMode: (configMap['syncMode'] as String? ?? 'full') == 'album'
|
||||
? 'album_upload'
|
||||
: (configMap['syncMode'] as String? ?? 'full'),
|
||||
conflictStrategy:
|
||||
configMap['conflictStrategy'] as String? ?? 'keep_both',
|
||||
wcfDeleteMode:
|
||||
configMap['wcfDeleteMode'] as String? ?? 'wcf_delete_local_only',
|
||||
maxConcurrentTransfers:
|
||||
configMap['maxConcurrentTransfers'] as int? ?? 3,
|
||||
bandwidthLimitKbps: configMap['bandwidthLimitKbps'] as int? ?? 0,
|
||||
maxWorkers: configMap['maxWorkers'] as int? ?? 0,
|
||||
dataDir: configMap['dataDir'] as String? ?? '',
|
||||
clientId: configMap['clientId'] as String? ?? '',
|
||||
logLevel: configMap['logLevel'] as String? ?? 'info',
|
||||
);
|
||||
AppLogger.i(
|
||||
'恢复同步配置: 模式=${_persistedConfig!.syncMode}, 冲突=${_persistedConfig!.conflictStrategy}, 并发=${_persistedConfig!.maxConcurrentTransfers}, 带宽=${_persistedConfig!.bandwidthLimitKbps}kbps, maxWorkers=${_persistedConfig!.maxWorkers}',
|
||||
);
|
||||
} catch (e) {
|
||||
AppLogger.e('恢复同步配置失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
final savedState = await StorageService.instance.getSyncState();
|
||||
if (savedState != null && savedState != 'idle' && savedState != 'stopped') {
|
||||
AppLogger.i('恢复同步状态: $savedState');
|
||||
}
|
||||
|
||||
// 恢复累积统计
|
||||
final cumStats = await StorageService.instance.getSyncCumStats();
|
||||
if (cumStats != null) {
|
||||
_cumUploaded = cumStats['uploaded'] ?? 0;
|
||||
_cumDownloaded = cumStats['downloaded'] ?? 0;
|
||||
_cumRenamed = cumStats['renamed'] ?? 0;
|
||||
_cumMoved = cumStats['moved'] ?? 0;
|
||||
_cumFailed = cumStats['failed'] ?? 0;
|
||||
_cumConflicts = cumStats['conflicts'] ?? 0;
|
||||
_cumDeletedLocal = cumStats['deleted_local'] ?? 0;
|
||||
_cumDeletedRemote = cumStats['deleted_remote'] ?? 0;
|
||||
_cumSkipped = cumStats['skipped'] ?? 0;
|
||||
AppLogger.i(
|
||||
'恢复累积统计: 上传=$_cumUploaded, 下载=$_cumDownloaded, 失败=$_cumFailed, 冲突=$_cumConflicts, 删本地=$_cumDeletedLocal, 删远程=$_cumDeletedRemote, 跳过=$_cumSkipped',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存同步配置到持久化存储
|
||||
Future<void> _persistConfig(SyncConfigModel config) async {
|
||||
_persistedConfig = config;
|
||||
await StorageService.instance.setSyncConfig({
|
||||
'baseUrl': config.baseUrl,
|
||||
'accessToken': config.accessToken,
|
||||
'refreshToken': config.refreshToken,
|
||||
'localRoot': config.localRoot,
|
||||
'remoteRoot': config.remoteRoot,
|
||||
'syncMode': config.syncMode,
|
||||
'conflictStrategy': config.conflictStrategy,
|
||||
'wcfDeleteMode': config.wcfDeleteMode,
|
||||
'maxConcurrentTransfers': config.maxConcurrentTransfers,
|
||||
'bandwidthLimitKbps': config.bandwidthLimitKbps,
|
||||
'maxWorkers': config.maxWorkers,
|
||||
'dataDir': config.dataDir,
|
||||
'clientId': config.clientId,
|
||||
'logLevel': config.logLevel,
|
||||
});
|
||||
}
|
||||
|
||||
/// 持久化累积统计
|
||||
Future<void> _persistCumStats() async {
|
||||
await StorageService.instance.setSyncCumStats({
|
||||
'uploaded': _cumUploaded,
|
||||
'downloaded': _cumDownloaded,
|
||||
'renamed': _cumRenamed,
|
||||
'moved': _cumMoved,
|
||||
'failed': _cumFailed,
|
||||
'conflicts': _cumConflicts,
|
||||
'deleted_local': _cumDeletedLocal,
|
||||
'deleted_remote': _cumDeletedRemote,
|
||||
'skipped': _cumSkipped,
|
||||
});
|
||||
}
|
||||
|
||||
/// 从 Rust DB 加载累积统计(权威数据源)
|
||||
/// 取 DB 和内存中的较大值,避免覆盖事件已递增的增量
|
||||
Future<void> _loadCumStatsFromDb() async {
|
||||
try {
|
||||
final stats = await SyncService.instance.getCumStats();
|
||||
_cumUploaded = _max(_cumUploaded, stats['uploaded'] ?? 0);
|
||||
_cumDownloaded = _max(_cumDownloaded, stats['downloaded'] ?? 0);
|
||||
_cumRenamed = _max(_cumRenamed, stats['renamed'] ?? 0);
|
||||
_cumMoved = _max(_cumMoved, stats['moved'] ?? 0);
|
||||
_cumFailed = _max(_cumFailed, stats['failed'] ?? 0);
|
||||
_cumConflicts = _max(_cumConflicts, stats['conflicts'] ?? 0);
|
||||
_cumDeletedLocal = _max(_cumDeletedLocal, stats['deleted_local'] ?? 0);
|
||||
_cumDeletedRemote = _max(_cumDeletedRemote, stats['deleted_remote'] ?? 0);
|
||||
_cumSkipped = _max(_cumSkipped, stats['skipped'] ?? 0);
|
||||
await _persistCumStats();
|
||||
AppLogger.i(
|
||||
'从 DB 校准累积统计: 上传=$_cumUploaded, 下载=$_cumDownloaded, 失败=$_cumFailed, 冲突=$_cumConflicts, 删本地=$_cumDeletedLocal, 删远程=$_cumDeletedRemote, 跳过=$_cumSkipped',
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
AppLogger.e('从 DB 加载累积统计失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static int _max(int a, int b) => a > b ? a : b;
|
||||
|
||||
/// 持久化同步状态
|
||||
Future<void> _persistState(SyncState state) async {
|
||||
final stateStr = switch (state) {
|
||||
SyncState.idle => 'idle',
|
||||
SyncState.initializing => 'initializing',
|
||||
SyncState.initialSync => 'initialSync',
|
||||
SyncState.continuous => 'continuous',
|
||||
SyncState.paused => 'paused',
|
||||
SyncState.error => 'error',
|
||||
SyncState.stopped => 'stopped',
|
||||
};
|
||||
await StorageService.instance.setSyncState(stateStr);
|
||||
}
|
||||
|
||||
/// 初始化并启动同步
|
||||
Future<void> startSync(SyncConfigModel config) async {
|
||||
_state = SyncState.initializing;
|
||||
_errorMessage = null;
|
||||
_syncedFiles = 0;
|
||||
_totalFiles = 0;
|
||||
_currentFile = null;
|
||||
// 不再清零 cum — 从 DB 加载权威数据
|
||||
notifyListeners();
|
||||
|
||||
// 确保 clientId 已注入(Dart 生成、持久化、全层共享)
|
||||
final clientId = await StorageService.instance.getOrCreateClientId();
|
||||
final configWithClientId = config.copyWith(clientId: clientId);
|
||||
|
||||
// 持久化配置和状态
|
||||
await _persistConfig(configWithClientId);
|
||||
await _persistState(SyncState.initializing);
|
||||
|
||||
try {
|
||||
await SyncService.instance.init(configWithClientId);
|
||||
_engineInitialized = true;
|
||||
|
||||
// 从 DB 加载累积统计(权威数据源,替代 SharedPreferences)
|
||||
await _loadCumStatsFromDb();
|
||||
|
||||
_subscribeEvents();
|
||||
|
||||
_state = SyncState.initialSync;
|
||||
await _persistState(SyncState.initialSync);
|
||||
notifyListeners();
|
||||
|
||||
// 启动状态轮询
|
||||
_startPolling();
|
||||
|
||||
// 启动初始同步(后台运行)
|
||||
SyncService.instance
|
||||
.startInitialSync()
|
||||
.then((summary) async {
|
||||
_lastSummary = summary;
|
||||
// 不再覆盖 cum — TaskItemUpdated 事件已实时递增
|
||||
// 初始同步完成后从 DB 重新校准(避免事件遗漏)
|
||||
await _loadCumStatsFromDb();
|
||||
_state = SyncState.continuous;
|
||||
await _persistState(SyncState.continuous);
|
||||
AppLogger.i('初始同步完成');
|
||||
notifyListeners();
|
||||
|
||||
// 自动启动持续同步
|
||||
SyncService.instance.startContinuousSync();
|
||||
})
|
||||
.catchError((e) async {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e.toString();
|
||||
await _persistState(SyncState.error);
|
||||
AppLogger.e('初始同步失败: $e');
|
||||
notifyListeners();
|
||||
});
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e.toString();
|
||||
await _persistState(SyncState.error);
|
||||
AppLogger.e('同步启动失败: $e');
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 自动恢复同步(启动时调用,如果之前处于同步状态则恢复)
|
||||
Future<void> autoResumeIfNeeded({
|
||||
String? currentAccessToken,
|
||||
String? currentRefreshToken,
|
||||
}) async {
|
||||
if (_persistedConfig == null) {
|
||||
await restoreFromStorage();
|
||||
}
|
||||
if (_persistedConfig == null) return;
|
||||
|
||||
final savedState = await StorageService.instance.getSyncState();
|
||||
if (savedState == null ||
|
||||
savedState == 'idle' ||
|
||||
savedState == 'stopped' ||
|
||||
savedState == 'error') {
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.i('自动恢复同步,上次状态: $savedState');
|
||||
|
||||
var config = _persistedConfig!;
|
||||
if (currentAccessToken != null && currentRefreshToken != null) {
|
||||
config = config.copyWith(
|
||||
accessToken: currentAccessToken,
|
||||
refreshToken: currentRefreshToken,
|
||||
);
|
||||
}
|
||||
|
||||
await startSync(config);
|
||||
}
|
||||
|
||||
/// 更新配置(持久化 + 推送到 Rust 引擎)
|
||||
Future<void> updateConfig(SyncConfigModel config) async {
|
||||
await _persistConfig(config);
|
||||
|
||||
// 引擎已初始化时(无论是否运行中),推送配置到 Rust
|
||||
try {
|
||||
await SyncService.instance.updateConfig(config);
|
||||
AppLogger.i('同步配置已更新到引擎: 模式=${config.syncMode}');
|
||||
} catch (e) {
|
||||
AppLogger.e('更新配置到引擎失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 热修改日志级别(立即生效,无需重启引擎)
|
||||
Future<void> setLogLevel(String level) async {
|
||||
// 持久化
|
||||
if (_persistedConfig != null) {
|
||||
final updated = _persistedConfig!.copyWith(logLevel: level);
|
||||
await _persistConfig(updated);
|
||||
}
|
||||
|
||||
// 引擎已初始化时立即通知 Rust(不限制仅活跃状态)
|
||||
try {
|
||||
await SyncService.instance.setLogLevel(level);
|
||||
AppLogger.i('日志级别已切换为: $level');
|
||||
} catch (e) {
|
||||
AppLogger.e('切换日志级别失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询 Rust 引擎状态(动态间隔:活跃1s,空闲3s)
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollIntervalSeconds = isActive || isPaused ? 1 : 3;
|
||||
_pollTimer = Timer.periodic(
|
||||
Duration(seconds: _pollIntervalSeconds),
|
||||
(_) => _pollStatus(),
|
||||
);
|
||||
}
|
||||
|
||||
void _adjustPollInterval() {
|
||||
final target = isActive || isPaused ? 1 : 3;
|
||||
if (target != _pollIntervalSeconds) {
|
||||
_pollIntervalSeconds = target;
|
||||
_startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
void _stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
|
||||
int _pollErrorCount = 0;
|
||||
int _pollCount = 0;
|
||||
|
||||
Future<void> _pollStatus() async {
|
||||
try {
|
||||
final status = await SyncService.instance.getStatus();
|
||||
_syncedFiles = status.syncedFiles;
|
||||
_totalFiles = status.totalFiles;
|
||||
_uploadingCount = status.uploadingCount;
|
||||
_downloadingCount = status.downloadingCount;
|
||||
_conflictCount = status.conflictCount;
|
||||
_pollErrorCount = 0;
|
||||
|
||||
final rustState = _parseState(status.state);
|
||||
if (rustState != _state && rustState != SyncState.idle) {
|
||||
_state = rustState;
|
||||
await _persistState(rustState);
|
||||
}
|
||||
if (status.errorMessage != null && status.errorMessage!.isNotEmpty) {
|
||||
_errorMessage = status.errorMessage;
|
||||
}
|
||||
|
||||
// 轮询活跃任务 + 刷新已完成任务
|
||||
try {
|
||||
final newActiveWorkerCount = await SyncService.instance
|
||||
.getActiveWorkerCount();
|
||||
final newActiveTasks = await SyncService.instance.getActiveTasksTyped();
|
||||
|
||||
bool changed = newActiveWorkerCount != _activeWorkerCount;
|
||||
_activeWorkerCount = newActiveWorkerCount;
|
||||
|
||||
// 活跃任务列表有变化时才更新
|
||||
if (!_taskListEqual(newActiveTasks, _activeTasks)) {
|
||||
_activeTasks = newActiveTasks;
|
||||
changed = true;
|
||||
// 清除不再活跃的任务的详情缓存
|
||||
_taskDetailCache.removeWhere(
|
||||
(key, _) => !newActiveTasks.any((t) => t.id == key),
|
||||
);
|
||||
}
|
||||
|
||||
// 每次 polling 都刷新已完成任务
|
||||
await _refreshRecentTasks();
|
||||
|
||||
// 刷新展开中的任务详情(实时更新 item 状态)
|
||||
await _refreshWatchedTaskDetails();
|
||||
|
||||
if (changed) {
|
||||
notifyListeners();
|
||||
} else {
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
// 每 5 次轮询从 DB 校准累积统计(兜底,防止事件丢失导致 UI 不同步)
|
||||
_pollCount++;
|
||||
if (_pollCount % 5 == 0 && _engineInitialized) {
|
||||
try {
|
||||
final stats = await SyncService.instance.getCumStats();
|
||||
final dbUploaded = stats['uploaded'] ?? 0;
|
||||
final dbDownloaded = stats['downloaded'] ?? 0;
|
||||
final dbRenamed = stats['renamed'] ?? 0;
|
||||
final dbMoved = stats['moved'] ?? 0;
|
||||
final dbFailed = stats['failed'] ?? 0;
|
||||
final dbConflicts = stats['conflicts'] ?? 0;
|
||||
final dbDeletedLocal = stats['deleted_local'] ?? 0;
|
||||
final dbDeletedRemote = stats['deleted_remote'] ?? 0;
|
||||
final dbSkipped = stats['skipped'] ?? 0;
|
||||
if (dbUploaded != _cumUploaded ||
|
||||
dbDownloaded != _cumDownloaded ||
|
||||
dbRenamed != _cumRenamed ||
|
||||
dbMoved != _cumMoved ||
|
||||
dbFailed != _cumFailed ||
|
||||
dbConflicts != _cumConflicts ||
|
||||
dbDeletedLocal != _cumDeletedLocal ||
|
||||
dbDeletedRemote != _cumDeletedRemote ||
|
||||
dbSkipped != _cumSkipped) {
|
||||
_cumUploaded = dbUploaded;
|
||||
_cumDownloaded = dbDownloaded;
|
||||
_cumRenamed = dbRenamed;
|
||||
_cumMoved = dbMoved;
|
||||
_cumFailed = dbFailed;
|
||||
_cumConflicts = dbConflicts;
|
||||
_cumDeletedLocal = dbDeletedLocal;
|
||||
_cumDeletedRemote = dbDeletedRemote;
|
||||
_cumSkipped = dbSkipped;
|
||||
await _persistCumStats();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
_adjustPollInterval();
|
||||
} catch (_) {
|
||||
// 引擎未初始化等连续错误,停止轮询避免刷屏
|
||||
_pollErrorCount++;
|
||||
if (_pollErrorCount >= 3) {
|
||||
_stopPolling();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 首次加载已完成任务(如果轮询未运行则启动慢速轮询)
|
||||
Future<void> loadRecentTasks() async {
|
||||
if (_recentTasksLoaded) return;
|
||||
await _refreshRecentTasks();
|
||||
if (_pollTimer == null) {
|
||||
_startPolling();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _refreshRecentTasks() async {
|
||||
try {
|
||||
final tasks = await SyncService.instance.getRecentTasksTyped(limit: 50);
|
||||
if (!_taskListEqual(tasks, _recentTasks)) {
|
||||
_recentTasks = tasks;
|
||||
}
|
||||
_recentTasksLoaded = true;
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
bool _taskListEqual(List<SyncTaskModel> a, List<SyncTaskModel> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i].id != b[i].id ||
|
||||
a[i].completedCount != b[i].completedCount ||
|
||||
a[i].failedCount != b[i].failedCount ||
|
||||
a[i].status != b[i].status) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// 刷新展开中的任务详情(轮询时调用,只刷新 watched 的任务)
|
||||
Future<void> _refreshWatchedTaskDetails() async {
|
||||
if (_watchedTaskIds.isEmpty) return;
|
||||
for (final taskId in _watchedTaskIds.toList()) {
|
||||
try {
|
||||
// 重新加载前 20 条(和当前缓存量一致)
|
||||
final cachedCount = _taskDetailCache[taskId]?.length ?? 20;
|
||||
final newItems = await SyncService.instance.queryTaskItemsTyped(
|
||||
taskId: taskId,
|
||||
limit: cachedCount.clamp(20, 100),
|
||||
offset: 0,
|
||||
);
|
||||
final oldItems = _taskDetailCache[taskId];
|
||||
if (oldItems != null && !_itemListEqual(oldItems, newItems)) {
|
||||
_taskDetailCache[taskId] = newItems;
|
||||
notifyListeners();
|
||||
} else if (oldItems == null) {
|
||||
_taskDetailCache[taskId] = newItems;
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
bool _itemListEqual(List<SyncTaskItemModel> a, List<SyncTaskItemModel> b) {
|
||||
if (a.length != b.length) return false;
|
||||
for (int i = 0; i < a.length; i++) {
|
||||
if (a[i].id != b[i].id || a[i].status != b[i].status) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void _subscribeEvents() {
|
||||
_eventSub = SyncService.instance.events.listen((event) async {
|
||||
switch (event) {
|
||||
case SyncStateChanged(:final newState):
|
||||
_state = _parseState(newState);
|
||||
await _persistState(_state);
|
||||
case SyncProgress(:final synced, :final total, :final currentFile):
|
||||
_syncedFiles = synced;
|
||||
_totalFiles = total;
|
||||
_currentFile = currentFile;
|
||||
case SyncFileUploaded():
|
||||
_syncedFiles++;
|
||||
case SyncFileDownloaded():
|
||||
_syncedFiles++;
|
||||
case SyncConflictDetected():
|
||||
_conflictCount++;
|
||||
case SyncError(:final message, :final recoverable):
|
||||
if (!recoverable) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = message;
|
||||
await _persistState(SyncState.error);
|
||||
}
|
||||
case SyncTokenExpired():
|
||||
_handleTokenExpired();
|
||||
case SyncInitialSyncComplete(:final summary):
|
||||
_lastSummary = summary;
|
||||
_state = SyncState.continuous;
|
||||
await _persistState(SyncState.continuous);
|
||||
// 不再在此启动 continuous sync — .then() 回调已启动,避免重复
|
||||
case SyncDiskSpaceWarning():
|
||||
AppLogger.w('磁盘空间不足');
|
||||
case SyncWorkerCompleted():
|
||||
break;
|
||||
case SyncWorkerFailed():
|
||||
break;
|
||||
case SyncTaskItemUpdated(:final taskId, :final action, :final status):
|
||||
AppLogger.d(
|
||||
'[SyncProvider] TaskItemUpdated: taskId=$taskId, action=$action, status=$status',
|
||||
);
|
||||
// 实时更新活跃任务的 completedCount/failedCount
|
||||
final idx = _activeTasks.indexWhere((t) => t.id == taskId);
|
||||
if (idx >= 0) {
|
||||
final old = _activeTasks[idx];
|
||||
if (status == 'completed') {
|
||||
_activeTasks[idx] = SyncTaskModel(
|
||||
id: old.id,
|
||||
trigger: old.trigger,
|
||||
totalCount: old.totalCount,
|
||||
completedCount: old.completedCount + 1,
|
||||
failedCount: old.failedCount,
|
||||
status: old.status,
|
||||
createdAt: old.createdAt,
|
||||
updatedAt: old.updatedAt,
|
||||
finishedAt: old.finishedAt,
|
||||
);
|
||||
} else if (status == 'failed') {
|
||||
_activeTasks[idx] = SyncTaskModel(
|
||||
id: old.id,
|
||||
trigger: old.trigger,
|
||||
totalCount: old.totalCount,
|
||||
completedCount: old.completedCount,
|
||||
failedCount: old.failedCount + 1,
|
||||
status: old.status,
|
||||
createdAt: old.createdAt,
|
||||
updatedAt: old.updatedAt,
|
||||
finishedAt: old.finishedAt,
|
||||
);
|
||||
}
|
||||
}
|
||||
// 实时递增累积计数器
|
||||
if (status == 'completed') {
|
||||
switch (action) {
|
||||
case 'upload':
|
||||
_cumUploaded++;
|
||||
AppLogger.d('[SyncProvider] cumUploaded=$_cumUploaded');
|
||||
case 'download':
|
||||
_cumDownloaded++;
|
||||
case 'rename':
|
||||
_cumRenamed++;
|
||||
case 'move':
|
||||
_cumMoved++;
|
||||
case 'conflict_resolve':
|
||||
_cumConflicts++;
|
||||
case 'delete_local':
|
||||
_cumDeletedLocal++;
|
||||
case 'delete_remote':
|
||||
_cumDeletedRemote++;
|
||||
case 'create_placeholder':
|
||||
break;
|
||||
default:
|
||||
AppLogger.w(
|
||||
'[SyncProvider] TaskItemUpdated: 未知 action=$action',
|
||||
);
|
||||
}
|
||||
} else if (status == 'skipped') {
|
||||
_cumSkipped++;
|
||||
} else if (status == 'failed') {
|
||||
_cumFailed++;
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
_persistCumStats();
|
||||
});
|
||||
}
|
||||
|
||||
SyncState _parseState(String state) {
|
||||
return switch (state) {
|
||||
'idle' => SyncState.idle,
|
||||
'initializing' => SyncState.initializing,
|
||||
'initialSync' => SyncState.initialSync,
|
||||
'continuous' => SyncState.continuous,
|
||||
'paused' => SyncState.paused,
|
||||
'error' => SyncState.error,
|
||||
'stopped' => SyncState.stopped,
|
||||
_ => SyncState.idle,
|
||||
};
|
||||
}
|
||||
|
||||
void _handleTokenExpired() {
|
||||
AppLogger.w('Token 过期,需要刷新');
|
||||
}
|
||||
|
||||
/// 获取任务详情(分页加载,带缓存)
|
||||
Future<List<SyncTaskItemModel>> getTaskDetail(String taskId) async {
|
||||
if (_taskDetailCache.containsKey(taskId)) {
|
||||
return _taskDetailCache[taskId]!;
|
||||
}
|
||||
final items = await SyncService.instance.queryTaskItemsTyped(
|
||||
taskId: taskId,
|
||||
limit: 20,
|
||||
offset: 0,
|
||||
);
|
||||
_taskDetailCache[taskId] = items;
|
||||
_taskDetailHasMore[taskId] = items.length >= 20;
|
||||
notifyListeners();
|
||||
return items;
|
||||
}
|
||||
|
||||
/// 加载更多任务详情
|
||||
Future<List<SyncTaskItemModel>> loadMoreTaskDetail(String taskId) async {
|
||||
final current = _taskDetailCache[taskId] ?? [];
|
||||
final offset = current.length;
|
||||
final newItems = await SyncService.instance.queryTaskItemsTyped(
|
||||
taskId: taskId,
|
||||
limit: 20,
|
||||
offset: offset,
|
||||
);
|
||||
final merged = [...current, ...newItems];
|
||||
_taskDetailCache[taskId] = merged;
|
||||
_taskDetailHasMore[taskId] = newItems.length >= 20;
|
||||
notifyListeners();
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// 指定任务是否还有更多详情可加载
|
||||
bool hasMoreTaskDetail(String taskId) {
|
||||
return _taskDetailHasMore[taskId] ?? true;
|
||||
}
|
||||
|
||||
/// 标记任务详情需要实时刷新(UI 展开时调用)
|
||||
void watchTaskDetail(String taskId) {
|
||||
_watchedTaskIds.add(taskId);
|
||||
}
|
||||
|
||||
/// 取消实时刷新(UI 收起时调用)
|
||||
void unwatchTaskDetail(String taskId) {
|
||||
_watchedTaskIds.remove(taskId);
|
||||
}
|
||||
|
||||
/// 读取缓存的任务详情(同步,无则返回 null)
|
||||
List<SyncTaskItemModel>? getCachedTaskDetail(String taskId) {
|
||||
return _taskDetailCache[taskId];
|
||||
}
|
||||
|
||||
/// 清除任务详情缓存,下次获取时重新请求
|
||||
void invalidateTaskDetail(String taskId) {
|
||||
_taskDetailCache.remove(taskId);
|
||||
_taskDetailHasMore.remove(taskId);
|
||||
}
|
||||
|
||||
/// 清除所有任务详情缓存
|
||||
void invalidateAllTaskDetails() {
|
||||
_taskDetailCache.clear();
|
||||
_taskDetailHasMore.clear();
|
||||
_recentTasksLoaded = false;
|
||||
}
|
||||
|
||||
/// 暂停同步
|
||||
Future<void> pause() async {
|
||||
await SyncService.instance.pause();
|
||||
_state = SyncState.paused;
|
||||
await _persistState(SyncState.paused);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 恢复同步
|
||||
Future<void> resume() async {
|
||||
await SyncService.instance.resume();
|
||||
_state = SyncState.continuous;
|
||||
await _persistState(SyncState.continuous);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 停止同步
|
||||
Future<void> stop() async {
|
||||
await SyncService.instance.stop();
|
||||
_state = SyncState.stopped;
|
||||
await _persistState(SyncState.stopped);
|
||||
notifyListeners();
|
||||
_adjustPollInterval();
|
||||
}
|
||||
|
||||
/// 重置同步:停止任务 → 清空 DB → (可选)清空本地目录 → 回到初始状态
|
||||
Future<void> resetSync({bool deleteLocalFiles = true}) async {
|
||||
// 引擎未初始化时仅清空本地状态
|
||||
try {
|
||||
await SyncService.instance.resetSync(deleteLocalFiles: deleteLocalFiles);
|
||||
} catch (_) {}
|
||||
_state = SyncState.idle;
|
||||
_errorMessage = null;
|
||||
_syncedFiles = 0;
|
||||
_totalFiles = 0;
|
||||
_conflictCount = 0;
|
||||
_uploadingCount = 0;
|
||||
_downloadingCount = 0;
|
||||
_currentFile = null;
|
||||
_lastSummary = null;
|
||||
_cumUploaded = 0;
|
||||
_cumDownloaded = 0;
|
||||
_cumRenamed = 0;
|
||||
_cumMoved = 0;
|
||||
_cumFailed = 0;
|
||||
_cumConflicts = 0;
|
||||
_cumDeletedLocal = 0;
|
||||
_cumDeletedRemote = 0;
|
||||
_cumSkipped = 0;
|
||||
_persistCumStats();
|
||||
_activeTasks = [];
|
||||
_recentTasks = [];
|
||||
_activeWorkerCount = 0;
|
||||
_taskDetailCache.clear();
|
||||
_recentTasksLoaded = false;
|
||||
_engineInitialized = false;
|
||||
await _persistState(SyncState.idle);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 强制重新同步
|
||||
Future<void> forceSync() async {
|
||||
_state = SyncState.initialSync;
|
||||
_syncedFiles = 0;
|
||||
_totalFiles = 0;
|
||||
await _persistState(SyncState.initialSync);
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
final summary = await SyncService.instance.forceSync();
|
||||
_lastSummary = summary;
|
||||
_state = SyncState.continuous;
|
||||
await _persistState(SyncState.continuous);
|
||||
// 重新启动持续同步
|
||||
SyncService.instance.startContinuousSync();
|
||||
} catch (e) {
|
||||
_state = SyncState.error;
|
||||
_errorMessage = e.toString();
|
||||
await _persistState(SyncState.error);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_stopPolling();
|
||||
_eventSub?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -46,10 +46,7 @@ class UserSettingProvider extends ChangeNotifier {
|
||||
|
||||
/// 同时加载设置和容量
|
||||
Future<void> loadAll() async {
|
||||
await Future.wait([
|
||||
loadSettings(),
|
||||
loadCapacity(),
|
||||
]);
|
||||
await Future.wait([loadSettings(), loadCapacity()]);
|
||||
}
|
||||
|
||||
/// 修改昵称
|
||||
@@ -244,6 +241,15 @@ class UserSettingProvider extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 清除用户数据(切换账号时调用)
|
||||
void clear() {
|
||||
_settings = null;
|
||||
_capacity = null;
|
||||
_errorMessage = null;
|
||||
_state = UserSettingState.idle;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _setState(UserSettingState state) {
|
||||
_state = state;
|
||||
notifyListeners();
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
|
||||
import '../../core/utils/file_utils.dart';
|
||||
|
||||
/// 面包屑导航组件
|
||||
class FileBreadcrumb extends StatelessWidget {
|
||||
class FileBreadcrumb extends StatefulWidget {
|
||||
final String currentPath;
|
||||
final void Function(String path) onPathTap;
|
||||
|
||||
@@ -12,9 +14,38 @@ class FileBreadcrumb extends StatelessWidget {
|
||||
required this.onPathTap,
|
||||
});
|
||||
|
||||
@override
|
||||
State<FileBreadcrumb> createState() => _FileBreadcrumbState();
|
||||
}
|
||||
|
||||
class _FileBreadcrumbState extends State<FileBreadcrumb> {
|
||||
final _controller = ScrollController();
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant FileBreadcrumb oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.currentPath != widget.currentPath) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (_controller.hasClients) {
|
||||
_controller.animateTo(
|
||||
_controller.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pathParts = currentPath.split('/');
|
||||
final pathParts = widget.currentPath.split('/');
|
||||
pathParts.removeWhere((part) => part.isEmpty);
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
@@ -32,6 +63,7 @@ class FileBreadcrumb extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
controller: _controller,
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -41,7 +73,7 @@ class FileBreadcrumb extends StatelessWidget {
|
||||
path: '/',
|
||||
icon: LucideIcons.home,
|
||||
primaryColor: colorScheme.primary,
|
||||
onTap: () => onPathTap('/'),
|
||||
onTap: () => widget.onPathTap('/'),
|
||||
),
|
||||
for (int i = 0; i < pathParts.length; i++) ...[
|
||||
Padding(
|
||||
@@ -58,8 +90,9 @@ class FileBreadcrumb extends StatelessWidget {
|
||||
path: '/${pathParts.sublist(0, i + 1).join('/')}',
|
||||
icon: null,
|
||||
primaryColor: colorScheme.primary,
|
||||
onTap: () =>
|
||||
onPathTap('/${pathParts.sublist(0, i + 1).join('/')}'),
|
||||
onTap: () => widget.onPathTap(
|
||||
'/${pathParts.sublist(0, i + 1).join('/')}',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
@@ -69,19 +102,7 @@ class FileBreadcrumb extends StatelessWidget {
|
||||
}
|
||||
|
||||
String _decodePathSegment(String value) {
|
||||
var decoded = value;
|
||||
|
||||
for (var i = 0; i < 5; i++) {
|
||||
try {
|
||||
final next = Uri.decodeComponent(decoded);
|
||||
if (next == decoded) break;
|
||||
decoded = next;
|
||||
} on FormatException {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return decoded;
|
||||
return FileUtils.safeDecodePathSegment(value);
|
||||
}
|
||||
|
||||
Widget _buildBreadcrumbItem(
|
||||
|
||||
@@ -17,6 +17,7 @@ class FileGridItem extends StatelessWidget {
|
||||
final VoidCallback? onSelect;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onOpenInBrowser;
|
||||
final VoidCallback? onOpenInCloudreveApp;
|
||||
final VoidCallback? onRename;
|
||||
final VoidCallback? onMove;
|
||||
final VoidCallback? onCopy;
|
||||
@@ -38,6 +39,7 @@ class FileGridItem extends StatelessWidget {
|
||||
this.onSelect,
|
||||
this.onDownload,
|
||||
this.onOpenInBrowser,
|
||||
this.onOpenInCloudreveApp,
|
||||
this.onRename,
|
||||
this.onMove,
|
||||
this.onCopy,
|
||||
@@ -50,25 +52,27 @@ class FileGridItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Builder(
|
||||
builder: (builderContext) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
|
||||
return RepaintBoundary(
|
||||
child: Builder(
|
||||
builder: (builderContext) => LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final fontSize = (constraints.maxWidth * 0.1).clamp(10.0, 13.0);
|
||||
|
||||
return _FileGridItemHover(
|
||||
file: file,
|
||||
isSelected: isSelected,
|
||||
isHighlighted: isHighlighted,
|
||||
showCheckbox: showCheckbox,
|
||||
contextHint: contextHint,
|
||||
fontSize: fontSize,
|
||||
tapToShowMenu: tapToShowMenu,
|
||||
onTap: tapToShowMenu ? null : onTap,
|
||||
onLongPress: () => _showMenu(builderContext),
|
||||
onSelect: onSelect,
|
||||
onMore: () => _showMenu(builderContext),
|
||||
);
|
||||
},
|
||||
return _FileGridItemHover(
|
||||
file: file,
|
||||
isSelected: isSelected,
|
||||
isHighlighted: isHighlighted,
|
||||
showCheckbox: showCheckbox,
|
||||
contextHint: contextHint,
|
||||
fontSize: fontSize,
|
||||
tapToShowMenu: tapToShowMenu,
|
||||
onTap: tapToShowMenu ? null : onTap,
|
||||
onLongPress: () => _showMenu(builderContext),
|
||||
onSelect: onSelect,
|
||||
onMore: () => _showMenu(builderContext),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -79,6 +83,7 @@ class FileGridItem extends StatelessWidget {
|
||||
hasSelect: onSelect != null,
|
||||
hasDownload: onDownload != null,
|
||||
hasOpenInBrowser: onOpenInBrowser != null,
|
||||
hasOpenInCloudreveApp: onOpenInCloudreveApp != null,
|
||||
hasRename: onRename != null,
|
||||
hasMove: onMove != null,
|
||||
hasCopy: onCopy != null,
|
||||
@@ -95,6 +100,8 @@ class FileGridItem extends StatelessWidget {
|
||||
onDownload?.call();
|
||||
case FileMenuAction.openInBrowser:
|
||||
onOpenInBrowser?.call();
|
||||
case FileMenuAction.openInCloudreveApp:
|
||||
onOpenInCloudreveApp?.call();
|
||||
case FileMenuAction.rename:
|
||||
onRename?.call();
|
||||
case FileMenuAction.move:
|
||||
@@ -318,10 +325,8 @@ class _FileGridItemHoverState extends State<_FileGridItemHover> {
|
||||
|
||||
Widget _buildIconArea(BuildContext context) {
|
||||
final file = widget.file;
|
||||
final ext = FileUtils.getFileExtension(file.name);
|
||||
final isThumbnailable = !file.isFolder
|
||||
&& FileUtils.isImageFile(file.name)
|
||||
&& ext != 'svg';
|
||||
final isThumbnailable =
|
||||
!file.isFolder && FileUtils.isThumbnailableFile(file.name);
|
||||
|
||||
if (!isThumbnailable) {
|
||||
return Center(
|
||||
@@ -335,10 +340,52 @@ class _FileGridItemHoverState extends State<_FileGridItemHover> {
|
||||
);
|
||||
}
|
||||
|
||||
return ThumbnailImage(
|
||||
file: file,
|
||||
contextHint: widget.contextHint,
|
||||
borderRadius: 10,
|
||||
return Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,58 @@ class FileInfoPanel extends StatefulWidget {
|
||||
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
|
||||
State<FileInfoPanel> createState() => _FileInfoPanelState();
|
||||
}
|
||||
|
||||
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;
|
||||
bool _isLoading = true;
|
||||
bool _isCalculatingFolder = false;
|
||||
@@ -87,58 +134,53 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Drawer(
|
||||
child: SafeArea(
|
||||
right: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 12,
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 8,
|
||||
top: 8,
|
||||
bottom: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: theme.dividerColor.withValues(alpha: 0.2)),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
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,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
FileIconUtils.buildIconWidget(
|
||||
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,
|
||||
),
|
||||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _buildError(theme)
|
||||
: _buildContent(theme, colorScheme),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _buildError(theme)
|
||||
: _buildContent(theme, colorScheme),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -179,7 +221,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 类型标签
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
@@ -196,8 +237,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 基本信息
|
||||
_buildInfoRow(LucideIcons.folderOpen, '位置', Uri.decodeComponent(file.relativePath)),
|
||||
if (file.isFile)
|
||||
_buildInfoRow(
|
||||
@@ -210,7 +249,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||
if (file.owned != null)
|
||||
_buildInfoRow(LucideIcons.shield, '所有者', file.owned! ? '是' : '否'),
|
||||
|
||||
// 文件扩展信息
|
||||
if (file.isFile && extendedInfo != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
@@ -234,7 +272,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||
),
|
||||
],
|
||||
|
||||
// 版本历史
|
||||
if (file.isFile && versionEntities.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
@@ -242,7 +279,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||
_buildVersionSection(theme, colorScheme, versionEntities),
|
||||
],
|
||||
|
||||
// 文件夹信息
|
||||
if (file.isFolder) ...[
|
||||
const SizedBox(height: 12),
|
||||
Divider(color: theme.dividerColor.withValues(alpha: 0.3)),
|
||||
@@ -488,8 +524,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 版本操作 ───
|
||||
|
||||
void _openVersion(EntityModel entity) {
|
||||
final file = _fileInfo!.file;
|
||||
if (!FileTypeUtils.isPreviewable(file.name)) {
|
||||
@@ -601,8 +635,6 @@ class _FileInfoPanelState extends State<FileInfoPanel> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 文件夹摘要 ───
|
||||
|
||||
Widget _buildFolderSummary(ThemeData theme, ColorScheme colorScheme) {
|
||||
final summary = _fileInfo?.folderSummary;
|
||||
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 文件列表表头(桌面端)
|
||||
import '../../core/constants/sort_options.dart';
|
||||
|
||||
/// 文件列表表头(桌面端),支持点击排序
|
||||
class FileListHeader extends StatelessWidget {
|
||||
final bool showCheckbox;
|
||||
const FileListHeader({super.key, this.showCheckbox = false});
|
||||
final SortOption? currentSort;
|
||||
final ValueChanged<SortOption>? onSort;
|
||||
|
||||
const FileListHeader({
|
||||
super.key,
|
||||
this.showCheckbox = false,
|
||||
this.currentSort,
|
||||
this.onSort,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final style = TextStyle(
|
||||
color: theme.hintColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
);
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||
@@ -26,11 +31,77 @@ class FileListHeader extends StatelessWidget {
|
||||
if (showCheckbox) const SizedBox(width: 40),
|
||||
// 图标占位
|
||||
const SizedBox(width: 36 + 16),
|
||||
Expanded(flex: 5, child: Text('名称', style: style)),
|
||||
Expanded(flex: 2, child: Text('修改日期', style: style)),
|
||||
Expanded(flex: 1, child: Text('大小', style: style)),
|
||||
Expanded(
|
||||
flex: 5,
|
||||
child: _buildSortHeader(context, theme, SortField.name, '名称'),
|
||||
),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _buildSortHeader(
|
||||
context,
|
||||
theme,
|
||||
SortField.updatedAt,
|
||||
'修改日期',
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _buildSortHeader(context, theme, SortField.size, '大小'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSortHeader(
|
||||
BuildContext context,
|
||||
ThemeData theme,
|
||||
SortField field,
|
||||
String label,
|
||||
) {
|
||||
final isActive = currentSort?.field == field;
|
||||
final style = TextStyle(
|
||||
color: isActive ? theme.colorScheme.primary : theme.hintColor,
|
||||
fontSize: 12,
|
||||
fontWeight: isActive ? FontWeight.w600 : FontWeight.w500,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: () => _onHeaderTap(field),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(label, style: style),
|
||||
if (isActive) ...[
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
currentSort!.direction == SortDirection.asc
|
||||
? Icons.arrow_upward
|
||||
: Icons.arrow_downward,
|
||||
size: 14,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onHeaderTap(SortField field) {
|
||||
if (onSort == null) return;
|
||||
if (currentSort?.field == field) {
|
||||
// 同一列:切换方向
|
||||
final newDir = currentSort!.direction == SortDirection.asc
|
||||
? SortDirection.desc
|
||||
: SortDirection.asc;
|
||||
onSort!(SortOption(field, newDir));
|
||||
} else {
|
||||
// 新列:默认升序
|
||||
onSort!(SortOption(field, SortDirection.asc));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class FileListItem extends StatelessWidget {
|
||||
final VoidCallback? onSelect;
|
||||
final VoidCallback? onDownload;
|
||||
final VoidCallback? onOpenInBrowser;
|
||||
final VoidCallback? onOpenInCloudreveApp;
|
||||
final VoidCallback? onRename;
|
||||
final VoidCallback? onMove;
|
||||
final VoidCallback? onCopy;
|
||||
@@ -40,6 +41,7 @@ class FileListItem extends StatelessWidget {
|
||||
this.onSelect,
|
||||
this.onDownload,
|
||||
this.onOpenInBrowser,
|
||||
this.onOpenInCloudreveApp,
|
||||
this.onRename,
|
||||
this.onMove,
|
||||
this.onCopy,
|
||||
@@ -51,17 +53,19 @@ class FileListItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _FileListItemHover(
|
||||
file: file,
|
||||
isSelected: isSelected,
|
||||
isHighlighted: isHighlighted,
|
||||
index: index,
|
||||
isDesktop: isDesktop,
|
||||
showCheckbox: showCheckbox,
|
||||
tapToShowMenu: tapToShowMenu,
|
||||
onTap: tapToShowMenu ? null : onTap,
|
||||
onLongPress: () => _showMenu(context),
|
||||
onSelect: onSelect,
|
||||
return RepaintBoundary(
|
||||
child: _FileListItemHover(
|
||||
file: file,
|
||||
isSelected: isSelected,
|
||||
isHighlighted: isHighlighted,
|
||||
index: index,
|
||||
isDesktop: isDesktop,
|
||||
showCheckbox: showCheckbox,
|
||||
tapToShowMenu: tapToShowMenu,
|
||||
onTap: tapToShowMenu ? null : onTap,
|
||||
onLongPress: () => _showMenu(context),
|
||||
onSelect: onSelect,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +75,7 @@ class FileListItem extends StatelessWidget {
|
||||
hasSelect: onSelect != null,
|
||||
hasDownload: onDownload != null,
|
||||
hasOpenInBrowser: onOpenInBrowser != null,
|
||||
hasOpenInCloudreveApp: onOpenInCloudreveApp != null,
|
||||
hasRename: onRename != null,
|
||||
hasMove: onMove != null,
|
||||
hasCopy: onCopy != null,
|
||||
@@ -87,6 +92,8 @@ class FileListItem extends StatelessWidget {
|
||||
onDownload?.call();
|
||||
case FileMenuAction.openInBrowser:
|
||||
onOpenInBrowser?.call();
|
||||
case FileMenuAction.openInCloudreveApp:
|
||||
onOpenInCloudreveApp?.call();
|
||||
case FileMenuAction.rename:
|
||||
onRename?.call();
|
||||
case FileMenuAction.move:
|
||||
|
||||
@@ -7,6 +7,7 @@ enum FileMenuAction {
|
||||
select,
|
||||
download,
|
||||
openInBrowser,
|
||||
openInCloudreveApp,
|
||||
rename,
|
||||
move,
|
||||
copy,
|
||||
@@ -22,6 +23,7 @@ Future<FileMenuAction?> showFileMenu({
|
||||
required bool hasSelect,
|
||||
required bool hasDownload,
|
||||
required bool hasOpenInBrowser,
|
||||
bool hasOpenInCloudreveApp = false,
|
||||
required bool hasRename,
|
||||
required bool hasMove,
|
||||
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)
|
||||
const PopupMenuItem(
|
||||
value: FileMenuAction.rename,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -132,7 +132,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
return InkWell(
|
||||
onTap: () => _enterFolder(folder),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -163,7 +163,7 @@ class _FolderPickerState extends State<FolderPicker> {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../core/utils/date_utils.dart' as date_utils;
|
||||
import '../../core/utils/file_icon_utils.dart';
|
||||
import '../../core/utils/file_utils.dart';
|
||||
import '../../data/models/file_model.dart';
|
||||
import '../../services/file_service.dart';
|
||||
import '../../services/storage_service.dart';
|
||||
@@ -17,7 +19,12 @@ import 'glassmorphism_container.dart';
|
||||
class SearchDialog extends StatefulWidget {
|
||||
const SearchDialog({super.key});
|
||||
|
||||
static bool _isShowing = false;
|
||||
static bool get isShowing => _isShowing;
|
||||
|
||||
static Future<void> show(BuildContext context) {
|
||||
if (_isShowing) return Future.value();
|
||||
_isShowing = true;
|
||||
return showGeneralDialog(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
@@ -38,8 +45,9 @@ class SearchDialog extends StatefulWidget {
|
||||
child: FadeTransition(opacity: fadeAnim, child: child),
|
||||
);
|
||||
},
|
||||
pageBuilder: (context, animation, secondaryAnimation) => const SearchDialog(),
|
||||
);
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const SearchDialog(),
|
||||
).whenComplete(() => _isShowing = false);
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -145,8 +153,10 @@ class _SearchDialogState extends State<SearchDialog> {
|
||||
|
||||
// 在 async gap 之前获取所有引用
|
||||
final navProvider = Provider.of<NavigationProvider>(context, listen: false);
|
||||
final fileManager =
|
||||
Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final parentPath = _extractParentPath(file.path);
|
||||
final filePath = file.path;
|
||||
|
||||
@@ -181,25 +191,40 @@ class _SearchDialogState extends State<SearchDialog> {
|
||||
final isWide = screenWidth >= 600;
|
||||
final dialogWidth = isWide ? 560.0 : screenWidth - 32.0;
|
||||
|
||||
return Center(
|
||||
child: Container(
|
||||
width: dialogWidth,
|
||||
constraints: BoxConstraints(maxHeight: screenHeight * 0.75),
|
||||
child: GlassmorphismContainer(
|
||||
borderRadius: 16,
|
||||
sigmaX: 20,
|
||||
sigmaY: 20,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildSearchInput(context),
|
||||
const Divider(height: 1),
|
||||
Flexible(child: _buildBody(context)),
|
||||
],
|
||||
return Shortcuts(
|
||||
shortcuts: <ShortcutActivator, Intent>{
|
||||
const SingleActivator(LogicalKeyboardKey.escape): DismissIntent(),
|
||||
},
|
||||
child: Actions(
|
||||
actions: <Type, Action<Intent>>{
|
||||
DismissIntent: CallbackAction<DismissIntent>(
|
||||
onInvoke: (_) {
|
||||
Navigator.of(context).pop();
|
||||
return null;
|
||||
},
|
||||
),
|
||||
},
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: dialogWidth,
|
||||
constraints: BoxConstraints(maxHeight: screenHeight * 0.75),
|
||||
child: GlassmorphismContainer(
|
||||
borderRadius: 16,
|
||||
sigmaX: 20,
|
||||
sigmaY: 20,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildSearchInput(context),
|
||||
const Divider(height: 1),
|
||||
Flexible(child: _buildBody(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -286,16 +311,20 @@ class _SearchDialogState extends State<SearchDialog> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.alertCircle,
|
||||
size: 40, color: colorScheme.error.withValues(alpha: 0.7)),
|
||||
Icon(
|
||||
LucideIcons.alertCircle,
|
||||
size: 40,
|
||||
color: colorScheme.error.withValues(alpha: 0.7),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(_errorMessage!,
|
||||
style: TextStyle(color: theme.hintColor),
|
||||
textAlign: TextAlign.center),
|
||||
Text(
|
||||
_errorMessage!,
|
||||
style: TextStyle(color: theme.hintColor),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.tonal(
|
||||
onPressed: () =>
|
||||
_performSearch(_searchController.text.trim()),
|
||||
onPressed: () => _performSearch(_searchController.text.trim()),
|
||||
child: const Text('重试'),
|
||||
),
|
||||
],
|
||||
@@ -316,12 +345,13 @@ class _SearchDialogState extends State<SearchDialog> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.searchX,
|
||||
size: 40,
|
||||
color: theme.hintColor.withValues(alpha: 0.5)),
|
||||
Icon(
|
||||
LucideIcons.searchX,
|
||||
size: 40,
|
||||
color: theme.hintColor.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('未找到匹配文件',
|
||||
style: TextStyle(color: theme.hintColor)),
|
||||
Text('未找到匹配文件', style: TextStyle(color: theme.hintColor)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -330,8 +360,7 @@ class _SearchDialogState extends State<SearchDialog> {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Center(
|
||||
child: Text('输入关键词搜索文件',
|
||||
style: TextStyle(color: theme.hintColor)),
|
||||
child: Text('输入关键词搜索文件', style: TextStyle(color: theme.hintColor)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -364,17 +393,21 @@ class _SearchDialogState extends State<SearchDialog> {
|
||||
children: [
|
||||
Icon(LucideIcons.history, size: 16, color: theme.hintColor),
|
||||
const SizedBox(width: 6),
|
||||
Text('搜索历史',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.hintColor)),
|
||||
Text(
|
||||
'搜索历史',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: _clearHistory,
|
||||
child: Text('清除',
|
||||
style:
|
||||
TextStyle(fontSize: 12, color: colorScheme.primary)),
|
||||
child: Text(
|
||||
'清除',
|
||||
style: TextStyle(fontSize: 12, color: colorScheme.primary),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -416,13 +449,15 @@ class _SearchDialogState extends State<SearchDialog> {
|
||||
Text(
|
||||
file.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w500, fontSize: 14),
|
||||
fontWeight: FontWeight.w500,
|
||||
fontSize: 14,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
Uri.decodeComponent(file.relativePath),
|
||||
FileUtils.safeDecodePathSegment(file.relativePath),
|
||||
style: TextStyle(fontSize: 12, color: theme.hintColor),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter/material.dart';
|
||||
/// 选择工具栏组件
|
||||
class SelectionToolbar extends StatelessWidget {
|
||||
final int selectionCount;
|
||||
final int totalCount;
|
||||
final VoidCallback onCancel;
|
||||
final VoidCallback? onRename;
|
||||
final VoidCallback? onSelectAll;
|
||||
final VoidCallback? onMore;
|
||||
final VoidCallback? onMove;
|
||||
final VoidCallback? onCopy;
|
||||
final VoidCallback onDelete;
|
||||
@@ -12,8 +14,10 @@ class SelectionToolbar extends StatelessWidget {
|
||||
const SelectionToolbar({
|
||||
super.key,
|
||||
required this.selectionCount,
|
||||
this.totalCount = 0,
|
||||
required this.onCancel,
|
||||
this.onRename,
|
||||
this.onSelectAll,
|
||||
this.onMore,
|
||||
this.onMove,
|
||||
this.onCopy,
|
||||
required this.onDelete,
|
||||
@@ -45,11 +49,19 @@ class SelectionToolbar extends StatelessWidget {
|
||||
onPressed: onCancel,
|
||||
tooltip: '取消选择',
|
||||
),
|
||||
if (selectionCount == 1 && onRename != null)
|
||||
if (onSelectAll != null && selectionCount < totalCount)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: onRename,
|
||||
tooltip: '重命名',
|
||||
icon: const Icon(Icons.select_all),
|
||||
onPressed: onSelectAll,
|
||||
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)
|
||||
IconButton(
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 同步统计卡片
|
||||
/// 宽屏(>=400):左饼图 + 右双列明细(左5右4)
|
||||
/// 窄屏(<400):上饼图+5指标,下4指标
|
||||
class SyncStatsCard extends StatelessWidget {
|
||||
final int uploaded;
|
||||
final int downloaded;
|
||||
final int renamed;
|
||||
final int moved;
|
||||
final int conflicts;
|
||||
final int failed;
|
||||
final int deletedLocal;
|
||||
final int deletedRemote;
|
||||
final int skipped;
|
||||
|
||||
const SyncStatsCard({
|
||||
super.key,
|
||||
required this.uploaded,
|
||||
required this.downloaded,
|
||||
required this.renamed,
|
||||
required this.moved,
|
||||
required this.conflicts,
|
||||
required this.failed,
|
||||
required this.deletedLocal,
|
||||
required this.deletedRemote,
|
||||
required this.skipped,
|
||||
});
|
||||
|
||||
static const _colorUploaded = Color(0xFF8E5D67);
|
||||
static const _colorDownloaded = Color(0xFF8BA7DA);
|
||||
static const _colorRenamed = Color(0xFFD4AF37);
|
||||
static const _colorMoved = Color(0xFF67B5B1);
|
||||
static const _colorConflicts = Color(0xFFE69A6A);
|
||||
static const _colorFailed = Color(0xFFD9534F);
|
||||
static const _colorDeletedLocal = Color(0xFFE57373);
|
||||
static const _colorDeletedRemote = Color(0xFFEF9A9A);
|
||||
static const _colorSkipped = Color(0xFFB0BEC5);
|
||||
|
||||
static const _narrowThreshold = 400.0;
|
||||
|
||||
int get _total =>
|
||||
uploaded +
|
||||
downloaded +
|
||||
renamed +
|
||||
moved +
|
||||
conflicts +
|
||||
failed +
|
||||
deletedLocal +
|
||||
deletedRemote +
|
||||
skipped;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
|
||||
final decoration = BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
theme.colorScheme.primaryContainer.withValues(alpha: 0.3),
|
||||
theme.colorScheme.tertiaryContainer.withValues(alpha: 0.3),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final isWide = constraints.maxWidth >= _narrowThreshold;
|
||||
|
||||
if (isWide) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: decoration,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
'同步数据概览',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
// 饼图
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 160,
|
||||
child: PieChart(
|
||||
PieChartData(
|
||||
sectionsSpace: 4,
|
||||
centerSpaceRadius: 42,
|
||||
sections: _buildSections(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$_total',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
// 双列明细
|
||||
Expanded(
|
||||
flex: 6,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 左列 5 项
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRow(
|
||||
'上传',
|
||||
uploaded,
|
||||
_colorUploaded,
|
||||
theme,
|
||||
),
|
||||
_buildRow(
|
||||
'下载',
|
||||
downloaded,
|
||||
_colorDownloaded,
|
||||
theme,
|
||||
),
|
||||
_buildRow('移动', moved, _colorMoved, theme),
|
||||
_buildRow(
|
||||
'冲突',
|
||||
conflicts,
|
||||
_colorConflicts,
|
||||
theme,
|
||||
),
|
||||
_buildRow('失败', failed, _colorFailed, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
// 右列 4 项
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRow('重命名', renamed, _colorRenamed, theme),
|
||||
_buildRow(
|
||||
'删本地',
|
||||
deletedLocal,
|
||||
_colorDeletedLocal,
|
||||
theme,
|
||||
),
|
||||
_buildRow(
|
||||
'删远程',
|
||||
deletedRemote,
|
||||
_colorDeletedRemote,
|
||||
theme,
|
||||
),
|
||||
_buildRow('跳过', skipped, _colorSkipped, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 窄屏:上下布局
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 24),
|
||||
decoration: decoration,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Center(
|
||||
child: Text(
|
||||
'同步数据概览',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 上半区:饼图 + 5 个主指标
|
||||
Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 120,
|
||||
height: 120,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
PieChart(
|
||||
PieChartData(
|
||||
sectionsSpace: 3,
|
||||
centerSpaceRadius: 32,
|
||||
sections: _buildSections(),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$_total',
|
||||
style: theme.textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 20),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildRow('上传', uploaded, _colorUploaded, theme),
|
||||
_buildRow('下载', downloaded, _colorDownloaded, theme),
|
||||
_buildRow('移动', moved, _colorMoved, theme),
|
||||
_buildRow('冲突', conflicts, _colorConflicts, theme),
|
||||
_buildRow('失败', failed, _colorFailed, theme),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 下半区:4 个副指标
|
||||
Row(
|
||||
children: [
|
||||
_buildSubItem('重命名', renamed, _colorRenamed, theme),
|
||||
const SizedBox(width: 16),
|
||||
_buildSubItem('删本地', deletedLocal, _colorDeletedLocal, theme),
|
||||
const SizedBox(width: 16),
|
||||
_buildSubItem(
|
||||
'删远程',
|
||||
deletedRemote,
|
||||
_colorDeletedRemote,
|
||||
theme,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildSubItem('跳过', skipped, _colorSkipped, theme),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 指标行:圆点 + 固定宽标签 + 数字,保证数字对齐
|
||||
Widget _buildRow(String label, int count, Color color, ThemeData theme) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 3.5),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
SizedBox(
|
||||
width: 40,
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 12, color: theme.hintColor),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$count',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 窄屏副指标:圆点 + 标签 + 数字(紧凑,Expanded 均分)
|
||||
Widget _buildSubItem(String label, int count, Color color, ThemeData theme) {
|
||||
return Expanded(
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 7,
|
||||
height: 7,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 11, color: theme.hintColor),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'$count',
|
||||
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<PieChartSectionData> _buildSections() {
|
||||
if (_total == 0) {
|
||||
return [
|
||||
PieChartSectionData(
|
||||
value: 1,
|
||||
color: const Color(0xFFE0E0E0),
|
||||
radius: 14,
|
||||
showTitle: false,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
final entries = [
|
||||
(uploaded, _colorUploaded, 18.0),
|
||||
(downloaded, _colorDownloaded, 16.0),
|
||||
(renamed, _colorRenamed, 14.0),
|
||||
(moved, _colorMoved, 12.0),
|
||||
(conflicts, _colorConflicts, 10.0),
|
||||
(failed, _colorFailed, 8.0),
|
||||
(deletedLocal, _colorDeletedLocal, 8.0),
|
||||
(deletedRemote, _colorDeletedRemote, 6.0),
|
||||
(skipped, _colorSkipped, 6.0),
|
||||
];
|
||||
|
||||
return entries
|
||||
.where((e) => e.$1 > 0)
|
||||
.map(
|
||||
(e) => PieChartSectionData(
|
||||
value: e.$1.toDouble(),
|
||||
color: e.$2,
|
||||
radius: e.$3,
|
||||
showTitle: false,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../data/models/app_update_model.dart';
|
||||
import '../../services/update_service.dart';
|
||||
import 'toast_helper.dart';
|
||||
|
||||
class UpdatePrompt extends StatefulWidget {
|
||||
final Widget child;
|
||||
|
||||
const UpdatePrompt({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<UpdatePrompt> createState() => _UpdatePromptState();
|
||||
}
|
||||
|
||||
class _UpdatePromptState extends State<UpdatePrompt> {
|
||||
static bool _checkedThisRun = false;
|
||||
bool _dialogVisible = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _checkUpdate());
|
||||
}
|
||||
|
||||
Future<void> _checkUpdate() async {
|
||||
if (_checkedThisRun || _dialogVisible || !mounted) return;
|
||||
_checkedThisRun = true;
|
||||
|
||||
final update = await UpdateService.instance.checkForUpdate();
|
||||
if (update == null || !mounted) return;
|
||||
final shouldShow = await UpdateService.instance.shouldShowPrompt(update);
|
||||
if (!shouldShow || !mounted) return;
|
||||
|
||||
_dialogVisible = true;
|
||||
try {
|
||||
await _showUpdateDialog(update);
|
||||
} finally {
|
||||
_dialogVisible = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showUpdateDialog(AppUpdateInfo update) async {
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (dialogContext) {
|
||||
Future<void> openDownloadsPage() async {
|
||||
try {
|
||||
await UpdateService.instance.openDownloadsPage();
|
||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||
} catch (e) {
|
||||
ToastHelper.failure('打开下载页面失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> skipPrompt() async {
|
||||
await UpdateService.instance.skipPromptForFiveDays(update);
|
||||
if (dialogContext.mounted) Navigator.of(dialogContext).pop();
|
||||
}
|
||||
|
||||
final releaseNotes = (update.description ?? '').trim();
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('发现新版本 ${update.version}'),
|
||||
content: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420, maxHeight: 360),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('新版本号:${update.version}'),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'版本日志',
|
||||
style: Theme.of(dialogContext).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(releaseNotes.isEmpty ? update.title : releaseNotes),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: skipPrompt, child: const Text('跳过')),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(onPressed: openDownloadsPage, child: const Text('更新')),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => widget.child;
|
||||
}
|
||||
@@ -7,6 +7,8 @@ import '../presentation/pages/recycle_bin/recycle_bin_page.dart';
|
||||
import '../presentation/pages/webdav/webdav_page.dart';
|
||||
import '../presentation/pages/remote_download/remote_download_page.dart';
|
||||
import '../presentation/pages/settings/settings_page.dart';
|
||||
import '../presentation/pages/sync/sync_page_android.dart';
|
||||
import '../presentation/pages/sync/sync_settings_page.dart';
|
||||
import '../presentation/pages/preview/image_preview_page.dart';
|
||||
import '../presentation/pages/preview/pdf_preview_page.dart';
|
||||
import '../presentation/pages/preview/video_preview_page.dart';
|
||||
@@ -14,6 +16,9 @@ import '../presentation/pages/preview/audio_preview_page.dart';
|
||||
import '../presentation/pages/preview/document_preview_page.dart';
|
||||
import '../presentation/pages/preview/markdown_preview_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';
|
||||
|
||||
/// 路由名称
|
||||
@@ -35,6 +40,10 @@ class RouteNames {
|
||||
static const String documentPreview = '/document-preview';
|
||||
static const String markdownPreview = '/markdown-preview';
|
||||
static const String categoryFiles = '/category-files';
|
||||
static const String syncStatus = '/sync-status';
|
||||
static const String syncSettings = '/sync-settings';
|
||||
static const String shareLink = '/share-link';
|
||||
static const String cloudreveFileApp = '/cloudreve-file-app';
|
||||
}
|
||||
|
||||
/// 应用路由
|
||||
@@ -218,6 +227,47 @@ class AppRouter {
|
||||
),
|
||||
);
|
||||
|
||||
case RouteNames.syncSettings:
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
builder: (context) => const SyncSettingsPage(),
|
||||
);
|
||||
|
||||
case RouteNames.syncStatus:
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
builder: (context) => const SyncPageAndroid(),
|
||||
);
|
||||
|
||||
case RouteNames.shareLink:
|
||||
final args = settings.arguments;
|
||||
if (args is ShareLinkCandidate) {
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
builder: (context) => ShareLinkPage(candidate: args),
|
||||
);
|
||||
}
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
builder: (context) => const SplashPage(),
|
||||
);
|
||||
|
||||
case RouteNames.cloudreveFileApp:
|
||||
final args = settings.arguments;
|
||||
if (args is Map<String, dynamic>) {
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
builder: (context) => CloudreveFileAppPage(
|
||||
file: args['file'] as FileModel,
|
||||
preferredAction: args['preferredAction'] as String?,
|
||||
),
|
||||
);
|
||||
}
|
||||
return MaterialPageRoute(
|
||||
settings: settings,
|
||||
builder: (context) => const SplashPage(),
|
||||
);
|
||||
|
||||
default:
|
||||
return MaterialPageRoute(
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import '../config/api_config.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../core/exceptions/app_exception.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
|
||||
@@ -9,7 +10,7 @@ import '../core/utils/app_logger.dart';
|
||||
class ApiResponse<T> {
|
||||
final int code;
|
||||
final String message;
|
||||
final T? data;
|
||||
final dynamic data;
|
||||
final String? error;
|
||||
final String? correlationId;
|
||||
|
||||
@@ -25,7 +26,7 @@ class ApiResponse<T> {
|
||||
return ApiResponse<T>(
|
||||
code: json['code'] as int? ?? 0,
|
||||
message: json['msg'] as String? ?? '',
|
||||
data: json['data'] as T?,
|
||||
data: json['data'],
|
||||
error: json['error'] as String?,
|
||||
correlationId: json['correlation_id'] as String?,
|
||||
);
|
||||
@@ -120,6 +121,11 @@ class ApiService {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
}
|
||||
}
|
||||
// 附加 X-Cr-Client-Id,服务端据此过滤 SSE 自身事件
|
||||
try {
|
||||
final clientId = await StorageService.instance.getOrCreateClientId();
|
||||
options.headers['X-Cr-Client-Id'] = clientId;
|
||||
} catch (_) {}
|
||||
return handler.next(options);
|
||||
},
|
||||
);
|
||||
@@ -192,6 +198,12 @@ class ApiService {
|
||||
AppLogger.d('API Error: ${error.requestOptions.uri} - ${error.message}');
|
||||
|
||||
// 检查是否是 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;
|
||||
if (!is401Error && error.response?.data is Map<String, dynamic>) {
|
||||
final data = error.response!.data as Map<String, dynamic>;
|
||||
@@ -314,12 +326,16 @@ class ApiService {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
bool noAuth = false,
|
||||
bool isNoData = false,
|
||||
bool silent404 = false,
|
||||
Map<String, dynamic>? headers,
|
||||
}) async {
|
||||
final response = await _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: Options(extra: {'noAuth': noAuth}, headers: headers),
|
||||
options: Options(
|
||||
extra: {'noAuth': noAuth, 'silent404': silent404},
|
||||
headers: headers,
|
||||
),
|
||||
);
|
||||
// 如果是分享请求, 则不进入 _parseResponse
|
||||
if (isNoData) {
|
||||
@@ -350,9 +366,12 @@ class ApiService {
|
||||
AppLogger.d('Response Data: ${response.data}');
|
||||
|
||||
var isActivEmail = 0;
|
||||
if (response.statusCode == 200) {
|
||||
Map<String, dynamic>? tmp = response.data as Map<String, dynamic>?;
|
||||
isActivEmail = tmp?['code'] as int;
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
final tmp = Map<String, dynamic>.from(response.data as Map);
|
||||
final code = tmp['code'];
|
||||
if (code is int) {
|
||||
isActivEmail = code;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNoData || isActivEmail == 203) {
|
||||
@@ -441,11 +460,18 @@ class ApiService {
|
||||
T _parseResponse<T>(Response response) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final apiResponse = ApiResponse<T>.fromJson(data);
|
||||
final apiResponse = ApiResponse<dynamic>.fromJson(data);
|
||||
if (!apiResponse.isSuccess && !apiResponse.isContinue) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../presentation/pages/auth/captcha_challenge_page.dart';
|
||||
@@ -30,6 +32,11 @@ class CaptchaService {
|
||||
String? _captchaToken;
|
||||
bool _isLoadingCaptcha = false;
|
||||
|
||||
// 代理配置(仅 Windows 桌面端)
|
||||
CaptchaProxyConfig? _proxyConfig;
|
||||
|
||||
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
|
||||
|
||||
bool get isLoadingCaptcha => _isLoadingCaptcha;
|
||||
String? get captchaImage => _captchaImage;
|
||||
String? get captchaTicket => _captchaTicket;
|
||||
@@ -103,13 +110,15 @@ class CaptchaService {
|
||||
Map<String, dynamic> config = <String, dynamic>{};
|
||||
|
||||
try {
|
||||
config = await AuthService.instance.getBasicSiteConfig().timeout(
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
config = await AuthService.instance
|
||||
.getBasicSiteConfig()
|
||||
.timeout(const Duration(seconds: 10));
|
||||
} catch (_) {}
|
||||
|
||||
final captchaType = _normalizeCaptchaType(
|
||||
(config['captcha_type'] ?? config['captchaType'] ?? config['captcha'])
|
||||
(config['captcha_type'] ??
|
||||
config['captchaType'] ??
|
||||
config['captcha'])
|
||||
?.toString(),
|
||||
);
|
||||
|
||||
@@ -151,8 +160,7 @@ class CaptchaService {
|
||||
'capAssetServer',
|
||||
]);
|
||||
|
||||
final isExternalCaptcha =
|
||||
captchaType == 'turnstile' ||
|
||||
final isExternalCaptcha = captchaType == 'turnstile' ||
|
||||
captchaType == 'recaptcha' ||
|
||||
captchaType == 'cap';
|
||||
|
||||
@@ -199,7 +207,7 @@ class CaptchaService {
|
||||
}
|
||||
|
||||
/// 跳转到 Web 验证码页面
|
||||
Future<void> openCaptchaChallenge(BuildContext context) async {
|
||||
Future<void> openCaptchaChallenge(BuildContext context, {VoidCallback? onVerified}) async {
|
||||
final server = ServerService.instance.currentServer;
|
||||
final config = captchaWebConfig;
|
||||
|
||||
@@ -210,14 +218,18 @@ class CaptchaService {
|
||||
|
||||
final token = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
CaptchaChallengePage(config: config, baseUrl: server.baseUrl),
|
||||
builder: (_) => CaptchaChallengePage(
|
||||
config: config,
|
||||
baseUrl: server.baseUrl,
|
||||
proxyConfig: _isDesktop ? _proxyConfig : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (token != null && token.isNotEmpty) {
|
||||
_captchaToken = token;
|
||||
ToastHelper.success('人机验证完成');
|
||||
onVerified?.call();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,16 +253,21 @@ class CaptchaService {
|
||||
Map<String, String> getCaptchaParams() {
|
||||
if (isWebCaptcha) {
|
||||
if (_captchaToken == null || _captchaToken!.isEmpty) return {};
|
||||
return {'captcha': _captchaToken!, 'ticket': _captchaToken!};
|
||||
return {
|
||||
'captcha': _captchaToken!,
|
||||
'ticket': _captchaToken!,
|
||||
};
|
||||
}
|
||||
|
||||
final userInput = captchaController.text.trim();
|
||||
if (userInput.isEmpty &&
|
||||
(_captchaTicket == null || _captchaTicket!.isEmpty)) {
|
||||
if (userInput.isEmpty && (_captchaTicket == null || _captchaTicket!.isEmpty)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {'captcha': userInput, 'ticket': _captchaTicket ?? ''};
|
||||
return {
|
||||
'captcha': userInput,
|
||||
'ticket': _captchaTicket ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/// Web 验证码是否已通过
|
||||
@@ -263,30 +280,44 @@ class CaptchaService {
|
||||
final config = captchaWebConfig;
|
||||
final displayName = config?.displayName ?? '人机验证';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoadingCaptcha
|
||||
? null
|
||||
: () => openCaptchaChallenge(context),
|
||||
icon: Icon(
|
||||
_captchaToken == null
|
||||
? Icons.verified_user_outlined
|
||||
: Icons.verified,
|
||||
),
|
||||
label: Text(
|
||||
_captchaToken == null
|
||||
? '点击完成 $displayName'
|
||||
: '$displayName 已完成,点击重新验证',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'当前验证码类型:$displayName',
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final hasProxy = _proxyConfig != null;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoadingCaptcha ? null : () async {
|
||||
await openCaptchaChallenge(context);
|
||||
setState(() {});
|
||||
},
|
||||
onLongPress: _isDesktop && Platform.isWindows
|
||||
? () => _showProxyDialog(context, setState)
|
||||
: null,
|
||||
icon: Icon(
|
||||
_captchaToken == null
|
||||
? Icons.verified_user_outlined
|
||||
: Icons.verified,
|
||||
),
|
||||
label: Text(
|
||||
_captchaToken == null
|
||||
? '点击完成 $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;
|
||||
}
|
||||
|
||||
/// Windows 桌面端长按弹出代理设置对话框
|
||||
void _showProxyDialog(BuildContext context, StateSetter setState) {
|
||||
final hostCtrl = TextEditingController(text: _proxyConfig?.host ?? '');
|
||||
final portCtrl = TextEditingController(text: _proxyConfig?.port.toString() ?? '');
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('WebView 代理设置'),
|
||||
content: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'仅支持无认证代理(HTTP/SOCKS5)',
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: hostCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '代理地址',
|
||||
hintText: '127.0.0.1',
|
||||
prefixIcon: Icon(Icons.dns_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: portCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '端口',
|
||||
hintText: '7890',
|
||||
prefixIcon: Icon(Icons.numbers),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_proxyConfig = null;
|
||||
setState(() {});
|
||||
Navigator.of(ctx).pop();
|
||||
ToastHelper.success('已清除代理配置');
|
||||
},
|
||||
child: const Text('清除'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final host = hostCtrl.text.trim();
|
||||
final port = int.tryParse(portCtrl.text.trim()) ?? 0;
|
||||
if (host.isEmpty || port <= 0) {
|
||||
ToastHelper.failure('请输入有效的代理地址和端口');
|
||||
return;
|
||||
}
|
||||
_proxyConfig = CaptchaProxyConfig(host: host, port: port);
|
||||
setState(() {});
|
||||
Navigator.of(ctx).pop();
|
||||
ToastHelper.success('代理已设置: $host:$port');
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:media_kit/media_kit.dart';
|
||||
|
||||
import '../core/constants/storage_keys.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
import 'host_mapping_proxy_service.dart';
|
||||
import 'server_service.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
class CustomLineService {
|
||||
CustomLineService._();
|
||||
|
||||
static final CustomLineService instance = CustomLineService._();
|
||||
|
||||
HostMappingProxyEndpoint? get currentProxyEndpoint =>
|
||||
HostMappingProxyService.instance.endpoint;
|
||||
|
||||
Future<SelectedCustomLineNode?> getSelectedNode() async {
|
||||
final raw = await StorageService.instance.getString(
|
||||
StorageKeys.selectedCustomLineNode,
|
||||
);
|
||||
if (raw == null || raw.isEmpty) return null;
|
||||
|
||||
try {
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! Map<String, dynamic>) return null;
|
||||
final node = SelectedCustomLineNode.fromJson(decoded);
|
||||
if (!node.canRewriteDownloadUrl) return null;
|
||||
return node;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setSelectedNode(SelectedCustomLineNode node) async {
|
||||
if (!node.canRewriteDownloadUrl) {
|
||||
throw ArgumentError('The selected custom line node has no usable IP.');
|
||||
}
|
||||
await StorageService.instance.setString(
|
||||
StorageKeys.selectedCustomLineNode,
|
||||
jsonEncode(node.toJson()),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearSelectedNode() async {
|
||||
await StorageService.instance.remove(StorageKeys.selectedCustomLineNode);
|
||||
await HostMappingProxyService.instance.stop();
|
||||
}
|
||||
|
||||
Future<CustomLineDownloadRoute> routeDownloadUrl(String url) async {
|
||||
if (!_canUseCustomLine()) {
|
||||
await HostMappingProxyService.instance.stop();
|
||||
return CustomLineDownloadRoute(url: url);
|
||||
}
|
||||
|
||||
final node = await getSelectedNode();
|
||||
if (node == null) return CustomLineDownloadRoute(url: url);
|
||||
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null || uri.host.isEmpty) {
|
||||
return CustomLineDownloadRoute(url: url);
|
||||
}
|
||||
|
||||
final endpoint = await activateSelectedNodeForHost(uri.host);
|
||||
if (endpoint == null) {
|
||||
return CustomLineDownloadRoute(url: url);
|
||||
}
|
||||
|
||||
return CustomLineDownloadRoute(
|
||||
url: url,
|
||||
nodeName: node.name,
|
||||
originalHost: uri.host,
|
||||
proxy: endpoint,
|
||||
);
|
||||
}
|
||||
|
||||
Future<HostMappingProxyEndpoint?> activateSelectedNodeForHost(String host) {
|
||||
return activateSelectedNodeForHosts([host]);
|
||||
}
|
||||
|
||||
Future<HostMappingProxyEndpoint?> activateSelectedNodeForHosts(
|
||||
Iterable<String> hosts,
|
||||
) async {
|
||||
if (!_canUseCustomLine()) {
|
||||
await HostMappingProxyService.instance.stop();
|
||||
return null;
|
||||
}
|
||||
|
||||
final node = await getSelectedNode();
|
||||
if (node == null) {
|
||||
await HostMappingProxyService.instance.stop();
|
||||
return null;
|
||||
}
|
||||
|
||||
final hostMap = <String, String>{};
|
||||
void addHost(String value) {
|
||||
final host = value.trim();
|
||||
if (host.isNotEmpty) hostMap[host] = node.ip;
|
||||
}
|
||||
|
||||
addHost(node.host);
|
||||
for (final host in hosts) {
|
||||
addHost(host);
|
||||
}
|
||||
|
||||
if (hostMap.isEmpty) {
|
||||
await HostMappingProxyService.instance.stop();
|
||||
return null;
|
||||
}
|
||||
return HostMappingProxyService.instance.configure(hostMap);
|
||||
}
|
||||
|
||||
Future<HostMappingProxyEndpoint?> activateSelectedNodeForUrl(String url) {
|
||||
final uri = Uri.tryParse(url);
|
||||
return activateSelectedNodeForHost(uri?.host ?? '');
|
||||
}
|
||||
|
||||
Future<HostMappingProxyEndpoint?> activateSelectedNodeForUrls(
|
||||
Iterable<String> urls,
|
||||
) {
|
||||
final hosts = urls
|
||||
.map((url) => Uri.tryParse(url)?.host ?? '')
|
||||
.where((host) => host.trim().isNotEmpty);
|
||||
return activateSelectedNodeForHosts(hosts);
|
||||
}
|
||||
|
||||
Future<void> configureMediaPlayerProxy(Player player, String url) async {
|
||||
final endpoint = await activateSelectedNodeForUrl(url);
|
||||
final platform = player.platform;
|
||||
if (platform is! NativePlayer) return;
|
||||
|
||||
try {
|
||||
await platform.setProperty('http-proxy', endpoint?.proxyUri ?? '');
|
||||
AppLogger.d('媒体播放器自定义线路代理已配置: ${endpoint?.proxyUri ?? 'DIRECT'}');
|
||||
} catch (e) {
|
||||
AppLogger.d('媒体播放器自定义线路代理配置失败: $e');
|
||||
}
|
||||
}
|
||||
|
||||
bool _canUseCustomLine() {
|
||||
final user = ServerService.instance.currentServer?.user;
|
||||
final groupName = user?.group?.name.trim().toLowerCase();
|
||||
return user != null &&
|
||||
groupName != null &&
|
||||
groupName.isNotEmpty &&
|
||||
groupName != 'user';
|
||||
}
|
||||
}
|
||||
|
||||
class SelectedCustomLineNode {
|
||||
final String id;
|
||||
final String name;
|
||||
final String ip;
|
||||
final String host;
|
||||
final String protocol;
|
||||
final int? port;
|
||||
|
||||
const SelectedCustomLineNode({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.ip,
|
||||
required this.host,
|
||||
this.protocol = '',
|
||||
this.port,
|
||||
});
|
||||
|
||||
bool get canRewriteDownloadUrl =>
|
||||
ip.trim().isNotEmpty && host.trim().isNotEmpty;
|
||||
|
||||
String routeScheme(String fallback) {
|
||||
final value = protocol.trim().toLowerCase();
|
||||
return value == 'http' || value == 'https' ? value : fallback;
|
||||
}
|
||||
|
||||
int? get routePort => port != null && port! > 0 ? port : null;
|
||||
|
||||
factory SelectedCustomLineNode.fromJson(Map<String, dynamic> json) {
|
||||
return SelectedCustomLineNode(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '',
|
||||
ip: json['ip']?.toString() ?? '',
|
||||
host: json['host']?.toString() ?? '',
|
||||
protocol: json['protocol']?.toString() ?? '',
|
||||
port: _asNullableInt(json['port']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'id': id,
|
||||
'name': name,
|
||||
'ip': ip,
|
||||
'host': host,
|
||||
if (protocol.trim().isNotEmpty) 'protocol': protocol,
|
||||
if (routePort != null) 'port': routePort,
|
||||
};
|
||||
}
|
||||
|
||||
static int? _asNullableInt(Object? value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.round();
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
class CustomLineDownloadRoute {
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
final String? nodeName;
|
||||
final String? originalHost;
|
||||
final HostMappingProxyEndpoint? proxy;
|
||||
|
||||
const CustomLineDownloadRoute({
|
||||
required this.url,
|
||||
this.headers = const {},
|
||||
this.nodeName,
|
||||
this.originalHost,
|
||||
this.proxy,
|
||||
});
|
||||
|
||||
bool get changed => proxy != null || headers.isNotEmpty;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ import 'package:tray_manager/tray_manager.dart';
|
||||
import '../config/app_config.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../presentation/providers/theme_provider.dart';
|
||||
import 'sync_service.dart';
|
||||
import '../src/rust/api/ffi.dart' as ffi;
|
||||
|
||||
/// 桌面端服务(窗口管理 + 系统托盘)
|
||||
class DesktopService with TrayListener, WindowListener {
|
||||
@@ -188,6 +190,22 @@ class DesktopService with TrayListener, WindowListener {
|
||||
try {
|
||||
AppLogger.d('DesktopService: Cleaning up before exit...');
|
||||
|
||||
// 同步引擎清理(WCF 模式下必须调用,同步释放占位符、注销 sync root)
|
||||
try {
|
||||
await SyncService.instance.stop();
|
||||
AppLogger.d('DesktopService: Sync engine stopped');
|
||||
} catch (e) {
|
||||
AppLogger.e('DesktopService: Error stopping sync: $e');
|
||||
}
|
||||
|
||||
// 进程退出前同步清理(确保 WCF 资源释放,不依赖 tokio runtime)
|
||||
try {
|
||||
await ffi.syncShutdown();
|
||||
AppLogger.d('DesktopService: Sync shutdown complete');
|
||||
} catch (e) {
|
||||
AppLogger.e('DesktopService: Error in sync shutdown: $e');
|
||||
}
|
||||
|
||||
// 彻底解绑
|
||||
windowManager.removeListener(this);
|
||||
trayManager.removeListener(this);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// 全局弹窗队列。
|
||||
///
|
||||
/// 用于登录后自动弹出的公告、更新、剪贴板分享链接等场景,保证同一时间
|
||||
/// 只有一个弹窗/引导流程在前台显示。前一个流程结束后,后一个流程才会执行。
|
||||
class DialogQueueService {
|
||||
DialogQueueService._();
|
||||
|
||||
static final DialogQueueService instance = DialogQueueService._();
|
||||
|
||||
Future<void> _tail = Future<void>.value();
|
||||
|
||||
Future<T?> enqueue<T>(Future<T?> Function() task) {
|
||||
final completer = Completer<T?>();
|
||||
|
||||
_tail = _tail.catchError((_) {}).then((_) async {
|
||||
try {
|
||||
final result = await task();
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(result);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:background_downloader/background_downloader.dart' as bd;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../core/constants/storage_keys.dart';
|
||||
import '../data/models/download_task_model.dart';
|
||||
import 'custom_line_service.dart';
|
||||
import 'file_service.dart';
|
||||
import 'storage_service.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
@@ -27,7 +29,7 @@ class DownloadService {
|
||||
|
||||
// 回调处理器
|
||||
static Function(String taskId, DownloadStatus status, int progress)?
|
||||
_callbackHandler;
|
||||
_callbackHandler;
|
||||
|
||||
final FileService _fileService = FileService();
|
||||
final Map<String, StreamController<DownloadTaskModel>> _progressControllers =
|
||||
@@ -36,7 +38,8 @@ class DownloadService {
|
||||
|
||||
/// 设置回调处理器
|
||||
static void setCallbackHandler(
|
||||
Function(String taskId, DownloadStatus status, int progress) handler) {
|
||||
Function(String taskId, DownloadStatus status, int progress) handler,
|
||||
) {
|
||||
_callbackHandler = handler;
|
||||
}
|
||||
|
||||
@@ -65,7 +68,10 @@ class DownloadService {
|
||||
throw Exception('存储权限被拒绝');
|
||||
}
|
||||
|
||||
final directory = Directory('/storage/emulated/0/Download');
|
||||
final downloadPath = await ExternalPath.getExternalStoragePublicDirectory(
|
||||
ExternalPath.DIRECTORY_DOWNLOAD,
|
||||
);
|
||||
final directory = Directory(downloadPath);
|
||||
if (!await directory.exists()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
@@ -103,22 +109,23 @@ class DownloadService {
|
||||
|
||||
/// 读取 WiFi-only 下载设置
|
||||
Future<bool> isWifiOnlyEnabled() async {
|
||||
return await StorageService.instance
|
||||
.getBool(StorageKeys.downloadWifiOnly) ??
|
||||
return await StorageService.instance.getBool(
|
||||
StorageKeys.downloadWifiOnly,
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
/// 读取重试次数设置
|
||||
Future<int> getRetries() async {
|
||||
return await StorageService.instance
|
||||
.getInt(StorageKeys.downloadRetries) ??
|
||||
return await StorageService.instance.getInt(StorageKeys.downloadRetries) ??
|
||||
3;
|
||||
}
|
||||
|
||||
/// 初始化下载器
|
||||
Future<void> initialize(
|
||||
{Function(String taskId, DownloadStatus status, int progress)?
|
||||
callbackHandler}) async {
|
||||
Future<void> initialize({
|
||||
Function(String taskId, DownloadStatus status, int progress)?
|
||||
callbackHandler,
|
||||
}) async {
|
||||
if (callbackHandler != null) {
|
||||
setCallbackHandler(callbackHandler);
|
||||
AppLogger.d('回调处理器已更新');
|
||||
@@ -133,9 +140,10 @@ class DownloadService {
|
||||
if (Platform.isAndroid) {
|
||||
bd.FileDownloader().configureNotification(
|
||||
running: const bd.TaskNotification(
|
||||
'正在下载', '文件: {filename} - {progress}'),
|
||||
complete:
|
||||
const bd.TaskNotification('下载完成', '文件: {filename} 已保存'),
|
||||
'正在下载',
|
||||
'文件: {filename} - {progress}',
|
||||
),
|
||||
complete: const bd.TaskNotification('下载完成', '文件: {filename} 已保存'),
|
||||
error: const bd.TaskNotification('下载失败', '文件: {filename} 下载出错'),
|
||||
paused: const bd.TaskNotification('已暂停', '文件: {filename} 已暂停'),
|
||||
progressBar: true,
|
||||
@@ -170,14 +178,15 @@ class DownloadService {
|
||||
_internalIdToExternalTaskId[metaInternalId] = update.task.taskId;
|
||||
_bdTasks[metaInternalId] = update.task as bd.DownloadTask;
|
||||
AppLogger.d(
|
||||
'从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId');
|
||||
'从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId',
|
||||
);
|
||||
}
|
||||
|
||||
final resolvedInternalId =
|
||||
_externalTaskIdToInternalId[update.task.taskId];
|
||||
final resolvedInternalId = _externalTaskIdToInternalId[update.task.taskId];
|
||||
if (resolvedInternalId == null) {
|
||||
AppLogger.d(
|
||||
'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}');
|
||||
'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,7 +210,8 @@ class DownloadService {
|
||||
}
|
||||
|
||||
AppLogger.d(
|
||||
'background_downloader 状态更新: taskId=${update.task.taskId}, internalId=$resolvedInternalId, status=$status');
|
||||
'background_downloader 状态更新: taskId=${update.task.taskId}, internalId=$resolvedInternalId, status=$status',
|
||||
);
|
||||
|
||||
final progress = status == DownloadStatus.completed ? 100 : 0;
|
||||
_callbackHandler?.call(resolvedInternalId, status, progress);
|
||||
@@ -252,7 +262,14 @@ class DownloadService {
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
return _startBdDownload(task, url, dir);
|
||||
final route = await CustomLineService.instance.routeDownloadUrl(url);
|
||||
if (route.changed) {
|
||||
AppLogger.d(
|
||||
'自定义线路下载代理已应用: node=${route.nodeName}, host=${route.originalHost}, proxy=${route.proxy?.proxyUri}',
|
||||
);
|
||||
}
|
||||
|
||||
return _startBdDownload(task, route.url, dir, headers: route.headers);
|
||||
} catch (e) {
|
||||
AppLogger.d('下载失败: $e');
|
||||
rethrow;
|
||||
@@ -261,11 +278,17 @@ class DownloadService {
|
||||
|
||||
/// 使用 background_downloader 开始下载
|
||||
Future<String?> _startBdDownload(
|
||||
DownloadTaskModel task, String url, Directory dir) async {
|
||||
DownloadTaskModel task,
|
||||
String url,
|
||||
Directory dir, {
|
||||
Map<String, String> headers = const {},
|
||||
}) async {
|
||||
await _configureCustomLineProxy();
|
||||
final wifiOnly = await isWifiOnlyEnabled();
|
||||
final retries = await getRetries();
|
||||
final bdTask = bd.DownloadTask(
|
||||
url: url,
|
||||
headers: headers,
|
||||
filename: task.fileName,
|
||||
directory: dir.path,
|
||||
baseDirectory: bd.BaseDirectory.root,
|
||||
@@ -291,14 +314,14 @@ class DownloadService {
|
||||
task.backgroundTaskId = bdTask.taskId;
|
||||
|
||||
AppLogger.d(
|
||||
'background_downloader 任务已添加: taskId=${bdTask.taskId}, internalId=${task.id}, requiresWiFi=$wifiOnly, retries=$retries');
|
||||
'background_downloader 任务已添加: taskId=${bdTask.taskId}, internalId=${task.id}, requiresWiFi=$wifiOnly, retries=$retries',
|
||||
);
|
||||
|
||||
return bdTask.taskId;
|
||||
}
|
||||
|
||||
/// 恢复下载(用于重启后恢复暂停的任务)
|
||||
Future<String?> resumeDownloadAfterRestart(
|
||||
DownloadTaskModel task) async {
|
||||
Future<String?> resumeDownloadAfterRestart(DownloadTaskModel task) async {
|
||||
try {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
@@ -328,10 +351,19 @@ class DownloadService {
|
||||
}
|
||||
|
||||
// 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask
|
||||
final route = await CustomLineService.instance.routeDownloadUrl(url);
|
||||
if (route.changed) {
|
||||
AppLogger.d(
|
||||
'自定义线路下载代理已应用: node=${route.nodeName}, host=${route.originalHost}, proxy=${route.proxy?.proxyUri}',
|
||||
);
|
||||
}
|
||||
|
||||
await _configureCustomLineProxy();
|
||||
final wifiOnly = await isWifiOnlyEnabled();
|
||||
final retries = await getRetries();
|
||||
final bdTask = bd.DownloadTask(
|
||||
url: url,
|
||||
url: route.url,
|
||||
headers: route.headers,
|
||||
filename: task.fileName,
|
||||
directory: dir.path,
|
||||
baseDirectory: bd.BaseDirectory.root,
|
||||
@@ -349,11 +381,11 @@ class DownloadService {
|
||||
task.backgroundTaskId = bdTask.taskId;
|
||||
|
||||
// 如果有已下载的部分,尝试 resume;否则 enqueue
|
||||
final partialFile =
|
||||
File('${dir.path}/${task.fileName}.part');
|
||||
final partialFile = File('${dir.path}/${task.fileName}.part');
|
||||
if (task.downloadedBytes > 0 && await partialFile.exists()) {
|
||||
AppLogger.d(
|
||||
'断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes');
|
||||
'断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes',
|
||||
);
|
||||
await bd.FileDownloader().resume(bdTask);
|
||||
} else {
|
||||
AppLogger.d('重新下载: ${task.fileName}');
|
||||
@@ -370,6 +402,14 @@ class DownloadService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _configureCustomLineProxy() async {
|
||||
final endpoint = CustomLineService.instance.currentProxyEndpoint;
|
||||
final config = endpoint == null ? false : (endpoint.host, endpoint.port);
|
||||
await bd.FileDownloader().configure(
|
||||
globalConfig: (bd.Config.proxy, config),
|
||||
);
|
||||
}
|
||||
|
||||
/// 暂停下载
|
||||
Future<void> pauseDownload(String taskId) async {
|
||||
final bdTask = _bdTasks[taskId];
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import '../core/utils/file_type_utils.dart';
|
||||
import '../core/utils/file_utils.dart';
|
||||
import '../data/models/file_model.dart';
|
||||
|
||||
class FileAppViewer {
|
||||
final String id;
|
||||
final String type;
|
||||
final String displayName;
|
||||
final List<String> exts;
|
||||
final String? icon;
|
||||
final int maxSize;
|
||||
final String? url;
|
||||
|
||||
const FileAppViewer({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.displayName,
|
||||
required this.exts,
|
||||
this.icon,
|
||||
this.maxSize = 0,
|
||||
this.url,
|
||||
});
|
||||
|
||||
factory FileAppViewer.fromJson(Map<String, dynamic> json) {
|
||||
return FileAppViewer(
|
||||
id: json['id']?.toString() ?? '',
|
||||
type: json['type']?.toString() ?? '',
|
||||
displayName: json['display_name']?.toString() ??
|
||||
json['displayName']?.toString() ??
|
||||
json['name']?.toString() ??
|
||||
'文件应用',
|
||||
exts: _parseExts(json['exts'] ?? json['extensions'] ?? json['ext']),
|
||||
icon: json['icon']?.toString(),
|
||||
maxSize: (json['max_size'] as num?)?.toInt() ??
|
||||
(json['maxSize'] as num?)?.toInt() ??
|
||||
0,
|
||||
url: json['url']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _parseExts(dynamic raw) {
|
||||
if (raw is List) {
|
||||
return raw
|
||||
.map((e) => e.toString().toLowerCase().replaceAll('.', '').trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (raw is String) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((e) => e.toLowerCase().replaceAll('.', '').trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
bool supports(FileModel file) {
|
||||
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
|
||||
if (ext.isEmpty || !exts.contains(ext)) return false;
|
||||
if (maxSize > 0 && file.size > maxSize) return false;
|
||||
return id.isNotEmpty;
|
||||
}
|
||||
|
||||
bool get isWopi => type.toLowerCase().contains('wopi');
|
||||
}
|
||||
|
||||
class FileAppSession {
|
||||
final FileAppViewer viewer;
|
||||
final String wopiSrc;
|
||||
final String? accessToken;
|
||||
final int? expires;
|
||||
|
||||
const FileAppSession({
|
||||
required this.viewer,
|
||||
required this.wopiSrc,
|
||||
this.accessToken,
|
||||
this.expires,
|
||||
});
|
||||
|
||||
bool get hasAccessToken => accessToken != null && accessToken!.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Cloudreve V4 文件应用服务。
|
||||
///
|
||||
/// 注意:部分 Cloudreve V4 站点没有 `/site/config`,只有 `/site/config/basic`。
|
||||
/// 所以这里优先请求 `/site/config/basic`,如果没有 file_viewers 再尝试
|
||||
/// `/site/config`,并且对 404 做静默降级,避免 WebView 打开器直接崩溃。
|
||||
class FileAppService {
|
||||
FileAppService._();
|
||||
|
||||
static final FileAppService instance = FileAppService._();
|
||||
|
||||
List<FileAppViewer>? _cachedViewers;
|
||||
DateTime? _cachedAt;
|
||||
|
||||
Future<List<FileAppViewer>> getViewers({bool forceRefresh = false}) async {
|
||||
final cached = _cachedViewers;
|
||||
final cachedAt = _cachedAt;
|
||||
if (!forceRefresh &&
|
||||
cached != null &&
|
||||
cachedAt != null &&
|
||||
DateTime.now().difference(cachedAt).inMinutes < 10) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final viewers = await _loadViewersFromSiteConfig();
|
||||
|
||||
_cachedViewers = viewers;
|
||||
_cachedAt = DateTime.now();
|
||||
|
||||
return viewers;
|
||||
}
|
||||
|
||||
Future<List<FileAppViewer>> _loadViewersFromSiteConfig() async {
|
||||
// 当前项目登录验证码就是从 /site/config/basic 取的;
|
||||
// /site/config 在部分 Cloudreve 部署中不存在,会返回 404。
|
||||
const endpoints = <String>[
|
||||
'/site/config/basic',
|
||||
'/site/config',
|
||||
];
|
||||
|
||||
for (final endpoint in endpoints) {
|
||||
final root = await _tryGetSiteConfig(endpoint);
|
||||
if (root == null) continue;
|
||||
|
||||
final viewers = _extractViewers(root);
|
||||
if (viewers.isNotEmpty) {
|
||||
return viewers;
|
||||
}
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _tryGetSiteConfig(String endpoint) async {
|
||||
try {
|
||||
final response = await ApiService.instance.dio.get<dynamic>(
|
||||
endpoint,
|
||||
options: Options(
|
||||
extra: {'noAuth': true},
|
||||
// 404 也作为普通响应返回,由这里自己处理,避免进入全局错误拦截器。
|
||||
validateStatus: (status) => status != null && status >= 200 && status < 500,
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final raw = response.data;
|
||||
final map = _asMap(raw);
|
||||
if (map == null) return null;
|
||||
|
||||
final code = map['code'];
|
||||
if (code is int && code != 0 && code != 203) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _asMap(map['data']) ?? map;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<FileAppViewer?> findViewerForFile(
|
||||
FileModel file, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final viewers = await getViewers(forceRefresh: forceRefresh);
|
||||
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
|
||||
|
||||
final candidates = viewers.where((viewer) => viewer.supports(file)).toList();
|
||||
if (candidates.isEmpty) return null;
|
||||
|
||||
candidates.sort((a, b) {
|
||||
final aw = a.isWopi ? 0 : 1;
|
||||
final bw = b.isWopi ? 0 : 1;
|
||||
if (aw != bw) return aw.compareTo(bw);
|
||||
|
||||
final aExact = a.exts.contains(ext) ? 0 : 1;
|
||||
final bExact = b.exts.contains(ext) ? 0 : 1;
|
||||
return aExact.compareTo(bExact);
|
||||
});
|
||||
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
Future<FileAppSession> createViewerSession({
|
||||
required FileModel file,
|
||||
required FileAppViewer viewer,
|
||||
String preferredAction = 'view',
|
||||
}) async {
|
||||
final uri = FileUtils.toCloudreveUri(file.relativePath);
|
||||
final data = <String, dynamic>{
|
||||
'uri': uri,
|
||||
'viewer_id': viewer.id,
|
||||
'preferred_action': preferredAction,
|
||||
'parent_uri': _parentUri(uri),
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.put<Map<String, dynamic>>(
|
||||
'/file/viewerSession',
|
||||
data: data,
|
||||
);
|
||||
|
||||
final root = _asMap(response['data']) ?? response;
|
||||
final session = _asMap(root['session']);
|
||||
final wopiSrc = root['wopi_src']?.toString() ??
|
||||
root['wopiSrc']?.toString() ??
|
||||
root['url']?.toString() ??
|
||||
'';
|
||||
|
||||
if (wopiSrc.isEmpty) {
|
||||
throw Exception('文件应用没有返回可打开的 URL');
|
||||
}
|
||||
|
||||
return FileAppSession(
|
||||
viewer: viewer,
|
||||
wopiSrc: _absoluteUrl(wopiSrc),
|
||||
accessToken: session?['access_token']?.toString() ??
|
||||
session?['accessToken']?.toString(),
|
||||
expires: (session?['expires'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<FileAppSession> openFile({
|
||||
required FileModel file,
|
||||
String preferredAction = 'view',
|
||||
}) async {
|
||||
final viewer = await findViewerForFile(file);
|
||||
if (viewer == null) {
|
||||
throw Exception('没有找到支持 ${FileTypeUtils.getExtension(file.name)} 的 Cloudreve 文件应用');
|
||||
}
|
||||
|
||||
try {
|
||||
return await createViewerSession(
|
||||
file: file,
|
||||
viewer: viewer,
|
||||
preferredAction: preferredAction,
|
||||
);
|
||||
} catch (_) {
|
||||
if (preferredAction != 'view') {
|
||||
return createViewerSession(
|
||||
file: file,
|
||||
viewer: viewer,
|
||||
preferredAction: 'view',
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
List<FileAppViewer> _extractViewers(Map<String, dynamic> root) {
|
||||
final raw = _findValueByKey(root, const [
|
||||
'file_viewers',
|
||||
'fileViewers',
|
||||
'file_viewer',
|
||||
'viewers',
|
||||
]);
|
||||
|
||||
dynamic viewerList = raw;
|
||||
|
||||
final rawMap = _asMap(raw);
|
||||
if (rawMap != null) {
|
||||
viewerList = rawMap['viewers'] ?? rawMap['items'] ?? rawMap['data'];
|
||||
}
|
||||
|
||||
if (viewerList is! List) return const [];
|
||||
|
||||
return viewerList
|
||||
.whereType<Map>()
|
||||
.map((item) => FileAppViewer.fromJson(Map<String, dynamic>.from(item)))
|
||||
.where((viewer) => viewer.id.isNotEmpty && viewer.exts.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
dynamic _findValueByKey(dynamic value, List<String> keys) {
|
||||
if (value is Map) {
|
||||
for (final key in keys) {
|
||||
if (value.containsKey(key)) {
|
||||
return value[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (final child in value.values) {
|
||||
final found = _findValueByKey(child, keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
} else if (value is List) {
|
||||
for (final child in value) {
|
||||
final found = _findValueByKey(child, keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
String _parentUri(String uri) {
|
||||
final normalized = uri.endsWith('/') ? uri.substring(0, uri.length - 1) : uri;
|
||||
final index = normalized.lastIndexOf('/');
|
||||
if (index <= 'cloudreve://my'.length) {
|
||||
return 'cloudreve://my';
|
||||
}
|
||||
return normalized.substring(0, index);
|
||||
}
|
||||
|
||||
String _absoluteUrl(String url) {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
|
||||
final origin = Uri(
|
||||
scheme: base.scheme,
|
||||
host: base.host,
|
||||
port: base.hasPort ? base.port : null,
|
||||
).toString();
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
return '$origin$url';
|
||||
}
|
||||
|
||||
return '$origin/$url';
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'api_service.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../core/utils/file_utils.dart';
|
||||
import 'custom_line_service.dart';
|
||||
|
||||
/// 文件服务
|
||||
class FileService {
|
||||
@@ -156,10 +157,25 @@ class FileService {
|
||||
data: data,
|
||||
headers: headers,
|
||||
);
|
||||
await _activateCustomLineForUrls(response);
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
Future<void> _activateCustomLineForUrls(Map<String, dynamic> response) async {
|
||||
final urls = response['urls'];
|
||||
if (urls is! List) return;
|
||||
|
||||
final resolvedUrls = <String>[];
|
||||
for (final item in urls) {
|
||||
if (item is! Map) continue;
|
||||
final url = item['url']?.toString();
|
||||
if (url == null || url.isEmpty) continue;
|
||||
resolvedUrls.add(url);
|
||||
}
|
||||
await CustomLineService.instance.activateSelectedNodeForUrls(resolvedUrls);
|
||||
}
|
||||
|
||||
/// 创建直接链接(分享链接)
|
||||
Future<List<Map<String, dynamic>>> createDirectLinks({
|
||||
required List<String> uris,
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import '../core/utils/app_logger.dart';
|
||||
|
||||
class HostMappingProxyService {
|
||||
HostMappingProxyService._();
|
||||
|
||||
static final HostMappingProxyService instance = HostMappingProxyService._();
|
||||
|
||||
ServerSocket? _server;
|
||||
HostMappingProxyEndpoint? _endpoint;
|
||||
Map<String, String> _hostMap = const {};
|
||||
|
||||
HostMappingProxyEndpoint? get endpoint => _hostMap.isEmpty ? null : _endpoint;
|
||||
|
||||
Future<HostMappingProxyEndpoint?> configure(
|
||||
Map<String, String> hostMap,
|
||||
) async {
|
||||
final cleaned = <String, String>{};
|
||||
for (final entry in hostMap.entries) {
|
||||
final host = _normalizeHost(entry.key);
|
||||
final ip = entry.value.trim();
|
||||
if (host.isNotEmpty && ip.isNotEmpty) {
|
||||
cleaned[host] = ip;
|
||||
}
|
||||
}
|
||||
|
||||
if (cleaned.isEmpty) {
|
||||
await stop();
|
||||
return null;
|
||||
}
|
||||
|
||||
_hostMap = cleaned;
|
||||
if (_server == null) {
|
||||
_server = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0);
|
||||
_endpoint = HostMappingProxyEndpoint(
|
||||
host: InternetAddress.loopbackIPv4.address,
|
||||
port: _server!.port,
|
||||
);
|
||||
_server!.listen(
|
||||
_handleClient,
|
||||
onError: (e) {
|
||||
AppLogger.d('自定义线路本地代理监听错误: $e');
|
||||
},
|
||||
);
|
||||
AppLogger.d('自定义线路本地代理已启动: ${_endpoint!.proxyUri}');
|
||||
}
|
||||
return _endpoint;
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
_hostMap = const {};
|
||||
final server = _server;
|
||||
_server = null;
|
||||
_endpoint = null;
|
||||
if (server != null) {
|
||||
await server.close();
|
||||
AppLogger.d('自定义线路本地代理已关闭');
|
||||
}
|
||||
}
|
||||
|
||||
void _handleClient(Socket client) {
|
||||
unawaited(_serveClient(client));
|
||||
}
|
||||
|
||||
Future<void> _serveClient(Socket client) async {
|
||||
Socket? remote;
|
||||
StreamController<List<int>>? rest;
|
||||
try {
|
||||
client.setOption(SocketOption.tcpNoDelay, true);
|
||||
final initial = await _readInitialRequest(client);
|
||||
rest = initial.rest;
|
||||
final lines = latin1.decode(initial.header).split('\r\n');
|
||||
if (lines.isEmpty || lines.first.trim().isEmpty) {
|
||||
throw const FormatException('empty proxy request');
|
||||
}
|
||||
|
||||
final first = lines.first.split(' ');
|
||||
if (first.length < 3) {
|
||||
throw FormatException('invalid proxy request: ${lines.first}');
|
||||
}
|
||||
|
||||
final method = first[0].toUpperCase();
|
||||
if (method == 'CONNECT') {
|
||||
final target = _splitAuthority(first[1], 443);
|
||||
remote = await _connectMapped(target.host, target.port);
|
||||
client.add(utf8.encode('HTTP/1.1 200 Connection Established\r\n\r\n'));
|
||||
await client.flush();
|
||||
} else {
|
||||
final target = _httpTarget(first[1], lines);
|
||||
remote = await _connectMapped(target.host, target.port);
|
||||
remote.add(_rewriteHttpHeader(initial.header, target.requestTarget));
|
||||
}
|
||||
|
||||
remote.setOption(SocketOption.tcpNoDelay, true);
|
||||
unawaited(rest.stream.pipe(remote).catchError((_) {}));
|
||||
unawaited(remote.cast<List<int>>().pipe(client).catchError((_) {}));
|
||||
} catch (e) {
|
||||
AppLogger.d('自定义线路本地代理请求失败: $e');
|
||||
try {
|
||||
client.add(utf8.encode('HTTP/1.1 502 Bad Gateway\r\n\r\n'));
|
||||
await client.flush();
|
||||
} catch (_) {}
|
||||
await rest?.close();
|
||||
remote?.destroy();
|
||||
client.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
Future<_ProxyInitialRequest> _readInitialRequest(Socket socket) {
|
||||
final completer = Completer<_ProxyInitialRequest>();
|
||||
final builder = BytesBuilder(copy: false);
|
||||
final rest = StreamController<List<int>>(sync: true);
|
||||
late StreamSubscription<Uint8List> subscription;
|
||||
|
||||
subscription = socket.listen(
|
||||
(chunk) {
|
||||
if (completer.isCompleted) {
|
||||
rest.add(chunk);
|
||||
return;
|
||||
}
|
||||
|
||||
builder.add(chunk);
|
||||
final bytes = builder.toBytes();
|
||||
final end = _headerEnd(bytes);
|
||||
if (end >= 0) {
|
||||
final headerEnd = end + 4;
|
||||
completer.complete(
|
||||
_ProxyInitialRequest(
|
||||
header: bytes.sublist(0, headerEnd),
|
||||
rest: rest,
|
||||
),
|
||||
);
|
||||
if (bytes.length > headerEnd) {
|
||||
rest.add(bytes.sublist(headerEnd));
|
||||
}
|
||||
} else if (bytes.length > 64 * 1024) {
|
||||
completer.completeError(
|
||||
const FormatException('proxy request header too large'),
|
||||
);
|
||||
unawaited(subscription.cancel());
|
||||
unawaited(rest.close());
|
||||
}
|
||||
},
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error, stackTrace);
|
||||
} else {
|
||||
rest.addError(error, stackTrace);
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(const SocketException('proxy client closed'));
|
||||
}
|
||||
unawaited(rest.close());
|
||||
},
|
||||
cancelOnError: false,
|
||||
);
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
|
||||
int _headerEnd(List<int> bytes) {
|
||||
for (var i = 0; i <= bytes.length - 4; i++) {
|
||||
if (bytes[i] == 13 &&
|
||||
bytes[i + 1] == 10 &&
|
||||
bytes[i + 2] == 13 &&
|
||||
bytes[i + 3] == 10) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
Future<Socket> _connectMapped(String host, int port) {
|
||||
final normalized = _normalizeHost(host);
|
||||
final targetHost = _hostMap[normalized] ?? host;
|
||||
return Socket.connect(
|
||||
targetHost,
|
||||
port,
|
||||
timeout: const Duration(seconds: 12),
|
||||
);
|
||||
}
|
||||
|
||||
_ProxyTarget _httpTarget(String target, List<String> lines) {
|
||||
final uri = Uri.tryParse(target);
|
||||
if (uri != null && uri.hasScheme && uri.host.isNotEmpty) {
|
||||
return _ProxyTarget(
|
||||
host: uri.host,
|
||||
port: uri.hasPort ? uri.port : (uri.scheme == 'https' ? 443 : 80),
|
||||
requestTarget: _originForm(uri),
|
||||
);
|
||||
}
|
||||
|
||||
final hostHeader = _headerValue(lines, 'Host');
|
||||
final authority = _splitAuthority(hostHeader, 80);
|
||||
return _ProxyTarget(
|
||||
host: authority.host,
|
||||
port: authority.port,
|
||||
requestTarget: target,
|
||||
);
|
||||
}
|
||||
|
||||
List<int> _rewriteHttpHeader(List<int> header, String requestTarget) {
|
||||
final text = latin1.decode(header);
|
||||
final lines = text.split('\r\n');
|
||||
final first = lines.first.split(' ');
|
||||
first[1] = requestTarget.isEmpty ? '/' : requestTarget;
|
||||
lines[0] = first.join(' ');
|
||||
final filtered = lines
|
||||
.where((line) => !line.toLowerCase().startsWith('proxy-connection:'))
|
||||
.join('\r\n');
|
||||
return latin1.encode(filtered);
|
||||
}
|
||||
|
||||
String _originForm(Uri uri) {
|
||||
final path = uri.path.isEmpty ? '/' : uri.path;
|
||||
return uri.hasQuery ? '$path?${uri.query}' : path;
|
||||
}
|
||||
|
||||
String _headerValue(List<String> lines, String name) {
|
||||
final prefix = '${name.toLowerCase()}:';
|
||||
for (final line in lines.skip(1)) {
|
||||
if (line.toLowerCase().startsWith(prefix)) {
|
||||
return line.substring(line.indexOf(':') + 1).trim();
|
||||
}
|
||||
}
|
||||
throw FormatException('missing $name header');
|
||||
}
|
||||
|
||||
_ProxyAuthority _splitAuthority(String value, int defaultPort) {
|
||||
final authority = value.trim();
|
||||
if (authority.startsWith('[')) {
|
||||
final end = authority.indexOf(']');
|
||||
if (end > 0) {
|
||||
final host = authority.substring(1, end);
|
||||
final port = authority.length > end + 2
|
||||
? int.tryParse(authority.substring(end + 2)) ?? defaultPort
|
||||
: defaultPort;
|
||||
return _ProxyAuthority(host, port);
|
||||
}
|
||||
}
|
||||
|
||||
final colon = authority.lastIndexOf(':');
|
||||
if (colon > 0 && colon < authority.length - 1) {
|
||||
final port = int.tryParse(authority.substring(colon + 1));
|
||||
if (port != null) {
|
||||
return _ProxyAuthority(authority.substring(0, colon), port);
|
||||
}
|
||||
}
|
||||
return _ProxyAuthority(authority, defaultPort);
|
||||
}
|
||||
|
||||
String _normalizeHost(String host) {
|
||||
final value = host.trim();
|
||||
final uri = Uri.tryParse(value);
|
||||
final resolved = uri != null && uri.hasScheme && uri.host.isNotEmpty
|
||||
? uri.host
|
||||
: value;
|
||||
return resolved.toLowerCase().replaceAll(RegExp(r'\.$'), '');
|
||||
}
|
||||
}
|
||||
|
||||
class HostMappingProxyEndpoint {
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
const HostMappingProxyEndpoint({required this.host, required this.port});
|
||||
|
||||
String get proxyUri => 'http://$host:$port';
|
||||
}
|
||||
|
||||
class HostMappingHttpOverrides extends HttpOverrides {
|
||||
@override
|
||||
HttpClient createHttpClient(SecurityContext? context) {
|
||||
final client = super.createHttpClient(context);
|
||||
client.findProxy = (_) {
|
||||
final endpoint = HostMappingProxyService.instance.endpoint;
|
||||
if (endpoint == null) return 'DIRECT';
|
||||
return 'PROXY ${endpoint.host}:${endpoint.port}; DIRECT';
|
||||
};
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
class _ProxyInitialRequest {
|
||||
final Uint8List header;
|
||||
final StreamController<List<int>> rest;
|
||||
|
||||
const _ProxyInitialRequest({required this.header, required this.rest});
|
||||
}
|
||||
|
||||
class _ProxyAuthority {
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
const _ProxyAuthority(this.host, this.port);
|
||||
}
|
||||
|
||||
class _ProxyTarget {
|
||||
final String host;
|
||||
final int port;
|
||||
final String requestTarget;
|
||||
|
||||
const _ProxyTarget({
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.requestTarget,
|
||||
});
|
||||
}
|
||||
@@ -45,12 +45,7 @@ class ServerService {
|
||||
} catch (e) {
|
||||
AppLogger.d('加载服务器列表失败: $e');
|
||||
// 加载失败时使用默认服务器
|
||||
_servers = [
|
||||
ServerModel(
|
||||
label: _defaultLabel,
|
||||
baseUrl: _defaultBaseUrl,
|
||||
),
|
||||
];
|
||||
_servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)];
|
||||
_currentServer = _servers.first;
|
||||
}
|
||||
}
|
||||
@@ -65,15 +60,10 @@ class ServerService {
|
||||
}
|
||||
}
|
||||
|
||||
_currentServer = (savedDefaultServer ??
|
||||
ServerModel(
|
||||
label: _defaultLabel,
|
||||
baseUrl: _defaultBaseUrl,
|
||||
))
|
||||
.copyWith(
|
||||
label: _defaultLabel,
|
||||
baseUrl: _defaultBaseUrl,
|
||||
);
|
||||
_currentServer =
|
||||
(savedDefaultServer ??
|
||||
ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl))
|
||||
.copyWith(label: _defaultLabel, baseUrl: _defaultBaseUrl);
|
||||
|
||||
_servers = [_currentServer!];
|
||||
}
|
||||
@@ -91,7 +81,9 @@ class ServerService {
|
||||
/// 保存上次选中的服务器
|
||||
Future<void> _saveLastSelected() async {
|
||||
if (_currentServer != null) {
|
||||
await StorageService.instance.setLastSelectedServerLabel(_currentServer!.label);
|
||||
await StorageService.instance.setLastSelectedServerLabel(
|
||||
_currentServer!.label,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,16 +117,21 @@ class ServerService {
|
||||
String? password,
|
||||
UserModel? user,
|
||||
bool? rememberMe,
|
||||
bool clearEmail = false,
|
||||
bool clearPassword = false,
|
||||
bool clearUser = false,
|
||||
}) async {
|
||||
if (_currentServer == null) {
|
||||
throw Exception('没有选中的服务器');
|
||||
}
|
||||
|
||||
_currentServer = _currentServer!.copyWith(
|
||||
email: email,
|
||||
password: password,
|
||||
user: user,
|
||||
_currentServer = ServerModel(
|
||||
label: _currentServer!.label,
|
||||
baseUrl: _currentServer!.baseUrl,
|
||||
rememberMe: rememberMe ?? _currentServer!.rememberMe,
|
||||
email: clearEmail ? null : (email ?? _currentServer!.email),
|
||||
password: clearPassword ? null : (password ?? _currentServer!.password),
|
||||
user: clearUser ? null : (user ?? _currentServer!.user),
|
||||
);
|
||||
|
||||
// 更新列表中的引用
|
||||
@@ -152,17 +149,15 @@ class ServerService {
|
||||
email: null,
|
||||
password: null,
|
||||
user: null,
|
||||
clearEmail: true,
|
||||
clearPassword: true,
|
||||
clearUser: true,
|
||||
);
|
||||
}
|
||||
|
||||
/// 重置为默认服务器列表
|
||||
Future<void> resetToDefault() async {
|
||||
_servers = [
|
||||
ServerModel(
|
||||
label: _defaultLabel,
|
||||
baseUrl: _defaultBaseUrl,
|
||||
),
|
||||
];
|
||||
_servers = [ServerModel(label: _defaultLabel, baseUrl: _defaultBaseUrl)];
|
||||
_currentServer = _servers.first;
|
||||
await _saveServers();
|
||||
await _saveLastSelected();
|
||||
|
||||
@@ -0,0 +1,906 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import '../core/utils/file_utils.dart';
|
||||
|
||||
class ShareLinkCandidate {
|
||||
final String id;
|
||||
final String url;
|
||||
final String? password;
|
||||
|
||||
const ShareLinkCandidate({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.password,
|
||||
});
|
||||
}
|
||||
|
||||
class ShareLinkInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final int visited;
|
||||
final bool expired;
|
||||
final bool unlocked;
|
||||
final bool isPrivate;
|
||||
final int sourceType;
|
||||
final String? sourceUri;
|
||||
final int size;
|
||||
final String? url;
|
||||
final String? ownerName;
|
||||
final String? ownerId;
|
||||
final String? ownerAvatar;
|
||||
final String? contextHint;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? expires;
|
||||
|
||||
const ShareLinkInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.visited,
|
||||
required this.expired,
|
||||
required this.unlocked,
|
||||
required this.isPrivate,
|
||||
required this.sourceType,
|
||||
this.sourceUri,
|
||||
this.size = 0,
|
||||
this.url,
|
||||
this.ownerName,
|
||||
this.ownerId,
|
||||
this.ownerAvatar,
|
||||
this.contextHint,
|
||||
this.createdAt,
|
||||
this.expires,
|
||||
});
|
||||
|
||||
bool get isFolder => sourceType == 1;
|
||||
bool get isFile => !isFolder;
|
||||
|
||||
factory ShareLinkInfo.fromJson(Map<String, dynamic> json) {
|
||||
final owner = _asMap(json['owner']);
|
||||
final source = _asMap(json['source']);
|
||||
final file = _asMap(json['file']);
|
||||
final object = _asMap(json['object']);
|
||||
final entity = _asMap(json['entity']);
|
||||
final rawSource = json['source'];
|
||||
|
||||
final ownerId = (owner?['id'] ??
|
||||
json['owner_id'] ??
|
||||
json['ownerId'] ??
|
||||
source?['owner_id'] ??
|
||||
file?['owner_id'])
|
||||
?.toString();
|
||||
|
||||
final name = (json['name'] ??
|
||||
file?['name'] ??
|
||||
source?['name'] ??
|
||||
object?['name'] ??
|
||||
'分享文件')
|
||||
.toString();
|
||||
|
||||
final parsedSourceUri = (json['source_uri'] ??
|
||||
json['sourceUri'] ??
|
||||
json['uri'] ??
|
||||
json['path'] ??
|
||||
(rawSource is String ? rawSource : null) ??
|
||||
source?['uri'] ??
|
||||
source?['source_uri'] ??
|
||||
source?['path'] ??
|
||||
source?['url'] ??
|
||||
file?['uri'] ??
|
||||
file?['source_uri'] ??
|
||||
file?['path'] ??
|
||||
object?['uri'] ??
|
||||
object?['source_uri'] ??
|
||||
object?['path'] ??
|
||||
entity?['uri'] ??
|
||||
entity?['source_uri'] ??
|
||||
entity?['path'])
|
||||
?.toString();
|
||||
|
||||
final resolvedSourceUri = _looksLikeCloudreveUri(parsedSourceUri)
|
||||
? parsedSourceUri
|
||||
: _fallbackSourceUri(ownerId: ownerId, name: name);
|
||||
|
||||
final size = _asInt(json['size'] ??
|
||||
json['source_size'] ??
|
||||
json['sourceSize'] ??
|
||||
source?['size'] ??
|
||||
file?['size'] ??
|
||||
object?['size'] ??
|
||||
entity?['size']);
|
||||
|
||||
return ShareLinkInfo(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: name,
|
||||
size: size,
|
||||
visited: _asInt(json['visited']),
|
||||
expired: json['expired'] as bool? ?? false,
|
||||
unlocked: json['unlocked'] as bool? ?? false,
|
||||
isPrivate: json['is_private'] as bool? ?? false,
|
||||
sourceType: _asInt(json['source_type'] ??
|
||||
source?['type'] ??
|
||||
file?['type'] ??
|
||||
object?['type']),
|
||||
sourceUri: resolvedSourceUri,
|
||||
url: json['url']?.toString(),
|
||||
ownerName: owner?['nickname']?.toString() ??
|
||||
owner?['name']?.toString() ??
|
||||
json['owner_name']?.toString(),
|
||||
ownerId: ownerId,
|
||||
ownerAvatar: owner?['avatar']?.toString(),
|
||||
contextHint: json['context_hint']?.toString() ??
|
||||
json['contextHint']?.toString() ??
|
||||
json['context']?.toString(),
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
expires: _parseDate(json['expires']),
|
||||
);
|
||||
}
|
||||
|
||||
static bool _looksLikeCloudreveUri(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return false;
|
||||
return value.trim().startsWith('cloudreve://');
|
||||
}
|
||||
|
||||
static String? _fallbackSourceUri({
|
||||
required String? ownerId,
|
||||
required String name,
|
||||
}) {
|
||||
// 不再用 owner@my 兜底。公开分享上下文不是当前账号的 my 文件系统,
|
||||
// 用 owner@my 会导致 /file/url 返回 40081 或空 urls。
|
||||
// 真正的兜底由 ShareLinkService.getShareInfo 根据分享 ID 构造
|
||||
// cloudreve://<shareId>@share/ 完成。
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
static int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic value) {
|
||||
final text = value?.toString();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return DateTime.tryParse(text);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareLinkFile {
|
||||
final int type;
|
||||
final String id;
|
||||
final String name;
|
||||
final int size;
|
||||
final String path;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final String? primaryEntity;
|
||||
final String? capability;
|
||||
final String? contextHint;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const ShareLinkFile({
|
||||
required this.type,
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.size,
|
||||
required this.path,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.primaryEntity,
|
||||
this.capability,
|
||||
this.contextHint,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
bool get isFolder => type == 1;
|
||||
bool get isFile => !isFolder;
|
||||
|
||||
factory ShareLinkFile.fromJson(Map<String, dynamic> json) {
|
||||
return ShareLinkFile(
|
||||
type: _asInt(json['type']),
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '未命名文件',
|
||||
size: _asInt(json['size']),
|
||||
path: json['path']?.toString() ?? '',
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
updatedAt: _parseDate(json['updated_at']),
|
||||
primaryEntity: json['primary_entity']?.toString() ??
|
||||
json['entity']?.toString(),
|
||||
capability: json['capability']?.toString(),
|
||||
contextHint: json['context_hint']?.toString(),
|
||||
metadata: _asMap(json['metadata']),
|
||||
);
|
||||
}
|
||||
|
||||
static int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic value) {
|
||||
final text = value?.toString();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return DateTime.tryParse(text);
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ShareLinkFileListResult {
|
||||
final List<ShareLinkFile> files;
|
||||
final ShareLinkFile? parent;
|
||||
final String? contextHint;
|
||||
final bool hasMore;
|
||||
final String? nextPageToken;
|
||||
|
||||
const ShareLinkFileListResult({
|
||||
required this.files,
|
||||
this.parent,
|
||||
this.contextHint,
|
||||
this.hasMore = false,
|
||||
this.nextPageToken,
|
||||
});
|
||||
|
||||
factory ShareLinkFileListResult.fromJson(Map<String, dynamic> json) {
|
||||
final rawFiles = json['files'];
|
||||
final rawParent = json['parent'];
|
||||
final pagination = ShareLinkService._asMap(json['pagination']);
|
||||
|
||||
return ShareLinkFileListResult(
|
||||
files: rawFiles is List
|
||||
? rawFiles
|
||||
.whereType<Map>()
|
||||
.map((item) => ShareLinkFile.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList()
|
||||
: const [],
|
||||
parent: rawParent is Map
|
||||
? ShareLinkFile.fromJson(Map<String, dynamic>.from(rawParent))
|
||||
: null,
|
||||
contextHint: json['context_hint']?.toString(),
|
||||
hasMore: pagination?['next_token'] != null,
|
||||
nextPageToken: pagination?['next_token']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareDownloadUrlResult {
|
||||
final String url;
|
||||
final DateTime? expires;
|
||||
|
||||
const ShareDownloadUrlResult({
|
||||
required this.url,
|
||||
this.expires,
|
||||
});
|
||||
}
|
||||
|
||||
class ShareLinkService {
|
||||
ShareLinkService._();
|
||||
|
||||
static final ShareLinkService instance = ShareLinkService._();
|
||||
|
||||
/// 兼容当前站点固定域名,也兼容用户自定义 Cloudreve 域名的 /s/{id}/{password?} 分享链接。
|
||||
static final RegExp _shareLinkRegExp = RegExp(
|
||||
r'https?://[^\s]+?/s/([A-Za-z0-9_-]+)(?:/([A-Za-z0-9_-]+))?',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
ShareLinkCandidate? parseShareLink(String? text) {
|
||||
if (text == null || text.trim().isEmpty) return null;
|
||||
|
||||
final match = _shareLinkRegExp.firstMatch(text.trim());
|
||||
if (match == null) return null;
|
||||
|
||||
final id = match.group(1);
|
||||
if (id == null || id.isEmpty) return null;
|
||||
|
||||
final password = match.group(2);
|
||||
final url = match.group(0)!;
|
||||
|
||||
return ShareLinkCandidate(
|
||||
id: id,
|
||||
url: url,
|
||||
password: password == null || password.isEmpty ? null : password,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ShareLinkInfo> getShareInfo(
|
||||
ShareLinkCandidate candidate, {
|
||||
String? password,
|
||||
}) async {
|
||||
final resolvedPassword = password ?? candidate.password;
|
||||
final query = <String, dynamic>{
|
||||
'count_views': true,
|
||||
'owner_extended': true,
|
||||
if (resolvedPassword != null && resolvedPassword.isNotEmpty)
|
||||
'password': resolvedPassword,
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||
'/share/info/${candidate.id}',
|
||||
queryParameters: query,
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: const {
|
||||
'X-Cr-Context-Hint': 'share',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(body['data']) ?? body;
|
||||
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||
final sourceType = ShareLinkInfo._asInt(data['source_type']);
|
||||
|
||||
// 公开分享页面访问文件时,必须使用 Cloudreve 官方 share 文件系统 URI,
|
||||
// 即 cloudreve://{shareId}[:password]@share[/...]。
|
||||
// /share/info 返回的 source_uri 往往是所有者 my 文件系统里的真实路径,
|
||||
// 匿名/noAuth 场景拿它直接请求 /file/url 会被服务端按普通文件解析,
|
||||
// 进而返回 40081 Entity not exist。
|
||||
final accessUri = shareRootUri(
|
||||
shareId: candidate.id,
|
||||
password: resolvedPassword,
|
||||
trailingSlash: sourceType != 0,
|
||||
);
|
||||
|
||||
return ShareLinkInfo.fromJson(<String, dynamic>{
|
||||
...data,
|
||||
'source_uri': accessUri,
|
||||
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
|
||||
headerContext != null &&
|
||||
headerContext.isNotEmpty)
|
||||
'context_hint': headerContext,
|
||||
});
|
||||
}
|
||||
|
||||
String? ownerAvatarUrl(ShareLinkInfo info) {
|
||||
final ownerId = info.ownerId;
|
||||
if (ownerId == null || ownerId.isEmpty) return null;
|
||||
|
||||
final avatar = info.ownerAvatar;
|
||||
if (avatar != null && avatar.startsWith(RegExp(r'https?://'))) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
final base = ApiService.instance.dio.options.baseUrl.replaceFirst(RegExp(r'/+$'), '');
|
||||
return '$base/user/avatar/${Uri.encodeComponent(ownerId)}';
|
||||
}
|
||||
|
||||
/// 按分享的 source_uri 读取目录文件。
|
||||
///
|
||||
/// Cloudreve V4 的 /file 接口支持 JWT Optional,并通过 X-Cr-Context-Hint
|
||||
/// 绑定分享上下文。首次读取时用 `share`,服务端返回 context_hint 后,后续下载
|
||||
/// 和进入子目录都继续携带该 context_hint。
|
||||
Future<ShareLinkFileListResult> listSharedFiles({
|
||||
required String uri,
|
||||
String? contextHint,
|
||||
int page = 0,
|
||||
int pageSize = 100,
|
||||
String? nextPageToken,
|
||||
}) async {
|
||||
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||
'/file',
|
||||
queryParameters: <String, dynamic>{
|
||||
'uri': uri,
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (nextPageToken != null && nextPageToken.isNotEmpty)
|
||||
'next_page_token': nextPageToken,
|
||||
},
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: <String, dynamic>{
|
||||
'X-Cr-Context-Hint': _firstContextHint(contextHint),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(body['data']) ?? body;
|
||||
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||
return ShareLinkFileListResult.fromJson(<String, dynamic>{
|
||||
...data,
|
||||
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
|
||||
headerContext != null &&
|
||||
headerContext.isNotEmpty)
|
||||
'context_hint': headerContext,
|
||||
});
|
||||
}
|
||||
|
||||
/// 读取分享上下文中文件详情,主要用于拿到 primary_entity。
|
||||
Future<ShareLinkFile> getSharedFileInfo({
|
||||
required String uri,
|
||||
String? contextHint,
|
||||
String? shareId,
|
||||
String? password,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
|
||||
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||
try {
|
||||
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||
'/file/info',
|
||||
queryParameters: <String, dynamic>{
|
||||
'uri': uri,
|
||||
'extended': true,
|
||||
},
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: <String, dynamic>{
|
||||
'X-Cr-Context-Hint': hint,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(body['data']) ?? body;
|
||||
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||
return ShareLinkFile.fromJson(<String, dynamic>{
|
||||
...data,
|
||||
'context_hint': data['context_hint'] ?? headerContext ?? hint,
|
||||
});
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception('文件详情读取失败');
|
||||
}
|
||||
|
||||
ShareLinkFile fileFromShareInfo(ShareLinkInfo info) {
|
||||
final sourceUri = info.sourceUri ??
|
||||
ShareLinkInfo._fallbackSourceUri(
|
||||
ownerId: info.ownerId,
|
||||
name: info.name,
|
||||
) ??
|
||||
'';
|
||||
return ShareLinkFile(
|
||||
type: info.sourceType,
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
size: info.size,
|
||||
path: sourceUri,
|
||||
createdAt: info.createdAt,
|
||||
updatedAt: info.createdAt,
|
||||
contextHint: info.contextHint,
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取分享上下文中的下载 URL。
|
||||
///
|
||||
/// 按 Cloudreve V4 官方 `/file/url` 方式创建临时下载链接:
|
||||
/// body 只提交 `uris`,文件夹批量下载时提交 `archive: true`;
|
||||
/// 分享访问走 JWT Optional,因此这里使用 noAuth,避免当前登录用户的
|
||||
/// Authorization 影响分享上下文。
|
||||
Future<ShareDownloadUrlResult> createShareDownloadUrl({
|
||||
required String uri,
|
||||
String? contextHint,
|
||||
String? shareId,
|
||||
String? password,
|
||||
String? entity,
|
||||
String? fileName,
|
||||
bool archive = false,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
|
||||
// Cloudreve V4 官方接口:POST /file/url
|
||||
// Header: X-Cr-Context-Hint
|
||||
// Body: { "uris": [...], "archive": true? }
|
||||
//
|
||||
// 分享下载必须不带当前账号 token;否则服务端可能按当前账号解析 URI,
|
||||
// 出现 Entity not exist (40081)。
|
||||
for (final candidateUri in _uriCandidates(
|
||||
uri,
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
fileName: fileName,
|
||||
)) {
|
||||
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||
try {
|
||||
return await _createShareDownloadUrlOnce(
|
||||
uri: candidateUri,
|
||||
contextHint: hint,
|
||||
archive: archive,
|
||||
);
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception('服务端没有返回可用的下载链接');
|
||||
}
|
||||
|
||||
Future<ShareDownloadUrlResult> _createShareDownloadUrlOnce({
|
||||
required String uri,
|
||||
required String contextHint,
|
||||
bool archive = false,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'uris': <String>[uri],
|
||||
'download': true,
|
||||
if (archive) 'archive': true,
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.dio.post<Map<String, dynamic>>(
|
||||
'/file/url',
|
||||
data: body,
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: <String, dynamic>{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Cr-Context-Hint': contextHint,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final raw = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(raw['data']) ?? raw;
|
||||
final url = _extractDownloadUrl(data) ?? _extractDownloadUrl(raw);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
return ShareDownloadUrlResult(
|
||||
url: url,
|
||||
expires: ShareLinkInfo._parseDate(data['expires'] ?? raw['expires']),
|
||||
);
|
||||
}
|
||||
|
||||
throw Exception('服务端没有返回可用的下载链接');
|
||||
}
|
||||
|
||||
/// 将分享文件转存到当前用户网盘。
|
||||
///
|
||||
/// Cloudreve V4 使用 /file/move,同一接口可移动/复制文件;传入 copy=true
|
||||
/// 时即为转存/复制到目标目录。
|
||||
Future<void> saveSharedFiles({
|
||||
required List<String> uris,
|
||||
required String destination,
|
||||
String? contextHint,
|
||||
String? shareId,
|
||||
String? password,
|
||||
String? fileName,
|
||||
}) async {
|
||||
final dst = FileUtils.toCloudreveUri(destination);
|
||||
Object? lastError;
|
||||
|
||||
final uriCandidateSets = uris
|
||||
.map((uri) => _uriCandidates(
|
||||
uri,
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
// 单文件分享的 source_uri 是 cloudreve://{id}@share 根。
|
||||
// /file/move 不允许复制 share 根目录,因此需要优先尝试
|
||||
// cloudreve://{id}@share/{fileName},并过滤掉根 URI。
|
||||
fileName: uris.length == 1 ? fileName : null,
|
||||
includeRootFallback: false,
|
||||
)
|
||||
.where((candidate) => !isShareRootUri(candidate, shareId: shareId))
|
||||
.toList())
|
||||
.where((candidates) => candidates.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (uriCandidateSets.isEmpty) {
|
||||
throw Exception('不能直接转存分享根目录,请选择具体文件或进入目录后再转存');
|
||||
}
|
||||
|
||||
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||
for (final candidateUris in _cartesianUriCandidates(uriCandidateSets)) {
|
||||
if (candidateUris.isEmpty) continue;
|
||||
try {
|
||||
await ApiService.instance.post<void>(
|
||||
'/file/move',
|
||||
data: <String, dynamic>{
|
||||
'uris': candidateUris,
|
||||
'dst': dst,
|
||||
'copy': true,
|
||||
},
|
||||
headers: <String, dynamic>{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Cr-Context-Hint': hint,
|
||||
},
|
||||
);
|
||||
return;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception('转存失败');
|
||||
}
|
||||
|
||||
static List<String> _uriCandidates(
|
||||
String uri, {
|
||||
String? shareId,
|
||||
String? password,
|
||||
String? fileName,
|
||||
bool includeRootFallback = true,
|
||||
}) {
|
||||
final values = <String>[];
|
||||
|
||||
void add(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
final shareUriCandidates = _shareScopedUriCandidates(
|
||||
uri,
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// 官方页面进入的是 cloudreve://{shareId}@share 文件系统;
|
||||
// 如果 /file 返回了 cloudreve://my/... 或 owner@my/...,
|
||||
// 转存/下载时也要先折算成 share 文件系统里的相对路径。
|
||||
for (final item in shareUriCandidates) {
|
||||
add(item);
|
||||
}
|
||||
|
||||
final name = fileName?.trim();
|
||||
final id = shareId?.trim();
|
||||
if (id != null && id.isNotEmpty && name != null && name.isNotEmpty) {
|
||||
final root = shareRootUri(
|
||||
shareId: id,
|
||||
password: password,
|
||||
trailingSlash: false,
|
||||
);
|
||||
add('$root/${Uri.encodeComponent(name)}');
|
||||
add('$root/${_encodeCloudrevePath(name)}');
|
||||
}
|
||||
|
||||
add(_withSharePassword(uri, shareId: shareId, password: password));
|
||||
add(uri);
|
||||
|
||||
if (uri.startsWith('cloudreve://')) {
|
||||
final withPassword = _withSharePassword(uri, shareId: shareId, password: password);
|
||||
if (withPassword != uri) add(withPassword);
|
||||
|
||||
final slash = uri.indexOf('/', 'cloudreve://'.length);
|
||||
if (slash > 0) {
|
||||
final prefix = uri.substring(0, slash + 1);
|
||||
final path = slash < uri.length - 1 ? uri.substring(slash + 1) : '';
|
||||
if (path.isNotEmpty) {
|
||||
final encodedPath = _encodeCloudrevePath(path);
|
||||
add(_withSharePassword('$prefix$encodedPath', shareId: shareId, password: password));
|
||||
add('$prefix$encodedPath');
|
||||
}
|
||||
if (!uri.endsWith('/')) {
|
||||
add(_withSharePassword('$uri/', shareId: shareId, password: password));
|
||||
add('$uri/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeRootFallback && shareId != null && shareId.trim().isNotEmpty) {
|
||||
add(shareRootUri(shareId: shareId, password: password, trailingSlash: true));
|
||||
add(shareRootUri(shareId: shareId, password: password, trailingSlash: false));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
static List<String> _shareScopedUriCandidates(
|
||||
String uri, {
|
||||
String? shareId,
|
||||
String? password,
|
||||
}) {
|
||||
final id = shareId?.trim();
|
||||
if (id == null || id.isEmpty || !uri.startsWith('cloudreve://')) {
|
||||
return const <String>[];
|
||||
}
|
||||
|
||||
final values = <String>[];
|
||||
void add(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
final authorityStart = 'cloudreve://'.length;
|
||||
final slash = uri.indexOf('/', authorityStart);
|
||||
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
|
||||
final rawPath = slash >= 0 && slash < uri.length - 1 ? uri.substring(slash + 1) : '';
|
||||
final decodedPath = rawPath
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.map((segment) => Uri.decodeComponent(segment))
|
||||
.join('/');
|
||||
|
||||
final isShareAuthority = authority.endsWith('@share');
|
||||
if (isShareAuthority) {
|
||||
add(_withSharePassword(uri, shareId: id, password: password));
|
||||
add(uri);
|
||||
return values;
|
||||
}
|
||||
|
||||
if (decodedPath.isEmpty) return values;
|
||||
|
||||
final pathParts = decodedPath.split('/').where((e) => e.isNotEmpty).toList();
|
||||
final root = shareRootUri(shareId: id, password: password, trailingSlash: false);
|
||||
|
||||
// 完整路径候选:cloudreve://id@share/folder/file.ext
|
||||
add('$root/${_encodeCloudrevePath(decodedPath)}');
|
||||
|
||||
// 单文件分享或列表返回 owner 的 my 路径时,官方 share 根目录下通常就是文件本身,
|
||||
// 因此还要尝试 basename 候选:cloudreve://id@share/file.ext
|
||||
if (pathParts.isNotEmpty) {
|
||||
add('$root/${Uri.encodeComponent(pathParts.last)}');
|
||||
}
|
||||
|
||||
// 如果原 path 形如 Folder/Sub/File,分享根可能是 Folder,子项在 share 中应为 Sub/File。
|
||||
if (pathParts.length > 1) {
|
||||
final withoutFirst = pathParts.skip(1).join('/');
|
||||
add('$root/${_encodeCloudrevePath(withoutFirst)}');
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
static String _encodeCloudrevePath(String path) {
|
||||
return path
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.map((segment) => Uri.encodeComponent(Uri.decodeComponent(segment)))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
static String _withSharePassword(String uri, {String? shareId, String? password}) {
|
||||
final pw = password?.trim();
|
||||
final id = shareId?.trim();
|
||||
if (pw == null || pw.isEmpty || id == null || id.isEmpty) return uri;
|
||||
if (!uri.startsWith('cloudreve://')) return uri;
|
||||
|
||||
final authorityStart = 'cloudreve://'.length;
|
||||
final slash = uri.indexOf('/', authorityStart);
|
||||
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
|
||||
final rest = slash >= 0 ? uri.substring(slash) : '';
|
||||
if (!authority.endsWith('@share')) return uri;
|
||||
if (authority.contains(':')) return uri;
|
||||
|
||||
final encodedId = Uri.encodeComponent(id);
|
||||
final encodedPw = Uri.encodeComponent(pw);
|
||||
if (authority != '$encodedId@share' && authority != '$id@share') return uri;
|
||||
return 'cloudreve://$encodedId:$encodedPw@share$rest';
|
||||
}
|
||||
|
||||
static String shareRootUri({
|
||||
required String shareId,
|
||||
String? password,
|
||||
bool trailingSlash = true,
|
||||
}) {
|
||||
final id = Uri.encodeComponent(shareId.trim());
|
||||
final pw = password?.trim();
|
||||
final userInfo = pw == null || pw.isEmpty
|
||||
? id
|
||||
: '$id:${Uri.encodeComponent(pw)}';
|
||||
return 'cloudreve://$userInfo@share${trailingSlash ? '/' : ''}';
|
||||
}
|
||||
|
||||
static bool isShareRootUri(String uri, {String? shareId}) {
|
||||
final text = uri.trim();
|
||||
const prefix = 'cloudreve://';
|
||||
if (!text.startsWith(prefix)) return false;
|
||||
|
||||
final authorityStart = prefix.length;
|
||||
final slash = text.indexOf('/', authorityStart);
|
||||
final authority = slash >= 0
|
||||
? text.substring(authorityStart, slash)
|
||||
: text.substring(authorityStart);
|
||||
if (!authority.endsWith('@share')) return false;
|
||||
|
||||
final path = slash >= 0 ? text.substring(slash) : '';
|
||||
if (path.isNotEmpty && path != '/') return false;
|
||||
|
||||
final expectedId = shareId?.trim();
|
||||
if (expectedId == null || expectedId.isEmpty) return true;
|
||||
|
||||
final userInfo = authority.substring(0, authority.length - '@share'.length);
|
||||
final rawId = userInfo.split(':').first;
|
||||
return Uri.decodeComponent(rawId) == expectedId || rawId == expectedId;
|
||||
}
|
||||
|
||||
|
||||
static String? _extractDownloadUrl(Map<String, dynamic> data) {
|
||||
final direct = data['url'] ??
|
||||
data['download_url'] ??
|
||||
data['downloadUrl'] ??
|
||||
data['src'] ??
|
||||
data['href'];
|
||||
if (direct is String && direct.trim().isNotEmpty) return direct.trim();
|
||||
|
||||
final rawUrls = data['urls'];
|
||||
if (rawUrls is String && rawUrls.trim().isNotEmpty) return rawUrls.trim();
|
||||
if (rawUrls is List) {
|
||||
for (final item in rawUrls) {
|
||||
if (item is String && item.trim().isNotEmpty) return item.trim();
|
||||
final map = _asMap(item);
|
||||
if (map == null) continue;
|
||||
final url = _extractDownloadUrl(map);
|
||||
if (url != null && url.isNotEmpty) return url;
|
||||
}
|
||||
}
|
||||
if (rawUrls is Map) {
|
||||
for (final item in rawUrls.values) {
|
||||
if (item is String && item.trim().isNotEmpty) return item.trim();
|
||||
final map = _asMap(item);
|
||||
if (map == null) continue;
|
||||
final url = _extractDownloadUrl(map);
|
||||
if (url != null && url.isNotEmpty) return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<List<String>> _cartesianUriCandidates(List<List<String>> sets) {
|
||||
if (sets.isEmpty) return const [[]];
|
||||
|
||||
var result = <List<String>>[const []];
|
||||
for (final set in sets) {
|
||||
final next = <List<String>>[];
|
||||
for (final prefix in result) {
|
||||
for (final item in set) {
|
||||
next.add(<String>[...prefix, item]);
|
||||
}
|
||||
}
|
||||
result = next;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String _firstContextHint(String? contextHint) {
|
||||
final value = contextHint?.trim();
|
||||
return value == null || value.isEmpty ? 'share' : value;
|
||||
}
|
||||
|
||||
static List<String> _contextHintCandidates(String? contextHint, {String? shareId}) {
|
||||
final values = <String>[];
|
||||
void add(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
add(contextHint);
|
||||
add(shareId);
|
||||
if (shareId != null && shareId.trim().isNotEmpty) {
|
||||
add('share:${shareId.trim()}');
|
||||
add('share_${shareId.trim()}');
|
||||
}
|
||||
add('share');
|
||||
return values;
|
||||
}
|
||||
|
||||
static String? _headerValue(Headers headers, String name) {
|
||||
final direct = headers.value(name) ?? headers.value(name.toLowerCase());
|
||||
if (direct != null && direct.isNotEmpty) return direct;
|
||||
|
||||
final lower = name.toLowerCase();
|
||||
for (final entry in headers.map.entries) {
|
||||
if (entry.key.toLowerCase() == lower && entry.value.isNotEmpty) {
|
||||
return entry.value.first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+139
-22
@@ -1,7 +1,54 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'api_service.dart';
|
||||
import '../core/exceptions/app_exception.dart';
|
||||
import '../data/models/share_model.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 {
|
||||
/// 将文件系统路径转换为 cloudreve URI 格式
|
||||
@@ -17,33 +64,98 @@ class ShareService {
|
||||
return 'cloudreve://my/$cleanPath';
|
||||
}
|
||||
|
||||
/// 创建分享链接
|
||||
/// 创建分享链接。
|
||||
///
|
||||
/// Cloudreve V4 接口为 PUT /share,核心字段包括 permissions、uri、
|
||||
/// is_private、share_view、expire、price、password、show_readme。
|
||||
Future<String> createShare({
|
||||
required String uri,
|
||||
Map<String, dynamic>? permissions,
|
||||
bool? isPrivate,
|
||||
bool? shareView,
|
||||
int? expire,
|
||||
int? downloads,
|
||||
int? price,
|
||||
String? password,
|
||||
bool? showReadme,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'permissions': {'anonymous': 'BQ==', 'everyone': 'AQ=='},
|
||||
'permissions': permissions ?? {'anonymous': 'BQ==', 'everyone': 'AQ=='},
|
||||
'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>>(
|
||||
'/share',
|
||||
data: data,
|
||||
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 {
|
||||
final queryParams = <String, dynamic>{
|
||||
'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>>(
|
||||
'/share',
|
||||
queryParameters: queryParams,
|
||||
@@ -79,13 +191,10 @@ class ShareService {
|
||||
if (ownerExtended != null) {
|
||||
queryParams['owner_extended'] = ownerExtended.toString();
|
||||
}
|
||||
// 获取分享详情是 GET 请求
|
||||
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||
'/share/info/$id',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
// 获取分享详情返回的 response 已经经过 _parseResponse -> ApiResponse.fromJson 处理, 不需要再通过 ['data'] 获取数据
|
||||
// return ShareModel.fromJson(response['data'] as Map<String, dynamic>);
|
||||
return ShareModel.fromJson(response);
|
||||
}
|
||||
|
||||
@@ -93,19 +202,26 @@ class ShareService {
|
||||
Future<String> editShare({
|
||||
required String id,
|
||||
required String uri,
|
||||
Map<String, dynamic>? permissions,
|
||||
bool? isPrivate,
|
||||
String? password,
|
||||
bool? shareView,
|
||||
int? downloads,
|
||||
int? expire,
|
||||
int? price,
|
||||
bool? showReadme,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'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) {
|
||||
data['password'] = password;
|
||||
}
|
||||
@@ -116,7 +232,8 @@ class ShareService {
|
||||
data: data,
|
||||
isNoData: true,
|
||||
);
|
||||
return response['data'] as String;
|
||||
final raw = response['data'];
|
||||
return raw?.toString() ?? '';
|
||||
}
|
||||
|
||||
/// 删除分享
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:convert';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../core/constants/storage_keys.dart';
|
||||
import '../data/models/server_model.dart';
|
||||
|
||||
@@ -80,11 +81,13 @@ class StorageService {
|
||||
|
||||
/// 设置
|
||||
Future<String?> get themeMode => getString(StorageKeys.themeMode);
|
||||
Future<bool> setThemeMode(String value) => setString(StorageKeys.themeMode, value);
|
||||
Future<bool> setThemeMode(String value) =>
|
||||
setString(StorageKeys.themeMode, value);
|
||||
|
||||
/// 服务器地址配置
|
||||
Future<String?> get customBaseUrl => getString(StorageKeys.customBaseUrl);
|
||||
Future<bool> setCustomBaseUrl(String? value) => setString(StorageKeys.customBaseUrl, value);
|
||||
Future<bool> setCustomBaseUrl(String? value) =>
|
||||
setString(StorageKeys.customBaseUrl, value);
|
||||
Future<bool> removeCustomBaseUrl() => remove(StorageKeys.customBaseUrl);
|
||||
|
||||
/// 服务器列表
|
||||
@@ -114,8 +117,10 @@ class StorageService {
|
||||
}
|
||||
|
||||
/// 上次选中的服务器 label
|
||||
Future<String?> get lastSelectedServerLabel => getString(StorageKeys.lastSelectedServer);
|
||||
Future<bool> setLastSelectedServerLabel(String? value) => setString(StorageKeys.lastSelectedServer, value);
|
||||
Future<String?> get lastSelectedServerLabel =>
|
||||
getString(StorageKeys.lastSelectedServer);
|
||||
Future<bool> setLastSelectedServerLabel(String? value) =>
|
||||
setString(StorageKeys.lastSelectedServer, value);
|
||||
|
||||
/// 搜索历史(最新在前,最多 20 条)
|
||||
Future<List<String>> getSearchHistory() async {
|
||||
@@ -149,4 +154,74 @@ class StorageService {
|
||||
Future<void> clearSearchHistory() async {
|
||||
await remove(StorageKeys.searchHistory);
|
||||
}
|
||||
|
||||
// ===== 同步配置持久化 =====
|
||||
|
||||
/// 保存同步配置
|
||||
Future<bool> setSyncConfig(Map<String, dynamic> config) async {
|
||||
try {
|
||||
final json = jsonEncode(config);
|
||||
return await setString(StorageKeys.syncConfig, json);
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 读取同步配置
|
||||
Future<Map<String, dynamic>?> getSyncConfig() async {
|
||||
final json = await getString(StorageKeys.syncConfig);
|
||||
if (json == null || json.isEmpty) return null;
|
||||
try {
|
||||
return jsonDecode(json) as Map<String, dynamic>;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 保存同步状态
|
||||
Future<bool> setSyncState(String state) async {
|
||||
return await setString(StorageKeys.syncState, state);
|
||||
}
|
||||
|
||||
/// 读取同步状态
|
||||
Future<String?> getSyncState() async {
|
||||
return await getString(StorageKeys.syncState);
|
||||
}
|
||||
|
||||
/// 清除同步配置和状态
|
||||
Future<void> clearSyncData() async {
|
||||
await remove(StorageKeys.syncConfig);
|
||||
await remove(StorageKeys.syncState);
|
||||
await remove(StorageKeys.syncCumStats);
|
||||
}
|
||||
|
||||
/// 保存同步累积统计
|
||||
Future<bool> setSyncCumStats(Map<String, int> stats) async {
|
||||
final json = jsonEncode(stats);
|
||||
return await setString(StorageKeys.syncCumStats, json);
|
||||
}
|
||||
|
||||
/// 读取同步累积统计
|
||||
Future<Map<String, int>?> getSyncCumStats() async {
|
||||
final json = await getString(StorageKeys.syncCumStats);
|
||||
if (json == null) return null;
|
||||
try {
|
||||
final map = jsonDecode(json) as Map<String, dynamic>;
|
||||
return map.map((k, v) => MapEntry(k, v as int));
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ===== client_id 持久化 =====
|
||||
|
||||
/// 获取 client_id,首次调用自动生成并持久化
|
||||
Future<String> getOrCreateClientId() async {
|
||||
var id = await getString(StorageKeys.clientId);
|
||||
if (id == null || id.isEmpty) {
|
||||
id = const Uuid().v4();
|
||||
await setString(StorageKeys.clientId, id);
|
||||
}
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import 'dart:async';
|
||||
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../data/models/sync_config_model.dart';
|
||||
import '../data/models/sync_event_model.dart';
|
||||
import '../data/models/sync_status_model.dart';
|
||||
import '../data/models/sync_task_model.dart';
|
||||
import '../src/rust/api/ffi.dart' as ffi;
|
||||
import '../src/rust/api/ffi_types.dart' as ffi_types;
|
||||
|
||||
/// 同步服务单例 - 桥接 Flutter UI 和 Rust 同步引擎
|
||||
class SyncService {
|
||||
static final SyncService instance = SyncService._();
|
||||
SyncService._();
|
||||
|
||||
bool _initialized = false;
|
||||
StreamSubscription<ffi_types.SyncEventFfi>? _rustEventSub;
|
||||
|
||||
/// 事件流,供 SyncProvider 订阅
|
||||
final _eventController = StreamController<SyncEventModel>.broadcast();
|
||||
Stream<SyncEventModel> get events => _eventController.stream;
|
||||
|
||||
/// 初始化同步引擎(已初始化时更新配置)
|
||||
Future<void> init(SyncConfigModel config) async {
|
||||
if (_initialized) {
|
||||
AppLogger.d(
|
||||
'[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps',
|
||||
);
|
||||
await ffi.updateSyncConfig(config: config.toFfi());
|
||||
AppLogger.d('[FFI] ← updateSyncConfig: ok');
|
||||
return;
|
||||
}
|
||||
|
||||
AppLogger.d(
|
||||
'[FFI] → initSyncEngine: localRoot=${config.localRoot}, mode=${config.syncMode}, conflict=${config.conflictStrategy}, logLevel=${config.logLevel}',
|
||||
);
|
||||
|
||||
await ffi.initSyncEngine(config: config.toFfi());
|
||||
|
||||
_initialized = true;
|
||||
_subscribeRustEvents();
|
||||
AppLogger.d('[FFI] ← initSyncEngine: ok');
|
||||
}
|
||||
|
||||
/// 执行初始全量同步
|
||||
Future<SyncSummaryModel> startInitialSync() async {
|
||||
AppLogger.d('[FFI] → startInitialSync');
|
||||
final summary = await ffi.startInitialSync();
|
||||
AppLogger.d(
|
||||
'[FFI] ← startInitialSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}',
|
||||
);
|
||||
return SyncSummaryModel.fromFfi(summary);
|
||||
}
|
||||
|
||||
/// 启动持续同步
|
||||
Future<void> startContinuousSync() async {
|
||||
AppLogger.d('[FFI] → startContinuousSync');
|
||||
await ffi.startContinuousSync();
|
||||
AppLogger.d('[FFI] ← startContinuousSync: spawned');
|
||||
}
|
||||
|
||||
/// 停止同步
|
||||
Future<void> stop() async {
|
||||
AppLogger.d('[FFI] → stopSync');
|
||||
await ffi.stopSync();
|
||||
AppLogger.d('[FFI] ← stopSync: ok');
|
||||
}
|
||||
|
||||
/// 暂停同步
|
||||
Future<void> pause() async {
|
||||
AppLogger.d('[FFI] → pauseSync');
|
||||
await ffi.pauseSync();
|
||||
AppLogger.d('[FFI] ← pauseSync: ok');
|
||||
}
|
||||
|
||||
/// 恢复同步
|
||||
Future<void> resume() async {
|
||||
AppLogger.d('[FFI] → resumeSync');
|
||||
await ffi.resumeSync();
|
||||
AppLogger.d('[FFI] ← resumeSync: ok');
|
||||
}
|
||||
|
||||
/// 强制重新同步
|
||||
Future<SyncSummaryModel> forceSync() async {
|
||||
AppLogger.d('[FFI] → forceSync');
|
||||
final summary = await ffi.forceSync();
|
||||
AppLogger.d(
|
||||
'[FFI] ← forceSync: uploaded=${summary.uploaded}, downloaded=${summary.downloaded}, conflicts=${summary.conflicts}, failed=${summary.failed}',
|
||||
);
|
||||
return SyncSummaryModel.fromFfi(summary);
|
||||
}
|
||||
|
||||
/// Token 变更时推送给 Rust
|
||||
Future<void> updateTokens(String accessToken) async {
|
||||
AppLogger.d('[FFI] → updateTokens: token_len=${accessToken.length}');
|
||||
await ffi.updateTokens(accessToken: accessToken);
|
||||
AppLogger.d('[FFI] ← updateTokens: ok');
|
||||
}
|
||||
|
||||
/// 更新同步配置(推送到 Rust 引擎,引擎未初始化时忽略)
|
||||
Future<void> updateConfig(SyncConfigModel config) async {
|
||||
if (!_initialized) return;
|
||||
AppLogger.d(
|
||||
'[FFI] → updateSyncConfig: mode=${config.syncMode}, conflict=${config.conflictStrategy}, concurrent=${config.maxConcurrentTransfers}, bandwidth=${config.bandwidthLimitKbps}kbps',
|
||||
);
|
||||
try {
|
||||
await ffi.updateSyncConfig(config: config.toFfi());
|
||||
AppLogger.d('[FFI] ← updateSyncConfig: ok');
|
||||
} catch (e) {
|
||||
AppLogger.e('[FFI] ← updateSyncConfig: error=$e');
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 以下为轮询/查询类,用 trace 避免刷屏 ==========
|
||||
|
||||
/// 获取同步状态快照(轮询高频调用,trace 级别)
|
||||
Future<SyncStatusModel> getStatus() async {
|
||||
final status = await ffi.getSyncStatus();
|
||||
AppLogger.t(
|
||||
'[FFI] ← getSyncStatus: state=${status.state}, synced=${status.syncedFiles}, total=${status.totalFiles}',
|
||||
);
|
||||
return SyncStatusModel.fromFfi(status);
|
||||
}
|
||||
|
||||
/// 获取活跃 Worker 数量(轮询高频调用,trace 级别)
|
||||
Future<int> getActiveWorkerCount() async {
|
||||
final count = await ffi.getActiveWorkerCount();
|
||||
AppLogger.t('[FFI] ← getActiveWorkerCount: $count');
|
||||
return count;
|
||||
}
|
||||
|
||||
/// 获取活跃的同步任务列表(轮询高频调用,trace 级别)
|
||||
Future<List<SyncTaskModel>> getActiveTasksTyped() async {
|
||||
final tasks = await ffi.getActiveTasks();
|
||||
AppLogger.t('[FFI] ← getActiveTasks: count=${tasks.length}');
|
||||
return tasks
|
||||
.map(
|
||||
(t) => SyncTaskModel(
|
||||
id: t.id,
|
||||
trigger: t.trigger,
|
||||
totalCount: t.totalCount,
|
||||
completedCount: t.completedCount,
|
||||
failedCount: t.failedCount,
|
||||
status: t.status,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
finishedAt: t.finishedAt,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取最近同步任务列表(轮询高频调用,trace 级别)
|
||||
Future<List<SyncTaskModel>> getRecentTasksTyped({int limit = 20}) async {
|
||||
AppLogger.t('[FFI] → getRecentTasks: limit=$limit');
|
||||
final tasks = await ffi.getRecentTasks(limit: limit);
|
||||
AppLogger.t('[FFI] ← getRecentTasks: count=${tasks.length}');
|
||||
return tasks
|
||||
.map(
|
||||
(t) => SyncTaskModel(
|
||||
id: t.id,
|
||||
trigger: t.trigger,
|
||||
totalCount: t.totalCount,
|
||||
completedCount: t.completedCount,
|
||||
failedCount: t.failedCount,
|
||||
status: t.status,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
finishedAt: t.finishedAt,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 获取任务详情(按需查询,trace 级别)
|
||||
Future<List<SyncTaskItemModel>> getTaskDetailTyped(String taskId) async {
|
||||
AppLogger.t('[FFI] → getTaskDetail: taskId=$taskId');
|
||||
final items = await ffi.getTaskDetail(taskId: taskId);
|
||||
AppLogger.t('[FFI] ← getTaskDetail: count=${items.length}');
|
||||
return items
|
||||
.map(
|
||||
(i) => SyncTaskItemModel(
|
||||
id: i.id.toInt(),
|
||||
taskId: i.taskId,
|
||||
relativePath: i.relativePath,
|
||||
actionType: i.actionType,
|
||||
status: i.status,
|
||||
fileSize: i.fileSize.toInt(),
|
||||
errorMessage: i.errorMessage,
|
||||
createdAt: i.createdAt,
|
||||
updatedAt: i.updatedAt,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 分页查询任务详情(trace 级别)
|
||||
Future<List<SyncTaskItemModel>> queryTaskItemsTyped({
|
||||
required String taskId,
|
||||
int limit = 20,
|
||||
int offset = 0,
|
||||
}) async {
|
||||
AppLogger.t(
|
||||
'[FFI] → queryTaskItems: taskId=$taskId, limit=$limit, offset=$offset',
|
||||
);
|
||||
final items = await ffi.queryTaskItems(
|
||||
filter: ffi_types.TaskItemFilterFfi(
|
||||
taskId: taskId,
|
||||
limit: limit,
|
||||
offset: offset,
|
||||
),
|
||||
);
|
||||
AppLogger.t('[FFI] ← queryTaskItems: count=${items.length}');
|
||||
return items
|
||||
.map(
|
||||
(i) => SyncTaskItemModel(
|
||||
id: i.id.toInt(),
|
||||
taskId: i.taskId,
|
||||
relativePath: i.relativePath,
|
||||
actionType: i.actionType,
|
||||
status: i.status,
|
||||
fileSize: i.fileSize.toInt(),
|
||||
errorMessage: i.errorMessage,
|
||||
createdAt: i.createdAt,
|
||||
updatedAt: i.updatedAt,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 从 DB 聚合累积统计(轮询高频调用,trace 级别)
|
||||
Future<Map<String, int>> getCumStats() async {
|
||||
AppLogger.t('[FFI] → getSyncCumStats');
|
||||
final stats = await ffi.getSyncCumStats();
|
||||
AppLogger.t(
|
||||
'[FFI] ← getSyncCumStats: uploaded=${stats.uploaded}, downloaded=${stats.downloaded}, failed=${stats.failed}, conflicts=${stats.conflicts}, deletedLocal=${stats.deletedLocal}, deletedRemote=${stats.deletedRemote}, skipped=${stats.skipped}',
|
||||
);
|
||||
return {
|
||||
'uploaded': stats.uploaded,
|
||||
'downloaded': stats.downloaded,
|
||||
'renamed': stats.renamed,
|
||||
'moved': stats.moved,
|
||||
'failed': stats.failed,
|
||||
'conflicts': stats.conflicts,
|
||||
'deleted_local': stats.deletedLocal,
|
||||
'deleted_remote': stats.deletedRemote,
|
||||
'skipped': stats.skipped,
|
||||
};
|
||||
}
|
||||
|
||||
// ========== 以下为低频操作,保持 debug 级别 ==========
|
||||
|
||||
/// 水合文件 (Windows CFAPi)
|
||||
Future<void> hydrateFile(String localPath) async {
|
||||
AppLogger.d('[FFI] → hydrateFile: path=$localPath');
|
||||
await ffi.hydrateFile(localPath: localPath);
|
||||
AppLogger.d('[FFI] ← hydrateFile: ok');
|
||||
}
|
||||
|
||||
/// 检查云端相册目录 (Android)
|
||||
Future<Map<String, dynamic>> checkCloudAlbumDirs(String baseUri) async {
|
||||
AppLogger.d('[FFI] → checkCloudAlbumDirs: uri=$baseUri');
|
||||
final result = await ffi.checkCloudAlbumDirs(baseUri: baseUri);
|
||||
AppLogger.d(
|
||||
'[FFI] ← checkCloudAlbumDirs: dcim=${result.dcimExists}, pictures=${result.picturesExists}, camera=${result.cameraExists}',
|
||||
);
|
||||
return {
|
||||
'dcimExists': result.dcimExists,
|
||||
'picturesExists': result.picturesExists,
|
||||
'dcimUri': result.dcimUri,
|
||||
'picturesUri': result.picturesUri,
|
||||
'cameraExists': result.cameraExists,
|
||||
'cameraUri': result.cameraUri,
|
||||
};
|
||||
}
|
||||
|
||||
/// 创建云端相册目录 (Android)
|
||||
Future<void> createCloudAlbumDirs(String baseUri) async {
|
||||
AppLogger.d('[FFI] → createCloudAlbumDirs: uri=$baseUri');
|
||||
await ffi.createCloudAlbumDirs(baseUri: baseUri);
|
||||
AppLogger.d('[FFI] ← createCloudAlbumDirs: ok');
|
||||
}
|
||||
|
||||
/// 销毁同步引擎
|
||||
Future<void> dispose() async {
|
||||
AppLogger.d('[FFI] → disposeSyncEngine');
|
||||
_rustEventSub?.cancel();
|
||||
_rustEventSub = null;
|
||||
await _eventController.close();
|
||||
await ffi.disposeSyncEngine();
|
||||
_initialized = false;
|
||||
AppLogger.d('[FFI] ← disposeSyncEngine: ok');
|
||||
}
|
||||
|
||||
/// 订阅 Rust 事件流,转换后转发到 _eventController
|
||||
void _subscribeRustEvents() {
|
||||
_rustEventSub?.cancel();
|
||||
try {
|
||||
final stream = ffi.registerSyncEventSink();
|
||||
_rustEventSub = stream.listen(
|
||||
(event) {
|
||||
final model = syncEventFromFfi(event);
|
||||
if (model != null && !_eventController.isClosed) {
|
||||
_eventController.add(model);
|
||||
}
|
||||
},
|
||||
onError: (e) => AppLogger.e('[FFI] Rust event stream error: $e'),
|
||||
onDone: () => AppLogger.d('[FFI] Rust event stream done'),
|
||||
);
|
||||
AppLogger.d('[FFI] Rust event stream subscribed');
|
||||
} catch (e) {
|
||||
AppLogger.e('[FFI] Failed to subscribe Rust event stream: $e');
|
||||
}
|
||||
}
|
||||
|
||||
/// 热修改日志级别(立即生效,无需重启)
|
||||
Future<void> setLogLevel(String level) async {
|
||||
AppLogger.d('[FFI] → setSyncLogLevel: level=$level');
|
||||
await ffi.setSyncLogLevel(level: level);
|
||||
AppLogger.d('[FFI] ← setSyncLogLevel: ok');
|
||||
}
|
||||
|
||||
/// 重置同步:停止任务 → 清空 DB → 清空本地目录 → 回到初始状态
|
||||
Future<void> resetSync({bool deleteLocalFiles = true}) async {
|
||||
AppLogger.d('[FFI] → resetSync: deleteLocalFiles=$deleteLocalFiles');
|
||||
await ffi.resetSync(deleteLocalFiles: deleteLocalFiles);
|
||||
AppLogger.d('[FFI] ← resetSync: ok');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'api_service.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../core/utils/file_utils.dart';
|
||||
import '../core/utils/time_flow_decoder.dart';
|
||||
import 'custom_line_service.dart';
|
||||
|
||||
/// 缩略图缓存条目
|
||||
class _ThumbCacheEntry {
|
||||
@@ -41,6 +42,9 @@ class ThumbnailService {
|
||||
// 1. 检查内存缓存
|
||||
final cached = _urlCache[cacheKey];
|
||||
if (cached != null && !cached.isExpired) {
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(
|
||||
cached.imageUrl,
|
||||
);
|
||||
return cached.imageUrl;
|
||||
}
|
||||
if (cached != null) {
|
||||
@@ -64,7 +68,10 @@ class ThumbnailService {
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _fetchThumbnailUrl(String fileUri, String? contextHint) async {
|
||||
Future<String?> _fetchThumbnailUrl(
|
||||
String fileUri,
|
||||
String? contextHint,
|
||||
) async {
|
||||
try {
|
||||
final uri = FileUtils.toCloudreveUri(fileUri);
|
||||
final headers = contextHint != null
|
||||
@@ -97,12 +104,15 @@ class ThumbnailService {
|
||||
if (url.isEmpty) return null;
|
||||
|
||||
AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url');
|
||||
await CustomLineService.instance.activateSelectedNodeForUrl(url);
|
||||
|
||||
// 解析过期时间,缓存提前 30 秒过期
|
||||
DateTime expiresAt;
|
||||
if (expiresStr != null) {
|
||||
try {
|
||||
expiresAt = DateTime.parse(expiresStr).subtract(const Duration(seconds: 30));
|
||||
expiresAt = DateTime.parse(
|
||||
expiresStr,
|
||||
).subtract(const Duration(seconds: 30));
|
||||
} catch (_) {
|
||||
expiresAt = DateTime.now().add(const Duration(minutes: 5));
|
||||
}
|
||||
@@ -118,7 +128,9 @@ class ThumbnailService {
|
||||
|
||||
return url;
|
||||
} catch (e) {
|
||||
AppLogger.d('ThumbnailService: failed to get thumbnail URL for $fileUri: $e');
|
||||
AppLogger.d(
|
||||
'ThumbnailService: failed to get thumbnail URL for $fileUri: $e',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,434 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:open_file/open_file.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
|
||||
import '../core/constants/storage_keys.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
import '../data/models/app_update_model.dart';
|
||||
import 'storage_service.dart';
|
||||
|
||||
class UpdateService extends ChangeNotifier {
|
||||
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 Uri releasesRssUrl = Uri.parse(
|
||||
'https://git.saont.net/gongyun/app/releases.rss',
|
||||
);
|
||||
static final Uri releasesPageUrl = Uri.parse(
|
||||
'https://git.saont.net/gongyun/app/releases',
|
||||
);
|
||||
static final Uri downloadsPageUrl = Uri.parse(
|
||||
'https://www.gongyun.org/downloads.html',
|
||||
);
|
||||
|
||||
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;
|
||||
AppUpdateInfo? _availableUpdate;
|
||||
|
||||
AppUpdateInfo? get availableUpdate => _availableUpdate;
|
||||
bool get hasUpdate => _availableUpdate != null;
|
||||
|
||||
Future<AppUpdateInfo?> checkForUpdate() async {
|
||||
if (_checking) return null;
|
||||
|
||||
_checking = true;
|
||||
try {
|
||||
final current = await _packageInfoProvider();
|
||||
final latest = await _fetchLatestRelease(currentVersion: current.version);
|
||||
if (latest == null ||
|
||||
_compareVersions(latest.version, current.version) <= 0) {
|
||||
_setAvailableUpdate(null);
|
||||
return null;
|
||||
}
|
||||
_setAvailableUpdate(latest);
|
||||
return latest;
|
||||
} catch (e, stackTrace) {
|
||||
AppLogger.e('检查更新失败: $e\n$stackTrace');
|
||||
return null;
|
||||
} finally {
|
||||
_checking = false;
|
||||
}
|
||||
}
|
||||
|
||||
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(
|
||||
AppUpdateInfo update, {
|
||||
void Function(int received, int total)? onProgress,
|
||||
}) async {
|
||||
final url = update.downloadUrl;
|
||||
if (url == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final dir = await getTemporaryDirectory();
|
||||
final updateDir = Directory(p.join(dir.path, 'app_updates'));
|
||||
await updateDir.create(recursive: true);
|
||||
|
||||
final fileName = update.fileName ?? _fileNameFromUrl(url);
|
||||
final file = File(p.join(updateDir.path, fileName));
|
||||
if (await file.exists()) {
|
||||
await file.delete();
|
||||
}
|
||||
|
||||
await _dio.downloadUri(
|
||||
url,
|
||||
file.path,
|
||||
onReceiveProgress: onProgress,
|
||||
options: Options(responseType: ResponseType.bytes),
|
||||
);
|
||||
return file;
|
||||
}
|
||||
|
||||
Future<void> openInstaller(File file) async {
|
||||
final result = await OpenFile.open(file.path);
|
||||
if (result.type != ResultType.done) {
|
||||
throw Exception(result.message);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openReleasePage(AppUpdateInfo update) async {
|
||||
final url = update.pageUrl;
|
||||
if (!await launchUrl(url, mode: LaunchMode.externalApplication)) {
|
||||
throw Exception('无法打开更新页面: $url');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openDownloadsPage() async {
|
||||
if (!await launchUrl(
|
||||
downloadsPageUrl,
|
||||
mode: LaunchMode.externalApplication,
|
||||
)) {
|
||||
throw Exception('无法打开下载页面: $downloadsPageUrl');
|
||||
}
|
||||
}
|
||||
|
||||
Future<AppUpdateInfo?> _fetchLatestRelease({String? currentVersion}) async {
|
||||
final response = await _dio.getUri<String>(releasesRssUrl);
|
||||
final body = response.data;
|
||||
if (body == null || body.trim().isEmpty) return null;
|
||||
|
||||
final document = XmlDocument.parse(body);
|
||||
final items = document.findAllElements('item').toList();
|
||||
if (items.isEmpty) return null;
|
||||
|
||||
final releases =
|
||||
items.map(_releaseFromRssItem).whereType<_RssRelease>().toList()
|
||||
..sort(_compareRssReleaseDesc);
|
||||
if (releases.isEmpty) return null;
|
||||
|
||||
for (final release in releases) {
|
||||
if (currentVersion != null &&
|
||||
_compareVersions(release.version, currentVersion) <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final update = await _buildUpdateInfo(release);
|
||||
if (update == null) continue;
|
||||
|
||||
return update;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_RssRelease? _releaseFromRssItem(XmlElement item) {
|
||||
final title = _elementText(item, 'title') ?? '';
|
||||
final link = _elementText(item, 'link') ?? releasesPageUrl.toString();
|
||||
final description = _elementText(item, 'description');
|
||||
final content = _elementText(item, 'encoded');
|
||||
final enclosure = item
|
||||
.findElements('enclosure')
|
||||
.map((e) => e.getAttribute('url'))
|
||||
.whereType<String>()
|
||||
.map(_absoluteUriOrNull)
|
||||
.whereType<Uri>()
|
||||
.toList();
|
||||
|
||||
final candidates = <Uri>[
|
||||
...enclosure,
|
||||
..._extractLinks(description ?? ''),
|
||||
..._extractLinks(content ?? ''),
|
||||
..._extractLinks(item.toXmlString()),
|
||||
];
|
||||
|
||||
final pageUrl = _absoluteUriOrNull(link) ?? releasesPageUrl;
|
||||
final version = _versionFromText(
|
||||
'$title $link ${description ?? ''} ${content ?? ''}',
|
||||
);
|
||||
if (version == null) return null;
|
||||
|
||||
return _RssRelease(
|
||||
version: version,
|
||||
title: title.isEmpty ? 'v$version' : title,
|
||||
description: _stripHtml(
|
||||
(content ?? '').isNotEmpty ? content : description,
|
||||
),
|
||||
pageUrl: pageUrl,
|
||||
candidates: candidates,
|
||||
publishedAt: _parseRssDate(_elementText(item, 'pubDate')),
|
||||
);
|
||||
}
|
||||
|
||||
Future<AppUpdateInfo?> _buildUpdateInfo(_RssRelease release) async {
|
||||
final candidates = <Uri>[...release.candidates];
|
||||
final downloadUrl = _pickDownloadUrl(candidates);
|
||||
Uri? resolvedDownloadUrl = downloadUrl;
|
||||
|
||||
resolvedDownloadUrl ??= await _downloadUrlFromReleasePage(release.pageUrl);
|
||||
|
||||
return AppUpdateInfo(
|
||||
version: release.version,
|
||||
title: release.title,
|
||||
description: release.description,
|
||||
pageUrl: release.pageUrl,
|
||||
downloadUrl: resolvedDownloadUrl,
|
||||
fileName: resolvedDownloadUrl == null
|
||||
? null
|
||||
: _fileNameFromUrl(resolvedDownloadUrl),
|
||||
publishedAt: release.publishedAt,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Uri?> _downloadUrlFromReleasePage(Uri pageUrl) async {
|
||||
try {
|
||||
final response = await _dio.getUri<String>(pageUrl);
|
||||
final body = response.data;
|
||||
if (body == null || body.trim().isEmpty) return null;
|
||||
return _pickDownloadUrl(_extractLinks(body));
|
||||
} catch (e) {
|
||||
AppLogger.w('鑾峰彇鏇存柊闄勪欢澶辫触: $pageUrl $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Uri? _pickDownloadUrl(List<Uri> candidates) {
|
||||
final normalized = candidates.toSet().toList();
|
||||
final preferred = normalized
|
||||
.where(_isReleaseAttachment)
|
||||
.where(_isSupportedPackage)
|
||||
.toList();
|
||||
if (preferred.isNotEmpty) return preferred.first;
|
||||
return null;
|
||||
}
|
||||
|
||||
bool _isReleaseAttachment(Uri uri) {
|
||||
return uri.path.contains('/releases/download/');
|
||||
}
|
||||
|
||||
bool _isSupportedPackage(Uri uri) {
|
||||
final name = _fileNameFromUrl(uri).toLowerCase();
|
||||
if (Platform.isAndroid) {
|
||||
return name.endsWith('.apk');
|
||||
}
|
||||
if (Platform.isWindows) {
|
||||
return name.endsWith('.exe') ||
|
||||
name.endsWith('.msix') ||
|
||||
name.endsWith('.msi') ||
|
||||
name.endsWith('.zip');
|
||||
}
|
||||
if (Platform.isLinux) {
|
||||
return name.endsWith('.appimage') ||
|
||||
name.endsWith('.deb') ||
|
||||
name.endsWith('.rpm') ||
|
||||
name.endsWith('.tar.gz') ||
|
||||
name.endsWith('.zip');
|
||||
}
|
||||
if (Platform.isMacOS) {
|
||||
return name.endsWith('.dmg') ||
|
||||
name.endsWith('.pkg') ||
|
||||
name.endsWith('.zip');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
List<Uri> _extractLinks(String html) {
|
||||
final matches = RegExp(
|
||||
r'''https?://[^\s"'<>]+|href=["']([^"']+)["']''',
|
||||
caseSensitive: false,
|
||||
).allMatches(html);
|
||||
return matches
|
||||
.map((m) => m.group(1) ?? m.group(0))
|
||||
.whereType<String>()
|
||||
.map(_absoluteUriOrNull)
|
||||
.whereType<Uri>()
|
||||
.toList();
|
||||
}
|
||||
|
||||
Uri? _absoluteUriOrNull(String value) {
|
||||
final cleaned = _decodeHtmlEntities(value.trim());
|
||||
final parsed = Uri.tryParse(cleaned);
|
||||
if (parsed == null) return null;
|
||||
if (parsed.hasScheme) return parsed;
|
||||
return releasesPageUrl.resolveUri(parsed);
|
||||
}
|
||||
|
||||
String? _elementText(XmlElement element, String name) {
|
||||
for (final child in element.children.whereType<XmlElement>()) {
|
||||
if (child.name.qualified == name || child.name.local == name) {
|
||||
return child.innerText.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _versionFromText(String text) {
|
||||
final match = RegExp(
|
||||
r'v?(\d+\.\d+\.\d+(?:[+\-][0-9A-Za-z.\-]+)?)',
|
||||
).firstMatch(text);
|
||||
return match?.group(1);
|
||||
}
|
||||
|
||||
String? _stripHtml(String? value) {
|
||||
if (value == null) return null;
|
||||
final withoutTags = value.replaceAll(RegExp(r'<[^>]+>'), ' ');
|
||||
return _decodeHtmlEntities(
|
||||
withoutTags,
|
||||
).replaceAll(RegExp(r'\s+'), ' ').trim();
|
||||
}
|
||||
|
||||
String _decodeHtmlEntities(String value) {
|
||||
return value
|
||||
.replaceAll(' ', ' ')
|
||||
.replaceAll('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll(''', "'")
|
||||
.replaceAll('+', '+')
|
||||
.replaceAll('<', '<')
|
||||
.replaceAll('>', '>');
|
||||
}
|
||||
|
||||
DateTime? _parseRssDate(String? value) {
|
||||
if (value == null || value.isEmpty) return null;
|
||||
final direct = DateTime.tryParse(value);
|
||||
if (direct != null) return direct;
|
||||
try {
|
||||
return HttpDate.parse(value);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
int _compareRssReleaseDesc(_RssRelease a, _RssRelease b) {
|
||||
final version = _compareVersions(b.version, a.version);
|
||||
if (version != 0) return version;
|
||||
|
||||
final left = a.publishedAt;
|
||||
final right = b.publishedAt;
|
||||
if (left == null && right == null) return 0;
|
||||
if (left == null) return 1;
|
||||
if (right == null) return -1;
|
||||
return right.compareTo(left);
|
||||
}
|
||||
|
||||
int _compareVersions(String a, String b) {
|
||||
final left = _versionParts(a);
|
||||
final right = _versionParts(b);
|
||||
final length = left.length > right.length ? left.length : right.length;
|
||||
for (var i = 0; i < length; i++) {
|
||||
final l = i < left.length ? left[i] : 0;
|
||||
final r = i < right.length ? right[i] : 0;
|
||||
if (l != r) return l.compareTo(r);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
List<int> _versionParts(String version) {
|
||||
final core = version.split(RegExp(r'[+\-]')).first;
|
||||
return core
|
||||
.split('.')
|
||||
.map((part) => int.tryParse(part) ?? 0)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
String _fileNameFromUrl(Uri uri) {
|
||||
final segment = uri.pathSegments.isEmpty
|
||||
? 'app_update'
|
||||
: uri.pathSegments.last;
|
||||
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
|
||||
Future<AppUpdateInfo?> debugFetchLatestRelease() => _fetchLatestRelease();
|
||||
}
|
||||
|
||||
class _RssRelease {
|
||||
final String version;
|
||||
final String title;
|
||||
final String? description;
|
||||
final Uri pageUrl;
|
||||
final List<Uri> candidates;
|
||||
final DateTime? publishedAt;
|
||||
|
||||
const _RssRelease({
|
||||
required this.version,
|
||||
required this.title,
|
||||
required this.pageUrl,
|
||||
required this.candidates,
|
||||
this.description,
|
||||
this.publishedAt,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
// This file is automatically generated, so please do not edit it.
|
||||
// @generated by `flutter_rust_bridge`@ 2.12.0.
|
||||
|
||||
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
|
||||
|
||||
import '../frb_generated.dart';
|
||||
import 'ffi_types.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
|
||||
// These functions are ignored because they are not marked as `pub`: `album_result_to_ffi`, `apply_log_level`, `config_from_ffi`, `config_to_ffi`, `error_to_ffi`, `get_engine`, `status_to_ffi`, `summary_to_ffi`, `task_item_to_ffi`, `task_to_ffi`
|
||||
|
||||
/// 初始化同步引擎
|
||||
Future<void> initSyncEngine({required SyncConfigFfi config}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiInitSyncEngine(config: config);
|
||||
|
||||
/// 销毁同步引擎
|
||||
Future<void> disposeSyncEngine() =>
|
||||
RustSyncApi.instance.api.crateApiFfiDisposeSyncEngine();
|
||||
|
||||
/// 进程退出前同步清理(WCF 模式下必须调用,确保占位符释放)
|
||||
/// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用
|
||||
Future<void> syncShutdown() =>
|
||||
RustSyncApi.instance.api.crateApiFfiSyncShutdown();
|
||||
|
||||
/// 执行初始全量同步
|
||||
Future<SyncSummaryFfi> startInitialSync() =>
|
||||
RustSyncApi.instance.api.crateApiFfiStartInitialSync();
|
||||
|
||||
/// 启动持续同步(后台运行,立即返回)
|
||||
Future<void> startContinuousSync() =>
|
||||
RustSyncApi.instance.api.crateApiFfiStartContinuousSync();
|
||||
|
||||
/// 停止同步
|
||||
Future<void> stopSync() => RustSyncApi.instance.api.crateApiFfiStopSync();
|
||||
|
||||
/// 暂停同步
|
||||
Future<void> pauseSync() => RustSyncApi.instance.api.crateApiFfiPauseSync();
|
||||
|
||||
/// 恢复同步
|
||||
Future<void> resumeSync() => RustSyncApi.instance.api.crateApiFfiResumeSync();
|
||||
|
||||
/// 强制同步(重新扫描全量差异)
|
||||
Future<SyncSummaryFfi> forceSync() =>
|
||||
RustSyncApi.instance.api.crateApiFfiForceSync();
|
||||
|
||||
/// 重置同步:停止任务 → 清空 DB → (可选)清空本地目录 → 回到初始状态
|
||||
Future<void> resetSync({required bool deleteLocalFiles}) => RustSyncApi
|
||||
.instance
|
||||
.api
|
||||
.crateApiFfiResetSync(deleteLocalFiles: deleteLocalFiles);
|
||||
|
||||
/// 获取同步状态快照
|
||||
Future<SyncStatusFfi> getSyncStatus() =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetSyncStatus();
|
||||
|
||||
/// 获取活跃 Worker 数量
|
||||
Future<int> getActiveWorkerCount() =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetActiveWorkerCount();
|
||||
|
||||
/// 获取同步配置
|
||||
Future<SyncConfigFfi> getSyncConfig() =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetSyncConfig();
|
||||
|
||||
/// 更新同步配置
|
||||
Future<void> updateSyncConfig({required SyncConfigFfi config}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiUpdateSyncConfig(config: config);
|
||||
|
||||
/// Dart 推送新 Token 给 Rust
|
||||
Future<void> updateTokens({required String accessToken}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiUpdateTokens(accessToken: accessToken);
|
||||
|
||||
/// 水合文件(Windows 按需下载)
|
||||
Future<void> hydrateFile({required String localPath}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiHydrateFile(localPath: localPath);
|
||||
|
||||
/// 同步相册到云端
|
||||
Future<void> syncAlbumToCloud({
|
||||
required List<String> albumPaths,
|
||||
required String remoteDcimUri,
|
||||
}) => RustSyncApi.instance.api.crateApiFfiSyncAlbumToCloud(
|
||||
albumPaths: albumPaths,
|
||||
remoteDcimUri: remoteDcimUri,
|
||||
);
|
||||
|
||||
/// 检查云端是否存在 DCIM/Pictures 目录
|
||||
Future<CloudAlbumCheckResultFfi> checkCloudAlbumDirs({
|
||||
required String baseUri,
|
||||
}) => RustSyncApi.instance.api.crateApiFfiCheckCloudAlbumDirs(baseUri: baseUri);
|
||||
|
||||
/// 在云端创建 DCIM/Pictures 目录
|
||||
Future<void> createCloudAlbumDirs({required String baseUri}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiCreateCloudAlbumDirs(baseUri: baseUri);
|
||||
|
||||
/// 注册 Rust→Dart 事件推送通道
|
||||
Stream<SyncEventFfi> registerSyncEventSink() =>
|
||||
RustSyncApi.instance.api.crateApiFfiRegisterSyncEventSink();
|
||||
|
||||
/// 运行时热修改日志级别(立即生效,无需重启)
|
||||
Future<void> setSyncLogLevel({required String level}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiSetSyncLogLevel(level: level);
|
||||
|
||||
/// 获取活跃的同步任务列表
|
||||
Future<List<SyncTaskFfi>> getActiveTasks() =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetActiveTasks();
|
||||
|
||||
/// 获取最近同步任务列表
|
||||
Future<List<SyncTaskFfi>> getRecentTasks({required int limit}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetRecentTasks(limit: limit);
|
||||
|
||||
/// 获取任务详情(任务项列表)
|
||||
Future<List<SyncTaskItemFfi>> getTaskDetail({required String taskId}) =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetTaskDetail(taskId: taskId);
|
||||
|
||||
/// 多维度查询任务项
|
||||
Future<List<SyncTaskItemFfi>> queryTaskItems({
|
||||
required TaskItemFilterFfi filter,
|
||||
}) => RustSyncApi.instance.api.crateApiFfiQueryTaskItems(filter: filter);
|
||||
|
||||
/// 获取累积统计(从 DB 聚合,跨所有同步任务)
|
||||
Future<SyncCumStatsFfi> getSyncCumStats() =>
|
||||
RustSyncApi.instance.api.crateApiFfiGetSyncCumStats();
|
||||
@@ -0,0 +1,510 @@
|
||||
// This file is automatically generated, so please do not edit it.
|
||||
// @generated by `flutter_rust_bridge`@ 2.12.0.
|
||||
|
||||
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
|
||||
|
||||
import '../frb_generated.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
|
||||
part 'ffi_types.freezed.dart';
|
||||
|
||||
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`, `fmt`
|
||||
|
||||
/// Android: 云端相册目录检查结果
|
||||
class CloudAlbumCheckResultFfi {
|
||||
final bool dcimExists;
|
||||
final bool picturesExists;
|
||||
final String? dcimUri;
|
||||
final String? picturesUri;
|
||||
final bool cameraExists;
|
||||
final String? cameraUri;
|
||||
|
||||
const CloudAlbumCheckResultFfi({
|
||||
required this.dcimExists,
|
||||
required this.picturesExists,
|
||||
this.dcimUri,
|
||||
this.picturesUri,
|
||||
required this.cameraExists,
|
||||
this.cameraUri,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
dcimExists.hashCode ^
|
||||
picturesExists.hashCode ^
|
||||
dcimUri.hashCode ^
|
||||
picturesUri.hashCode ^
|
||||
cameraExists.hashCode ^
|
||||
cameraUri.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CloudAlbumCheckResultFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
dcimExists == other.dcimExists &&
|
||||
picturesExists == other.picturesExists &&
|
||||
dcimUri == other.dcimUri &&
|
||||
picturesUri == other.picturesUri &&
|
||||
cameraExists == other.cameraExists &&
|
||||
cameraUri == other.cameraUri;
|
||||
}
|
||||
|
||||
/// 同步配置
|
||||
class SyncConfigFfi {
|
||||
final String baseUrl;
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final String localRoot;
|
||||
final String remoteRoot;
|
||||
final String syncMode;
|
||||
final String conflictStrategy;
|
||||
final String wcfDeleteMode;
|
||||
final int maxConcurrentTransfers;
|
||||
final BigInt bandwidthLimitKbps;
|
||||
final List<String> excludedPaths;
|
||||
final int maxWorkers;
|
||||
final String dataDir;
|
||||
final String clientId;
|
||||
final String logLevel;
|
||||
|
||||
const SyncConfigFfi({
|
||||
required this.baseUrl,
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.localRoot,
|
||||
required this.remoteRoot,
|
||||
required this.syncMode,
|
||||
required this.conflictStrategy,
|
||||
required this.wcfDeleteMode,
|
||||
required this.maxConcurrentTransfers,
|
||||
required this.bandwidthLimitKbps,
|
||||
required this.excludedPaths,
|
||||
required this.maxWorkers,
|
||||
required this.dataDir,
|
||||
required this.clientId,
|
||||
required this.logLevel,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
baseUrl.hashCode ^
|
||||
accessToken.hashCode ^
|
||||
refreshToken.hashCode ^
|
||||
localRoot.hashCode ^
|
||||
remoteRoot.hashCode ^
|
||||
syncMode.hashCode ^
|
||||
conflictStrategy.hashCode ^
|
||||
wcfDeleteMode.hashCode ^
|
||||
maxConcurrentTransfers.hashCode ^
|
||||
bandwidthLimitKbps.hashCode ^
|
||||
excludedPaths.hashCode ^
|
||||
maxWorkers.hashCode ^
|
||||
dataDir.hashCode ^
|
||||
clientId.hashCode ^
|
||||
logLevel.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncConfigFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
baseUrl == other.baseUrl &&
|
||||
accessToken == other.accessToken &&
|
||||
refreshToken == other.refreshToken &&
|
||||
localRoot == other.localRoot &&
|
||||
remoteRoot == other.remoteRoot &&
|
||||
syncMode == other.syncMode &&
|
||||
conflictStrategy == other.conflictStrategy &&
|
||||
wcfDeleteMode == other.wcfDeleteMode &&
|
||||
maxConcurrentTransfers == other.maxConcurrentTransfers &&
|
||||
bandwidthLimitKbps == other.bandwidthLimitKbps &&
|
||||
excludedPaths == other.excludedPaths &&
|
||||
maxWorkers == other.maxWorkers &&
|
||||
dataDir == other.dataDir &&
|
||||
clientId == other.clientId &&
|
||||
logLevel == other.logLevel;
|
||||
}
|
||||
|
||||
/// 累积统计(FFI)
|
||||
class SyncCumStatsFfi {
|
||||
final int uploaded;
|
||||
final int downloaded;
|
||||
final int renamed;
|
||||
final int moved;
|
||||
final int failed;
|
||||
final int conflicts;
|
||||
final int deletedLocal;
|
||||
final int deletedRemote;
|
||||
final int skipped;
|
||||
|
||||
const SyncCumStatsFfi({
|
||||
required this.uploaded,
|
||||
required this.downloaded,
|
||||
required this.renamed,
|
||||
required this.moved,
|
||||
required this.failed,
|
||||
required this.conflicts,
|
||||
required this.deletedLocal,
|
||||
required this.deletedRemote,
|
||||
required this.skipped,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
uploaded.hashCode ^
|
||||
downloaded.hashCode ^
|
||||
renamed.hashCode ^
|
||||
moved.hashCode ^
|
||||
failed.hashCode ^
|
||||
conflicts.hashCode ^
|
||||
deletedLocal.hashCode ^
|
||||
deletedRemote.hashCode ^
|
||||
skipped.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncCumStatsFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
uploaded == other.uploaded &&
|
||||
downloaded == other.downloaded &&
|
||||
renamed == other.renamed &&
|
||||
moved == other.moved &&
|
||||
failed == other.failed &&
|
||||
conflicts == other.conflicts &&
|
||||
deletedLocal == other.deletedLocal &&
|
||||
deletedRemote == other.deletedRemote &&
|
||||
skipped == other.skipped;
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SyncErrorFfi with _$SyncErrorFfi implements FrbException {
|
||||
const SyncErrorFfi._();
|
||||
|
||||
const factory SyncErrorFfi.notInitialized() = SyncErrorFfi_NotInitialized;
|
||||
const factory SyncErrorFfi.networkError({required String message}) =
|
||||
SyncErrorFfi_NetworkError;
|
||||
const factory SyncErrorFfi.diskFull({
|
||||
required BigInt needed,
|
||||
required BigInt available,
|
||||
}) = SyncErrorFfi_DiskFull;
|
||||
const factory SyncErrorFfi.authError({required String message}) =
|
||||
SyncErrorFfi_AuthError;
|
||||
const factory SyncErrorFfi.conflictError({required int count}) =
|
||||
SyncErrorFfi_ConflictError;
|
||||
const factory SyncErrorFfi.internalError({required String message}) =
|
||||
SyncErrorFfi_InternalError;
|
||||
}
|
||||
|
||||
@freezed
|
||||
sealed class SyncEventFfi with _$SyncEventFfi {
|
||||
const SyncEventFfi._();
|
||||
|
||||
const factory SyncEventFfi.stateChanged({required String newState}) =
|
||||
SyncEventFfi_StateChanged;
|
||||
const factory SyncEventFfi.progress({
|
||||
required BigInt synced,
|
||||
required BigInt total,
|
||||
required String currentFile,
|
||||
}) = SyncEventFfi_Progress;
|
||||
const factory SyncEventFfi.fileUploaded({
|
||||
required String localPath,
|
||||
required String remoteUri,
|
||||
}) = SyncEventFfi_FileUploaded;
|
||||
const factory SyncEventFfi.fileDownloaded({
|
||||
required String localPath,
|
||||
required String remoteUri,
|
||||
}) = SyncEventFfi_FileDownloaded;
|
||||
const factory SyncEventFfi.conflictDetected({
|
||||
required String localPath,
|
||||
required String conflictType,
|
||||
}) = SyncEventFfi_ConflictDetected;
|
||||
const factory SyncEventFfi.error({
|
||||
required String message,
|
||||
required bool recoverable,
|
||||
}) = SyncEventFfi_Error;
|
||||
const factory SyncEventFfi.tokenExpired() = SyncEventFfi_TokenExpired;
|
||||
const factory SyncEventFfi.diskSpaceWarning({required BigInt availableMb}) =
|
||||
SyncEventFfi_DiskSpaceWarning;
|
||||
const factory SyncEventFfi.initialSyncComplete({
|
||||
required SyncSummaryFfi summary,
|
||||
}) = SyncEventFfi_InitialSyncComplete;
|
||||
const factory SyncEventFfi.workerStarted({
|
||||
required String taskId,
|
||||
required String trigger,
|
||||
required int uploadCount,
|
||||
required int downloadCount,
|
||||
}) = SyncEventFfi_WorkerStarted;
|
||||
const factory SyncEventFfi.workerCompleted({
|
||||
required String taskId,
|
||||
required int uploaded,
|
||||
required int downloaded,
|
||||
required int renamed,
|
||||
required int moved,
|
||||
required int failed,
|
||||
required BigInt durationMs,
|
||||
}) = SyncEventFfi_WorkerCompleted;
|
||||
const factory SyncEventFfi.workerFailed({
|
||||
required String taskId,
|
||||
required String message,
|
||||
}) = SyncEventFfi_WorkerFailed;
|
||||
const factory SyncEventFfi.taskItemUpdated({
|
||||
required String taskId,
|
||||
required String relativePath,
|
||||
required String action,
|
||||
required String status,
|
||||
}) = SyncEventFfi_TaskItemUpdated;
|
||||
}
|
||||
|
||||
/// 同步状态快照
|
||||
class SyncStatusFfi {
|
||||
final String state;
|
||||
final BigInt syncedFiles;
|
||||
final BigInt totalFiles;
|
||||
final int uploadingCount;
|
||||
final int downloadingCount;
|
||||
final int conflictCount;
|
||||
final int errorCount;
|
||||
final String? lastSyncTime;
|
||||
final String? errorMessage;
|
||||
|
||||
const SyncStatusFfi({
|
||||
required this.state,
|
||||
required this.syncedFiles,
|
||||
required this.totalFiles,
|
||||
required this.uploadingCount,
|
||||
required this.downloadingCount,
|
||||
required this.conflictCount,
|
||||
required this.errorCount,
|
||||
this.lastSyncTime,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
state.hashCode ^
|
||||
syncedFiles.hashCode ^
|
||||
totalFiles.hashCode ^
|
||||
uploadingCount.hashCode ^
|
||||
downloadingCount.hashCode ^
|
||||
conflictCount.hashCode ^
|
||||
errorCount.hashCode ^
|
||||
lastSyncTime.hashCode ^
|
||||
errorMessage.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncStatusFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
state == other.state &&
|
||||
syncedFiles == other.syncedFiles &&
|
||||
totalFiles == other.totalFiles &&
|
||||
uploadingCount == other.uploadingCount &&
|
||||
downloadingCount == other.downloadingCount &&
|
||||
conflictCount == other.conflictCount &&
|
||||
errorCount == other.errorCount &&
|
||||
lastSyncTime == other.lastSyncTime &&
|
||||
errorMessage == other.errorMessage;
|
||||
}
|
||||
|
||||
/// 初始同步摘要
|
||||
class SyncSummaryFfi {
|
||||
final int uploaded;
|
||||
final int downloaded;
|
||||
final int renamed;
|
||||
final int moved;
|
||||
final int conflicts;
|
||||
final int failed;
|
||||
final int skipped;
|
||||
final int deletedLocal;
|
||||
final int deletedRemote;
|
||||
final BigInt durationMs;
|
||||
|
||||
const SyncSummaryFfi({
|
||||
required this.uploaded,
|
||||
required this.downloaded,
|
||||
required this.renamed,
|
||||
required this.moved,
|
||||
required this.conflicts,
|
||||
required this.failed,
|
||||
required this.skipped,
|
||||
required this.deletedLocal,
|
||||
required this.deletedRemote,
|
||||
required this.durationMs,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
uploaded.hashCode ^
|
||||
downloaded.hashCode ^
|
||||
renamed.hashCode ^
|
||||
moved.hashCode ^
|
||||
conflicts.hashCode ^
|
||||
failed.hashCode ^
|
||||
skipped.hashCode ^
|
||||
deletedLocal.hashCode ^
|
||||
deletedRemote.hashCode ^
|
||||
durationMs.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncSummaryFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
uploaded == other.uploaded &&
|
||||
downloaded == other.downloaded &&
|
||||
renamed == other.renamed &&
|
||||
moved == other.moved &&
|
||||
conflicts == other.conflicts &&
|
||||
failed == other.failed &&
|
||||
skipped == other.skipped &&
|
||||
deletedLocal == other.deletedLocal &&
|
||||
deletedRemote == other.deletedRemote &&
|
||||
durationMs == other.durationMs;
|
||||
}
|
||||
|
||||
/// 同步任务摘要(FFI)
|
||||
class SyncTaskFfi {
|
||||
final String id;
|
||||
final String trigger;
|
||||
final int totalCount;
|
||||
final int completedCount;
|
||||
final int failedCount;
|
||||
final String status;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
final String? finishedAt;
|
||||
|
||||
const SyncTaskFfi({
|
||||
required this.id,
|
||||
required this.trigger,
|
||||
required this.totalCount,
|
||||
required this.completedCount,
|
||||
required this.failedCount,
|
||||
required this.status,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
this.finishedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
trigger.hashCode ^
|
||||
totalCount.hashCode ^
|
||||
completedCount.hashCode ^
|
||||
failedCount.hashCode ^
|
||||
status.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
updatedAt.hashCode ^
|
||||
finishedAt.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncTaskFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
trigger == other.trigger &&
|
||||
totalCount == other.totalCount &&
|
||||
completedCount == other.completedCount &&
|
||||
failedCount == other.failedCount &&
|
||||
status == other.status &&
|
||||
createdAt == other.createdAt &&
|
||||
updatedAt == other.updatedAt &&
|
||||
finishedAt == other.finishedAt;
|
||||
}
|
||||
|
||||
/// 同步任务项(FFI)
|
||||
class SyncTaskItemFfi {
|
||||
final PlatformInt64 id;
|
||||
final String taskId;
|
||||
final String relativePath;
|
||||
final String actionType;
|
||||
final String status;
|
||||
final BigInt fileSize;
|
||||
final String? errorMessage;
|
||||
final String createdAt;
|
||||
final String updatedAt;
|
||||
|
||||
const SyncTaskItemFfi({
|
||||
required this.id,
|
||||
required this.taskId,
|
||||
required this.relativePath,
|
||||
required this.actionType,
|
||||
required this.status,
|
||||
required this.fileSize,
|
||||
this.errorMessage,
|
||||
required this.createdAt,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
id.hashCode ^
|
||||
taskId.hashCode ^
|
||||
relativePath.hashCode ^
|
||||
actionType.hashCode ^
|
||||
status.hashCode ^
|
||||
fileSize.hashCode ^
|
||||
errorMessage.hashCode ^
|
||||
createdAt.hashCode ^
|
||||
updatedAt.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SyncTaskItemFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
id == other.id &&
|
||||
taskId == other.taskId &&
|
||||
relativePath == other.relativePath &&
|
||||
actionType == other.actionType &&
|
||||
status == other.status &&
|
||||
fileSize == other.fileSize &&
|
||||
errorMessage == other.errorMessage &&
|
||||
createdAt == other.createdAt &&
|
||||
updatedAt == other.updatedAt;
|
||||
}
|
||||
|
||||
/// 任务项查询过滤器(FFI)
|
||||
class TaskItemFilterFfi {
|
||||
final String? taskId;
|
||||
final String? relativePathContains;
|
||||
final String? actionType;
|
||||
final String? status;
|
||||
final int limit;
|
||||
final int offset;
|
||||
|
||||
const TaskItemFilterFfi({
|
||||
this.taskId,
|
||||
this.relativePathContains,
|
||||
this.actionType,
|
||||
this.status,
|
||||
required this.limit,
|
||||
required this.offset,
|
||||
});
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
taskId.hashCode ^
|
||||
relativePathContains.hashCode ^
|
||||
actionType.hashCode ^
|
||||
status.hashCode ^
|
||||
limit.hashCode ^
|
||||
offset.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is TaskItemFilterFfi &&
|
||||
runtimeType == other.runtimeType &&
|
||||
taskId == other.taskId &&
|
||||
relativePathContains == other.relativePathContains &&
|
||||
actionType == other.actionType &&
|
||||
status == other.status &&
|
||||
limit == other.limit &&
|
||||
offset == other.offset;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,340 @@
|
||||
// This file is automatically generated, so please do not edit it.
|
||||
// @generated by `flutter_rust_bridge`@ 2.12.0.
|
||||
|
||||
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
|
||||
|
||||
import 'api/ffi.dart';
|
||||
import 'api/ffi_types.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:ffi' as ffi;
|
||||
import 'frb_generated.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
|
||||
|
||||
abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
|
||||
RustSyncApiApiImplPlatform({
|
||||
required super.handler,
|
||||
required super.wire,
|
||||
required super.generalizedFrbRustBinding,
|
||||
required super.portManager,
|
||||
});
|
||||
|
||||
@protected
|
||||
AnyhowException dco_decode_AnyhowException(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustStreamSink<SyncEventFfi> dco_decode_StreamSink_sync_event_ffi_Sse(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
bool dco_decode_bool(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi dco_decode_box_autoadd_sync_config_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi dco_decode_box_autoadd_sync_summary_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi dco_decode_box_autoadd_task_item_filter_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
CloudAlbumCheckResultFfi dco_decode_cloud_album_check_result_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64 dco_decode_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<String> dco_decode_list_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<SyncTaskFfi> dco_decode_list_sync_task_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<SyncTaskItemFfi> dco_decode_list_sync_task_item_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
String? dco_decode_opt_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncCumStatsFfi dco_decode_sync_cum_stats_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncEventFfi dco_decode_sync_event_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncStatusFfi dco_decode_sync_status_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi dco_decode_sync_summary_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncTaskFfi dco_decode_sync_task_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncTaskItemFfi dco_decode_sync_task_item_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi dco_decode_task_item_filter_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_u_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
BigInt dco_decode_u_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_u_8(dynamic raw);
|
||||
|
||||
@protected
|
||||
void dco_decode_unit(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
RustStreamSink<SyncEventFfi> sse_decode_StreamSink_sync_event_ffi_Sse(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
bool sse_decode_bool(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi sse_decode_box_autoadd_sync_config_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi sse_decode_box_autoadd_sync_summary_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi sse_decode_box_autoadd_task_item_filter_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
CloudAlbumCheckResultFfi sse_decode_cloud_album_check_result_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<String> sse_decode_list_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SyncTaskFfi> sse_decode_list_sync_task_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SyncTaskItemFfi> sse_decode_list_sync_task_item_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncCumStatsFfi sse_decode_sync_cum_stats_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncEventFfi sse_decode_sync_event_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncStatusFfi sse_decode_sync_status_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi sse_decode_sync_summary_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncTaskFfi sse_decode_sync_task_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncTaskItemFfi sse_decode_sync_task_item_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi sse_decode_task_item_filter_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
int sse_decode_u_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BigInt sse_decode_u_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_u_8(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_decode_unit(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_AnyhowException(
|
||||
AnyhowException self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_sync_event_ffi_Sse(
|
||||
RustStreamSink<SyncEventFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_sync_config_ffi(
|
||||
SyncConfigFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_sync_summary_ffi(
|
||||
SyncSummaryFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_task_item_filter_ffi(
|
||||
TaskItemFilterFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_cloud_album_check_result_ffi(
|
||||
CloudAlbumCheckResultFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_String(List<String> self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_prim_u_8_strict(
|
||||
Uint8List self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_sync_task_ffi(
|
||||
List<SyncTaskFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_sync_task_item_ffi(
|
||||
List<SyncTaskItemFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_cum_stats_ffi(
|
||||
SyncCumStatsFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_event_ffi(SyncEventFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_status_ffi(SyncStatusFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_summary_ffi(
|
||||
SyncSummaryFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_task_ffi(SyncTaskFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_task_item_ffi(
|
||||
SyncTaskItemFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_task_item_filter_ffi(
|
||||
TaskItemFilterFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_64(BigInt self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_8(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_unit(void self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_i_32(int self, SseSerializer serializer);
|
||||
}
|
||||
|
||||
// Section: wire_class
|
||||
|
||||
class RustSyncApiWire implements BaseWire {
|
||||
factory RustSyncApiWire.fromExternalLibrary(ExternalLibrary lib) =>
|
||||
RustSyncApiWire(lib.ffiDynamicLibrary);
|
||||
|
||||
/// Holds the symbol lookup function.
|
||||
final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
|
||||
_lookup;
|
||||
|
||||
/// The symbols are looked up in [dynamicLibrary].
|
||||
RustSyncApiWire(ffi.DynamicLibrary dynamicLibrary)
|
||||
: _lookup = dynamicLibrary.lookup;
|
||||
}
|
||||
@@ -0,0 +1,340 @@
|
||||
// This file is automatically generated, so please do not edit it.
|
||||
// @generated by `flutter_rust_bridge`@ 2.12.0.
|
||||
|
||||
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
|
||||
|
||||
// Static analysis wrongly picks the IO variant, thus ignore this
|
||||
// ignore_for_file: argument_type_not_assignable
|
||||
|
||||
import 'api/ffi.dart';
|
||||
import 'api/ffi_types.dart';
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'frb_generated.dart';
|
||||
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
|
||||
|
||||
abstract class RustSyncApiApiImplPlatform extends BaseApiImpl<RustSyncApiWire> {
|
||||
RustSyncApiApiImplPlatform({
|
||||
required super.handler,
|
||||
required super.wire,
|
||||
required super.generalizedFrbRustBinding,
|
||||
required super.portManager,
|
||||
});
|
||||
|
||||
@protected
|
||||
AnyhowException dco_decode_AnyhowException(dynamic raw);
|
||||
|
||||
@protected
|
||||
RustStreamSink<SyncEventFfi> dco_decode_StreamSink_sync_event_ffi_Sse(
|
||||
dynamic raw,
|
||||
);
|
||||
|
||||
@protected
|
||||
String dco_decode_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
bool dco_decode_bool(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi dco_decode_box_autoadd_sync_config_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi dco_decode_box_autoadd_sync_summary_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi dco_decode_box_autoadd_task_item_filter_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
CloudAlbumCheckResultFfi dco_decode_cloud_album_check_result_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
PlatformInt64 dco_decode_i_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<String> dco_decode_list_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<SyncTaskFfi> dco_decode_list_sync_task_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
List<SyncTaskItemFfi> dco_decode_list_sync_task_item_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
String? dco_decode_opt_String(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi dco_decode_sync_config_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncCumStatsFfi dco_decode_sync_cum_stats_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncErrorFfi dco_decode_sync_error_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncEventFfi dco_decode_sync_event_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncStatusFfi dco_decode_sync_status_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi dco_decode_sync_summary_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncTaskFfi dco_decode_sync_task_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
SyncTaskItemFfi dco_decode_sync_task_item_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi dco_decode_task_item_filter_ffi(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_u_32(dynamic raw);
|
||||
|
||||
@protected
|
||||
BigInt dco_decode_u_64(dynamic raw);
|
||||
|
||||
@protected
|
||||
int dco_decode_u_8(dynamic raw);
|
||||
|
||||
@protected
|
||||
void dco_decode_unit(dynamic raw);
|
||||
|
||||
@protected
|
||||
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
RustStreamSink<SyncEventFfi> sse_decode_StreamSink_sync_event_ffi_Sse(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String sse_decode_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
bool sse_decode_bool(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi sse_decode_box_autoadd_sync_config_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi sse_decode_box_autoadd_sync_summary_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi sse_decode_box_autoadd_task_item_filter_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
CloudAlbumCheckResultFfi sse_decode_cloud_album_check_result_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
PlatformInt64 sse_decode_i_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<String> sse_decode_list_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SyncTaskFfi> sse_decode_list_sync_task_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
List<SyncTaskItemFfi> sse_decode_list_sync_task_item_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
String? sse_decode_opt_String(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncConfigFfi sse_decode_sync_config_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncCumStatsFfi sse_decode_sync_cum_stats_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncErrorFfi sse_decode_sync_error_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncEventFfi sse_decode_sync_event_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncStatusFfi sse_decode_sync_status_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncSummaryFfi sse_decode_sync_summary_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncTaskFfi sse_decode_sync_task_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
SyncTaskItemFfi sse_decode_sync_task_item_ffi(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
TaskItemFilterFfi sse_decode_task_item_filter_ffi(
|
||||
SseDeserializer deserializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
int sse_decode_u_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
BigInt sse_decode_u_64(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_u_8(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_decode_unit(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
int sse_decode_i_32(SseDeserializer deserializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_AnyhowException(
|
||||
AnyhowException self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_StreamSink_sync_event_ffi_Sse(
|
||||
RustStreamSink<SyncEventFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_String(String self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_bool(bool self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_sync_config_ffi(
|
||||
SyncConfigFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_sync_summary_ffi(
|
||||
SyncSummaryFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_box_autoadd_task_item_filter_ffi(
|
||||
TaskItemFilterFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_cloud_album_check_result_ffi(
|
||||
CloudAlbumCheckResultFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_i_64(PlatformInt64 self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_String(List<String> self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_prim_u_8_strict(
|
||||
Uint8List self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_sync_task_ffi(
|
||||
List<SyncTaskFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_list_sync_task_item_ffi(
|
||||
List<SyncTaskItemFfi> self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_opt_String(String? self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_config_ffi(SyncConfigFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_cum_stats_ffi(
|
||||
SyncCumStatsFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_error_ffi(SyncErrorFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_event_ffi(SyncEventFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_status_ffi(SyncStatusFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_summary_ffi(
|
||||
SyncSummaryFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_task_ffi(SyncTaskFfi self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_sync_task_item_ffi(
|
||||
SyncTaskItemFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_task_item_filter_ffi(
|
||||
TaskItemFilterFfi self,
|
||||
SseSerializer serializer,
|
||||
);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_32(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_64(BigInt self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_u_8(int self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_unit(void self, SseSerializer serializer);
|
||||
|
||||
@protected
|
||||
void sse_encode_i_32(int self, SseSerializer serializer);
|
||||
}
|
||||
|
||||
// Section: wire_class
|
||||
|
||||
class RustSyncApiWire implements BaseWire {
|
||||
RustSyncApiWire.fromExternalLibrary(ExternalLibrary lib);
|
||||
}
|
||||
|
||||
@JS('wasm_bindgen')
|
||||
external RustSyncApiWasmModule get wasmModule;
|
||||
|
||||
@JS()
|
||||
@anonymous
|
||||
extension type RustSyncApiWasmModule._(JSObject _) implements JSObject {}
|
||||
@@ -126,3 +126,37 @@ if(NOT CMAKE_BUILD_TYPE MATCHES "Debug")
|
||||
install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
# === Rust Sync Engine ===
|
||||
find_program(CARGO_CMD cargo)
|
||||
if(NOT CARGO_CMD)
|
||||
message(WARNING "cargo not found, skipping Rust sync engine build")
|
||||
else()
|
||||
set(SYNC_CORE_DIR "${CMAKE_CURRENT_SOURCE_DIR}/../native")
|
||||
|
||||
if(CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "Profile")
|
||||
set(CARGO_PROFILE "--release")
|
||||
set(RUST_BUILD_DIR "release")
|
||||
else()
|
||||
set(CARGO_PROFILE "")
|
||||
set(RUST_BUILD_DIR "debug")
|
||||
endif()
|
||||
|
||||
set(RUST_TARGET_DIR "${CMAKE_BINARY_DIR}/rust_target")
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT "${RUST_TARGET_DIR}/${RUST_BUILD_DIR}/libsync_core.so"
|
||||
COMMAND ${CARGO_CMD} build ${CARGO_PROFILE}
|
||||
--manifest-path "${SYNC_CORE_DIR}/Cargo.toml"
|
||||
--target-dir "${RUST_TARGET_DIR}"
|
||||
--features sync-core/linux-fuse,sync-core/event_sink_enabled
|
||||
COMMENT "Building Rust sync engine..."
|
||||
)
|
||||
|
||||
add_custom_target(sync_core_build DEPENDS "${RUST_TARGET_DIR}/${RUST_BUILD_DIR}/libsync_core.so")
|
||||
add_dependencies(${BINARY_NAME} sync_core_build)
|
||||
|
||||
install(FILES "${RUST_TARGET_DIR}/${RUST_BUILD_DIR}/libsync_core.so"
|
||||
DESTINATION "${INSTALL_BUNDLE_LIB_DIR}"
|
||||
COMPONENT Runtime)
|
||||
endif()
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <desktop_drop/desktop_drop_plugin.h>
|
||||
#include <file_selector_linux/file_selector_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_video/media_kit_video_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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterAcrylicPlugin");
|
||||
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 =
|
||||
fl_plugin_registry_get_registrar_for_plugin(registry, "MediaKitLibsLinuxPlugin");
|
||||
media_kit_libs_linux_plugin_register_with_registrar(media_kit_libs_linux_registrar);
|
||||
|
||||
@@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
||||
desktop_drop
|
||||
file_selector_linux
|
||||
flutter_acrylic
|
||||
flutter_inappwebview_linux
|
||||
media_kit_libs_linux
|
||||
media_kit_video
|
||||
open_file_linux
|
||||
|
||||
Generated
+3121
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = [
|
||||
"sync-core",
|
||||
"sync-windows",
|
||||
"sync-linux",
|
||||
"sync-android",
|
||||
]
|
||||
|
||||
[workspace.dependencies]
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "fs", "io-util"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
thiserror = "2"
|
||||
anyhow = "1"
|
||||
tracing = "0.1"
|
||||
parking_lot = "0.12"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
@@ -0,0 +1,5 @@
|
||||
rust_input: crate::api
|
||||
rust_root: sync-core/
|
||||
dart_output: ../lib/src/rust/
|
||||
dart_entrypoint_class_name: RustSyncApi
|
||||
dart_enums_style: true
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "sync-android"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["lib"]
|
||||
|
||||
[dependencies]
|
||||
# 不依赖 sync-core,避免循环依赖
|
||||
jni = { version = "0.21", features = ["invocation"] }
|
||||
thiserror = { workspace = true }
|
||||
tracing = "0.1"
|
||||
anyhow = { workspace = true }
|
||||
tokio = { workspace = true }
|
||||
async-trait = "0.1"
|
||||
|
||||
# 通用日志接口
|
||||
log = "0.4"
|
||||
|
||||
# 仅在 Android 平台下生效的日志后端
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
android_logger = "0.15"
|
||||
@@ -0,0 +1,106 @@
|
||||
#![cfg(target_os = "android")]
|
||||
|
||||
//! Android 平台适配层
|
||||
//!
|
||||
//! 提供相册同步辅助功能:
|
||||
//! - 文件类型过滤(仅图片/视频)
|
||||
//! - 路径验证
|
||||
//!
|
||||
//! 实际的扫描由 Dart 侧通过平台通道完成,
|
||||
//! 上传和状态管理由 sync-core 的 sync_album 处理
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// 图片扩展名
|
||||
const IMAGE_EXTENSIONS: &[&str] = &[
|
||||
"jpg", "jpeg", "png", "gif", "bmp", "webp", "heic", "heif", "raw", "tiff", "svg",
|
||||
];
|
||||
|
||||
/// 视频扩展名
|
||||
const VIDEO_EXTENSIONS: &[&str] = &[
|
||||
"mp4", "mov", "avi", "mkv", "wmv", "flv", "3gp", "webm",
|
||||
];
|
||||
|
||||
pub struct AndroidAdapter;
|
||||
|
||||
impl Default for AndroidAdapter {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl AndroidAdapter {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 判断文件是否为媒体文件(图片或视频)
|
||||
pub fn is_media_file(path: &str) -> bool {
|
||||
let ext = Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
IMAGE_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
/// 判断文件是否为图片
|
||||
pub fn is_image_file(path: &str) -> bool {
|
||||
let ext = Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
IMAGE_EXTENSIONS.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
/// 判断文件是否为视频
|
||||
pub fn is_video_file(path: &str) -> bool {
|
||||
let ext = Path::new(path)
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.to_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
VIDEO_EXTENSIONS.contains(&ext.as_str())
|
||||
}
|
||||
|
||||
/// 从路径列表中过滤出媒体文件
|
||||
pub fn filter_media_files(paths: &[String]) -> Vec<String> {
|
||||
paths.iter()
|
||||
.filter(|p| Self::is_media_file(p))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_media_file() {
|
||||
assert!(AndroidAdapter::is_image_file("/sdcard/DCIM/IMG_001.jpg"));
|
||||
assert!(AndroidAdapter::is_image_file("/sdcard/DCIM/IMG_001.JPEG"));
|
||||
assert!(AndroidAdapter::is_video_file("/sdcard/DCIM/VID_001.mp4"));
|
||||
assert!(!AndroidAdapter::is_media_file("/sdcard/Download/doc.pdf"));
|
||||
assert!(AndroidAdapter::is_media_file("/sdcard/Pictures/photo.webp"));
|
||||
assert!(AndroidAdapter::is_media_file("/sdcard/DCIM/photo.heic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_filter_media_files() {
|
||||
let paths = vec![
|
||||
"/sdcard/DCIM/IMG_001.jpg".to_string(),
|
||||
"/sdcard/Download/doc.pdf".to_string(),
|
||||
"/sdcard/DCIM/VID_001.mp4".to_string(),
|
||||
"/sdcard/Music/song.mp3".to_string(),
|
||||
];
|
||||
let filtered = AndroidAdapter::filter_media_files(&paths);
|
||||
assert_eq!(filtered.len(), 2);
|
||||
assert!(filtered.contains(&"/sdcard/DCIM/IMG_001.jpg".to_string()));
|
||||
assert!(filtered.contains(&"/sdcard/DCIM/VID_001.mp4".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
[package]
|
||||
name = "sync-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "lib"]
|
||||
|
||||
[dependencies]
|
||||
# FRB
|
||||
flutter_rust_bridge = "=2.12.0"
|
||||
|
||||
# 异步运行时
|
||||
tokio = { workspace = true, features = ["signal"] }
|
||||
|
||||
# HTTP
|
||||
reqwest = { version = "0.12", features = ["json", "stream", "rustls-tls"], default-features = false }
|
||||
|
||||
# 序列化
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# 数据库
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
r2d2 = "0.8"
|
||||
r2d2_sqlite = "0.25"
|
||||
|
||||
# 文件系统
|
||||
walkdir = "2"
|
||||
sha2 = "0.10"
|
||||
hex = "0.4"
|
||||
notify-debouncer-full = "0.7.0"
|
||||
|
||||
# 错误处理
|
||||
thiserror = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
# 日志
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# 并发
|
||||
dashmap = "6"
|
||||
|
||||
# 工具
|
||||
uuid = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
parking_lot = { workspace = true }
|
||||
futures = "0.3"
|
||||
futures-util = "0.3"
|
||||
bytes = "1"
|
||||
filetime = "0.2"
|
||||
async-trait = "0.1"
|
||||
url = "2"
|
||||
urlencoding = "2"
|
||||
mime_guess = "2"
|
||||
once_cell = "1"
|
||||
tokio-util = { version = "0.7", features = ["compat"] }
|
||||
|
||||
# SSE
|
||||
# (已移除 eventsource 0.5 依赖,使用内联 SSE 解析器,避免旧版 tokio/net2 警告)
|
||||
|
||||
# 平台依赖 (feature-gated)
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
sync-windows = { path = "../sync-windows", optional = true }
|
||||
windows = { version = "0.58", optional = true }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
sync-linux = { path = "../sync-linux", optional = true }
|
||||
fuser = { version = "0.15", optional = true }
|
||||
libc = { version = "0.2", optional = true }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
sync-android = { path = "../sync-android", optional = true }
|
||||
jni = { version = "0.21", optional = true }
|
||||
android_logger = "0.15"
|
||||
log = "0.4"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
windows-cfapi = ["sync-windows", "windows"]
|
||||
linux-notify = ["sync-linux"]
|
||||
linux-fuse = ["fuser", "libc"]
|
||||
android-media = ["sync-android", "jni"]
|
||||
event_sink_enabled = []
|
||||
|
||||
[lints.rust]
|
||||
unexpected_cfgs = { level = "warn", check-cfg = ['cfg(frb_expand)'] }
|
||||
@@ -0,0 +1,848 @@
|
||||
use flutter_rust_bridge::frb;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::api::ffi_types::*;
|
||||
use crate::sync_engine::SyncEngine;
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
mod android_log {
|
||||
use std::fmt::Write;
|
||||
use tracing::{Event, Level, Subscriber};
|
||||
use tracing_subscriber::layer::{Context, Layer};
|
||||
use tracing_subscriber::registry::LookupSpan;
|
||||
|
||||
/// Tracing Layer:将事件转发到 `log` crate → android_logger → Logcat
|
||||
pub struct AndroidLogLayer;
|
||||
|
||||
impl<S> Layer<S> for AndroidLogLayer
|
||||
where
|
||||
S: Subscriber + for<'a> LookupSpan<'a>,
|
||||
{
|
||||
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
|
||||
let log_level = match *event.metadata().level() {
|
||||
Level::ERROR => log::Level::Error,
|
||||
Level::WARN => log::Level::Warn,
|
||||
Level::INFO => log::Level::Info,
|
||||
Level::DEBUG => log::Level::Debug,
|
||||
Level::TRACE => log::Level::Trace,
|
||||
};
|
||||
|
||||
let mut visitor = EventVisitor::default();
|
||||
event.record(&mut visitor);
|
||||
|
||||
let target = event.metadata().target();
|
||||
if visitor.message.is_empty() {
|
||||
log::log!(log_level, "[{}] {}", target, visitor.fields.trim_end());
|
||||
} else if visitor.fields.is_empty() {
|
||||
log::log!(log_level, "[{}] {}", target, visitor.message);
|
||||
} else {
|
||||
log::log!(
|
||||
log_level,
|
||||
"[{}] {} {}",
|
||||
target,
|
||||
visitor.message,
|
||||
visitor.fields.trim_end()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct EventVisitor {
|
||||
message: String,
|
||||
fields: String,
|
||||
}
|
||||
|
||||
impl tracing::field::Visit for EventVisitor {
|
||||
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
|
||||
if field.name() == "message" {
|
||||
// message 字段由 tracing::field::display() 包装,其 Debug 实际走 Display,无多余引号
|
||||
write!(self.message, "{:?}", value).unwrap();
|
||||
} else {
|
||||
write!(self.fields, "{}={:?} ", field.name(), value).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn init_android_logger() {
|
||||
android_logger::init_once(
|
||||
android_logger::Config::default()
|
||||
.with_max_level(log::LevelFilter::Trace)
|
||||
.with_tag("RustSyncCore"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 全局引擎实例
|
||||
static ENGINE: once_cell::sync::OnceCell<Arc<SyncEngine>> = once_cell::sync::OnceCell::new();
|
||||
|
||||
/// 全局日志级别重载句柄(支持运行时热修改)
|
||||
static LOG_RELOAD_HANDLE: once_cell::sync::OnceCell<
|
||||
tracing_subscriber::reload::Handle<tracing_subscriber::EnvFilter, tracing_subscriber::Registry>,
|
||||
> = once_cell::sync::OnceCell::new();
|
||||
|
||||
// 内部类型 -> FFI 类型的转换函数
|
||||
|
||||
fn error_to_ffi(e: crate::errors::SyncError) -> SyncErrorFfi {
|
||||
match e {
|
||||
crate::errors::SyncError::Network(msg) => SyncErrorFfi::NetworkError { message: msg },
|
||||
crate::errors::SyncError::DiskFull { needed, available } => {
|
||||
SyncErrorFfi::DiskFull { needed, available }
|
||||
}
|
||||
crate::errors::SyncError::Auth(msg) => SyncErrorFfi::AuthError { message: msg },
|
||||
crate::errors::SyncError::Conflict { count } => SyncErrorFfi::ConflictError { count },
|
||||
crate::errors::SyncError::NotInitialized => SyncErrorFfi::NotInitialized,
|
||||
_ => SyncErrorFfi::InternalError {
|
||||
message: e.to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn config_from_ffi(ffi: SyncConfigFfi) -> crate::models::SyncConfig {
|
||||
use crate::models::{ConflictStrategy, SyncMode, WcfDeleteMode};
|
||||
use std::path::PathBuf;
|
||||
|
||||
let sync_mode = match ffi.sync_mode.as_str() {
|
||||
"upload_only" => SyncMode::UploadOnly,
|
||||
"download_only" => SyncMode::DownloadOnly,
|
||||
"album_upload" => SyncMode::AlbumUpload,
|
||||
"album_download" => SyncMode::AlbumDownload,
|
||||
"mirror_wcf" => SyncMode::MirrorWcf,
|
||||
_ => SyncMode::Full,
|
||||
};
|
||||
|
||||
let conflict_strategy = match ffi.conflict_strategy.as_str() {
|
||||
"keep_local" => ConflictStrategy::KeepLocal,
|
||||
"keep_remote" => ConflictStrategy::KeepRemote,
|
||||
"newest_wins" => ConflictStrategy::NewestWins,
|
||||
"largest_wins" => ConflictStrategy::LargestWins,
|
||||
"manual" => ConflictStrategy::Manual,
|
||||
_ => ConflictStrategy::KeepBoth,
|
||||
};
|
||||
|
||||
let wcf_delete_mode = match ffi.wcf_delete_mode.as_str() {
|
||||
"wcf_delete_sync_remote" => WcfDeleteMode::SyncRemote,
|
||||
_ => WcfDeleteMode::LocalOnly,
|
||||
};
|
||||
|
||||
let bandwidth_limit = if ffi.bandwidth_limit_kbps > 0 {
|
||||
Some(ffi.bandwidth_limit_kbps * 1024)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
crate::models::SyncConfig {
|
||||
base_url: ffi.base_url,
|
||||
access_token: ffi.access_token,
|
||||
refresh_token: ffi.refresh_token,
|
||||
local_root: PathBuf::from(&ffi.local_root),
|
||||
remote_root: ffi.remote_root,
|
||||
sync_mode,
|
||||
conflict_strategy,
|
||||
wcf_delete_mode,
|
||||
max_concurrent_transfers: ffi.max_concurrent_transfers as usize,
|
||||
bandwidth_limit,
|
||||
excluded_paths: ffi.excluded_paths,
|
||||
max_workers: ffi.max_workers as usize,
|
||||
data_dir: PathBuf::from(&ffi.data_dir),
|
||||
client_id: ffi.client_id,
|
||||
}
|
||||
}
|
||||
|
||||
fn config_to_ffi(c: &crate::models::SyncConfig) -> SyncConfigFfi {
|
||||
use crate::models::{ConflictStrategy, SyncMode, WcfDeleteMode};
|
||||
|
||||
let sync_mode = match c.sync_mode {
|
||||
SyncMode::Full => "full",
|
||||
SyncMode::UploadOnly => "upload_only",
|
||||
SyncMode::DownloadOnly => "download_only",
|
||||
SyncMode::AlbumUpload => "album_upload",
|
||||
SyncMode::AlbumDownload => "album_download",
|
||||
SyncMode::MirrorWcf => "mirror_wcf",
|
||||
};
|
||||
|
||||
let conflict_strategy = match c.conflict_strategy {
|
||||
ConflictStrategy::KeepLocal => "keep_local",
|
||||
ConflictStrategy::KeepRemote => "keep_remote",
|
||||
ConflictStrategy::KeepBoth => "keep_both",
|
||||
ConflictStrategy::NewestWins => "newest_wins",
|
||||
ConflictStrategy::LargestWins => "largest_wins",
|
||||
ConflictStrategy::Manual => "manual",
|
||||
};
|
||||
|
||||
let wcf_delete_mode = match c.wcf_delete_mode {
|
||||
WcfDeleteMode::LocalOnly => "wcf_delete_local_only",
|
||||
WcfDeleteMode::SyncRemote => "wcf_delete_sync_remote",
|
||||
};
|
||||
|
||||
SyncConfigFfi {
|
||||
base_url: c.base_url.clone(),
|
||||
access_token: c.access_token.clone(),
|
||||
refresh_token: c.refresh_token.clone(),
|
||||
local_root: c.local_root.to_string_lossy().to_string(),
|
||||
remote_root: c.remote_root.clone(),
|
||||
sync_mode: sync_mode.to_string(),
|
||||
conflict_strategy: conflict_strategy.to_string(),
|
||||
wcf_delete_mode: wcf_delete_mode.to_string(),
|
||||
max_concurrent_transfers: c.max_concurrent_transfers as u32,
|
||||
bandwidth_limit_kbps: c.bandwidth_limit.map(|b| b / 1024).unwrap_or(0),
|
||||
excluded_paths: c.excluded_paths.clone(),
|
||||
max_workers: c.max_workers as u32,
|
||||
data_dir: c.data_dir.to_string_lossy().to_string(),
|
||||
client_id: c.client_id.clone(),
|
||||
log_level: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn status_to_ffi(s: crate::models::SyncStatusSnapshot) -> SyncStatusFfi {
|
||||
let error_msg = if let crate::models::SyncState::Error { ref message } = s.state {
|
||||
Some(message.clone())
|
||||
} else {
|
||||
s.error_message
|
||||
};
|
||||
|
||||
let state = match s.state {
|
||||
crate::models::SyncState::Idle => "idle".to_string(),
|
||||
crate::models::SyncState::Initializing => "initializing".to_string(),
|
||||
crate::models::SyncState::InitialSync { .. } => "initialSync".to_string(),
|
||||
crate::models::SyncState::Continuous => "continuous".to_string(),
|
||||
crate::models::SyncState::Paused => "paused".to_string(),
|
||||
crate::models::SyncState::Error { .. } => "error".to_string(),
|
||||
crate::models::SyncState::Stopped => "stopped".to_string(),
|
||||
};
|
||||
|
||||
SyncStatusFfi {
|
||||
state,
|
||||
synced_files: s.synced_files,
|
||||
total_files: s.total_files,
|
||||
uploading_count: s.uploading_count,
|
||||
downloading_count: s.downloading_count,
|
||||
conflict_count: s.conflict_count,
|
||||
error_count: s.error_count,
|
||||
last_sync_time: s.last_sync_time,
|
||||
error_message: error_msg,
|
||||
}
|
||||
}
|
||||
|
||||
fn summary_to_ffi(s: crate::models::SyncSummary) -> SyncSummaryFfi {
|
||||
SyncSummaryFfi {
|
||||
uploaded: s.uploaded,
|
||||
downloaded: s.downloaded,
|
||||
renamed: s.renamed,
|
||||
moved: s.moved,
|
||||
conflicts: s.conflicts,
|
||||
failed: s.failed,
|
||||
skipped: s.skipped,
|
||||
deleted_local: s.deleted_local,
|
||||
deleted_remote: s.deleted_remote,
|
||||
duration_ms: s.duration_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn album_result_to_ffi(r: crate::models::CloudAlbumCheckResult) -> CloudAlbumCheckResultFfi {
|
||||
CloudAlbumCheckResultFfi {
|
||||
dcim_exists: r.dcim_exists,
|
||||
pictures_exists: r.pictures_exists,
|
||||
dcim_uri: r.dcim_uri,
|
||||
pictures_uri: r.pictures_uri,
|
||||
camera_exists: r.camera_exists,
|
||||
camera_uri: r.camera_uri,
|
||||
}
|
||||
}
|
||||
|
||||
/// 获取引擎引用,未初始化则返回错误
|
||||
fn get_engine() -> Result<&'static SyncEngine, SyncErrorFfi> {
|
||||
ENGINE
|
||||
.get()
|
||||
.map(|arc| arc.as_ref())
|
||||
.ok_or(SyncErrorFfi::NotInitialized)
|
||||
}
|
||||
|
||||
/// 内部:应用日志级别到 reload handle
|
||||
fn apply_log_level(level: &str) {
|
||||
if level.is_empty() {
|
||||
return;
|
||||
}
|
||||
if let Some(handle) = LOG_RELOAD_HANDLE.get() {
|
||||
let directive = format!("sync_core={}", level);
|
||||
match handle.modify(|filter| {
|
||||
*filter = tracing_subscriber::EnvFilter::new(&directive);
|
||||
}) {
|
||||
Ok(()) => eprintln!("[sync-core] 日志级别已切换为: {}", level),
|
||||
Err(e) => eprintln!("[sync-core] 修改日志级别失败: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 生命周期 ==========
|
||||
|
||||
/// 初始化同步引擎
|
||||
#[frb]
|
||||
pub async fn init_sync_engine(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> {
|
||||
eprintln!("[FFI] init_sync_engine ← mode={}, conflict={}, wcf_delete={}, concurrent={}, bandwidth={}kbps, log_level={}",
|
||||
config.sync_mode, config.conflict_strategy, config.wcf_delete_mode, config.max_concurrent_transfers,
|
||||
config.bandwidth_limit_kbps, config.log_level);
|
||||
|
||||
// 确保本地同步目录存在
|
||||
let local_root = std::path::PathBuf::from(&config.local_root);
|
||||
if !local_root.exists() {
|
||||
std::fs::create_dir_all(&local_root).map_err(|e| SyncErrorFfi::InternalError {
|
||||
message: format!("无法创建同步目录: {}", e),
|
||||
})?;
|
||||
}
|
||||
|
||||
// 确保程序数据目录存在
|
||||
let data_dir = std::path::PathBuf::from(&config.data_dir);
|
||||
let db_dir = data_dir.join("sync_core").join("datas");
|
||||
let log_dir = data_dir.join("sync_core").join("logs");
|
||||
if !db_dir.exists() {
|
||||
std::fs::create_dir_all(&db_dir).map_err(|e| SyncErrorFfi::InternalError {
|
||||
message: format!("无法创建数据库目录: {}", e),
|
||||
})?;
|
||||
}
|
||||
if !log_dir.exists() {
|
||||
std::fs::create_dir_all(&log_dir).map_err(|e| SyncErrorFfi::InternalError {
|
||||
message: format!("无法创建日志目录: {}", e),
|
||||
})?;
|
||||
}
|
||||
|
||||
// 初始化 tracing 日志:输出到程序数据目录的 logs 和 stderr
|
||||
let log_path = log_dir.join("sync_log.txt");
|
||||
eprintln!("[sync-core] 日志文件: {}", log_path.display());
|
||||
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.ok();
|
||||
|
||||
if log_file.is_none() {
|
||||
eprintln!("[sync-core] 警告: 无法创建日志文件 {}", log_path.display());
|
||||
}
|
||||
|
||||
// Android: 初始化 Logcat 日志后端(tracing → log → android_logger → Logcat)
|
||||
#[cfg(target_os = "android")]
|
||||
android_log::init_android_logger();
|
||||
|
||||
// 尝试初始化 subscriber(仅首次有效,后续调用忽略)
|
||||
{
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
let filter = tracing_subscriber::EnvFilter::from_default_env()
|
||||
.add_directive("sync_core=debug".parse().unwrap());
|
||||
|
||||
let (reload_filter, reload_handle) =
|
||||
tracing_subscriber::reload::Layer::<_, tracing_subscriber::Registry>::new(filter);
|
||||
LOG_RELOAD_HANDLE.set(reload_handle).ok();
|
||||
|
||||
let registry = tracing_subscriber::registry().with(reload_filter);
|
||||
|
||||
// Android: 添加 Logcat 桥接层
|
||||
#[cfg(target_os = "android")]
|
||||
let registry = registry.with(android_log::AndroidLogLayer);
|
||||
|
||||
if let Some(file) = log_file {
|
||||
let _ = registry
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(std::sync::Mutex::new(file))
|
||||
.with_ansi(false),
|
||||
)
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||
.try_init();
|
||||
} else {
|
||||
let _ = registry
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||
.try_init();
|
||||
}
|
||||
}
|
||||
|
||||
// 提取配置信息用于日志(在 move 之前)
|
||||
let log_sync_mode = config.sync_mode.clone();
|
||||
let log_conflict_strategy = config.conflict_strategy.clone();
|
||||
let log_max_concurrent = config.max_concurrent_transfers;
|
||||
let log_bandwidth = config.bandwidth_limit_kbps;
|
||||
let log_level = config.log_level.clone();
|
||||
|
||||
let engine = SyncEngine::new(config_from_ffi(config))
|
||||
.await
|
||||
.map_err(error_to_ffi)?;
|
||||
|
||||
ENGINE
|
||||
.set(Arc::new(engine))
|
||||
.map_err(|_| SyncErrorFfi::InternalError {
|
||||
message: "引擎已初始化".to_string(),
|
||||
})?;
|
||||
|
||||
tracing::info!("同步引擎初始化完成, 日志文件: {}", log_path.display());
|
||||
tracing::info!(
|
||||
"配置: 模式={}, 冲突策略={}, 并发={}, 带宽限制={}kbps",
|
||||
log_sync_mode,
|
||||
log_conflict_strategy,
|
||||
log_max_concurrent,
|
||||
log_bandwidth,
|
||||
);
|
||||
if log_bandwidth > 0 {
|
||||
tracing::info!("仅对下载限速生效, 由于Cloudreve实现原因, 上传限速无法生效");
|
||||
}
|
||||
|
||||
// 应用配置中的日志级别(热修改覆盖默认 debug)
|
||||
apply_log_level(&log_level);
|
||||
|
||||
// 注册 SIGINT/SIGTERM 信号处理,确保 FUSE/WCF 等资源被优雅清理
|
||||
#[cfg(unix)]
|
||||
{
|
||||
tokio::spawn(async {
|
||||
let mut sigterm =
|
||||
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!("无法注册 SIGTERM 处理: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
tokio::select! {
|
||||
_ = tokio::signal::ctrl_c() => {
|
||||
tracing::info!("收到 SIGINT,开始清理...");
|
||||
}
|
||||
_ = sigterm.recv() => {
|
||||
tracing::info!("收到 SIGTERM,开始清理...");
|
||||
}
|
||||
}
|
||||
sync_shutdown().ok();
|
||||
std::process::exit(0);
|
||||
});
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
tokio::spawn(async {
|
||||
if tokio::signal::ctrl_c().await.is_ok() {
|
||||
tracing::info!("收到 Ctrl+C,开始清理...");
|
||||
sync_shutdown().ok();
|
||||
std::process::exit(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 销毁同步引擎
|
||||
#[frb]
|
||||
pub async fn dispose_sync_engine() -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] dispose_sync_engine ←");
|
||||
let engine = get_engine()?;
|
||||
engine.stop().await.map_err(error_to_ffi)?;
|
||||
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
{
|
||||
engine.cleanup_wcf();
|
||||
}
|
||||
|
||||
#[cfg(feature = "linux-fuse")]
|
||||
{
|
||||
engine.cleanup_fuse();
|
||||
}
|
||||
|
||||
tracing::info!("同步引擎已停止");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 进程退出前同步清理(WCF/FUSE 模式下必须调用,确保占位符释放和挂载点卸载)
|
||||
/// 此函数是同步的,不依赖 tokio runtime,可安全在 exit(0) 前调用
|
||||
#[frb]
|
||||
pub fn sync_shutdown() -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] sync_shutdown ←");
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
{
|
||||
let engine = match ENGINE.get() {
|
||||
Some(e) => e,
|
||||
None => return Ok(()),
|
||||
};
|
||||
engine.cleanup_wcf();
|
||||
}
|
||||
#[cfg(feature = "linux-fuse")]
|
||||
{
|
||||
let engine = match ENGINE.get() {
|
||||
Some(e) => e,
|
||||
None => return Ok(()),
|
||||
};
|
||||
engine.cleanup_fuse();
|
||||
}
|
||||
tracing::info!("同步引擎已同步清理");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== 同步控制 ==========
|
||||
|
||||
/// 执行初始全量同步
|
||||
#[frb]
|
||||
pub async fn start_initial_sync() -> Result<SyncSummaryFfi, SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] start_initial_sync ←");
|
||||
let engine = get_engine()?;
|
||||
engine.ensure_token_fresh();
|
||||
engine
|
||||
.run_initial_sync()
|
||||
.await
|
||||
.map(|s| {
|
||||
tracing::debug!(
|
||||
"[FFI] start_initial_sync → uploaded={}, downloaded={}, conflicts={}, failed={}",
|
||||
s.uploaded,
|
||||
s.downloaded,
|
||||
s.conflicts,
|
||||
s.failed
|
||||
);
|
||||
summary_to_ffi(s)
|
||||
})
|
||||
.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
/// 启动持续同步(后台运行,立即返回)
|
||||
#[frb]
|
||||
pub async fn start_continuous_sync() -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] start_continuous_sync ←");
|
||||
let engine = get_engine()?;
|
||||
let engine = engine.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = engine.run_continuous().await {
|
||||
tracing::error!("持续同步异常退出: {}", e);
|
||||
}
|
||||
});
|
||||
tracing::debug!("[FFI] start_continuous_sync → spawned");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 停止同步
|
||||
#[frb]
|
||||
pub async fn stop_sync() -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] stop_sync ←");
|
||||
let engine = get_engine()?;
|
||||
engine.stop().await.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
/// 暂停同步
|
||||
#[frb]
|
||||
pub async fn pause_sync() -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] pause_sync ←");
|
||||
let engine = get_engine()?;
|
||||
engine.pause().await.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
/// 恢复同步
|
||||
#[frb]
|
||||
pub async fn resume_sync() -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] resume_sync ←");
|
||||
let engine = get_engine()?;
|
||||
engine.resume().await.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
/// 强制同步(重新扫描全量差异)
|
||||
#[frb]
|
||||
pub async fn force_sync() -> Result<SyncSummaryFfi, SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] force_sync ←");
|
||||
let engine = get_engine()?;
|
||||
engine
|
||||
.force_sync()
|
||||
.await
|
||||
.map(|s| {
|
||||
tracing::debug!(
|
||||
"[FFI] force_sync → uploaded={}, downloaded={}, conflicts={}, failed={}",
|
||||
s.uploaded,
|
||||
s.downloaded,
|
||||
s.conflicts,
|
||||
s.failed
|
||||
);
|
||||
summary_to_ffi(s)
|
||||
})
|
||||
.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
/// 重置同步:停止任务 → 清空 DB → (可选)清空本地目录 → 回到初始状态
|
||||
#[frb]
|
||||
pub async fn reset_sync(delete_local_files: bool) -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!(
|
||||
"[FFI] reset_sync ← delete_local_files={}",
|
||||
delete_local_files
|
||||
);
|
||||
let engine = get_engine()?;
|
||||
engine
|
||||
.reset_sync(delete_local_files)
|
||||
.await
|
||||
.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
// ========== 状态查询 ==========
|
||||
|
||||
/// 获取同步状态快照
|
||||
#[frb]
|
||||
pub async fn get_sync_status() -> Result<SyncStatusFfi, SyncErrorFfi> {
|
||||
let engine = get_engine()?;
|
||||
let s = engine.status().await;
|
||||
tracing::trace!(
|
||||
"[FFI] get_sync_status → state={:?}, synced={}, total={}",
|
||||
s.state,
|
||||
s.synced_files,
|
||||
s.total_files
|
||||
);
|
||||
Ok(status_to_ffi(s))
|
||||
}
|
||||
|
||||
/// 获取活跃 Worker 数量
|
||||
#[frb]
|
||||
pub async fn get_active_worker_count() -> Result<u32, SyncErrorFfi> {
|
||||
let engine = get_engine()?;
|
||||
let count = engine.active_worker_count();
|
||||
tracing::trace!("[FFI] get_active_worker_count → {}", count);
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
/// 获取同步配置
|
||||
#[frb]
|
||||
pub async fn get_sync_config() -> Result<SyncConfigFfi, SyncErrorFfi> {
|
||||
let engine = get_engine()?;
|
||||
let c = engine.config().await;
|
||||
tracing::trace!(
|
||||
"[FFI] get_sync_config → mode={:?}, conflict={:?}",
|
||||
c.sync_mode,
|
||||
c.conflict_strategy
|
||||
);
|
||||
Ok(config_to_ffi(&c))
|
||||
}
|
||||
|
||||
/// 更新同步配置
|
||||
#[frb]
|
||||
pub async fn update_sync_config(config: SyncConfigFfi) -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] update_sync_config ← mode={}, conflict={}, wcf_delete={}, concurrent={}, bandwidth={}kbps",
|
||||
config.sync_mode, config.conflict_strategy, config.wcf_delete_mode, config.max_concurrent_transfers, config.bandwidth_limit_kbps);
|
||||
let engine = get_engine()?;
|
||||
engine
|
||||
.update_config(config_from_ffi(config))
|
||||
.await
|
||||
.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
// ========== Token 管理 ==========
|
||||
|
||||
/// Dart 推送新 Token 给 Rust
|
||||
#[frb]
|
||||
pub async fn update_tokens(access_token: String) -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] update_tokens ← token_len={}", access_token.len());
|
||||
let engine = get_engine()?;
|
||||
engine.update_access_token(access_token).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== Windows 专用 ==========
|
||||
|
||||
/// 水合文件(Windows 按需下载)
|
||||
#[frb]
|
||||
pub async fn hydrate_file(local_path: String) -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] hydrate_file ← path={}", local_path);
|
||||
let engine = get_engine()?;
|
||||
engine.hydrate_file(&local_path).await.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
// ========== Android 专用 ==========
|
||||
|
||||
/// 同步相册到云端
|
||||
#[frb]
|
||||
pub async fn sync_album_to_cloud(
|
||||
album_paths: Vec<String>,
|
||||
remote_dcim_uri: String,
|
||||
) -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!(
|
||||
"[FFI] sync_album_to_cloud ← paths={}, uri={}",
|
||||
album_paths.len(),
|
||||
remote_dcim_uri
|
||||
);
|
||||
let engine = get_engine()?;
|
||||
engine
|
||||
.sync_album(album_paths, &remote_dcim_uri)
|
||||
.await
|
||||
.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
/// 检查云端是否存在 DCIM/Pictures 目录
|
||||
#[frb]
|
||||
pub async fn check_cloud_album_dirs(
|
||||
base_uri: String,
|
||||
) -> Result<CloudAlbumCheckResultFfi, SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] check_cloud_album_dirs ← uri={}", base_uri);
|
||||
let engine = get_engine()?;
|
||||
engine
|
||||
.check_album_dirs(&base_uri)
|
||||
.await
|
||||
.map(|r| {
|
||||
tracing::debug!(
|
||||
"[FFI] check_cloud_album_dirs → dcim={}, pictures={}",
|
||||
r.dcim_exists,
|
||||
r.pictures_exists
|
||||
);
|
||||
album_result_to_ffi(r)
|
||||
})
|
||||
.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
/// 在云端创建 DCIM/Pictures 目录
|
||||
#[frb]
|
||||
pub async fn create_cloud_album_dirs(base_uri: String) -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] create_cloud_album_dirs ← uri={}", base_uri);
|
||||
let engine = get_engine()?;
|
||||
engine
|
||||
.create_album_dirs(&base_uri)
|
||||
.await
|
||||
.map_err(error_to_ffi)
|
||||
}
|
||||
|
||||
// ========== 事件推送 ==========
|
||||
|
||||
/// 注册 Rust→Dart 事件推送通道
|
||||
#[frb]
|
||||
pub fn register_sync_event_sink(
|
||||
sink: crate::frb_generated::StreamSink<SyncEventFfi>,
|
||||
) -> Result<(), SyncErrorFfi> {
|
||||
tracing::debug!("[FFI] register_sync_event_sink ←");
|
||||
let engine = get_engine()?;
|
||||
// flutter_rust_bridge 可能在非 Tokio 线程调用此同步函数,
|
||||
// 使用 spawn_blocking + block_on 确保 runtime 上下文可用
|
||||
let rt = tokio::runtime::Runtime::new().map_err(|e| SyncErrorFfi::InternalError {
|
||||
message: format!("创建 Tokio runtime 失败: {}", e),
|
||||
})?;
|
||||
rt.block_on(engine.register_event_sink(sink));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== 日志级别 ==========
|
||||
|
||||
/// 运行时热修改日志级别(立即生效,无需重启)
|
||||
#[frb]
|
||||
pub fn set_sync_log_level(level: String) -> Result<(), SyncErrorFfi> {
|
||||
eprintln!("[FFI] set_sync_log_level ← level={}", level);
|
||||
let valid_levels = ["error", "warn", "info", "debug", "trace"];
|
||||
let level_lower = level.to_lowercase();
|
||||
if !valid_levels.contains(&level_lower.as_str()) {
|
||||
return Err(SyncErrorFfi::InternalError {
|
||||
message: format!("无效的日志级别: {}, 可选: {:?}", level, valid_levels),
|
||||
});
|
||||
}
|
||||
|
||||
if LOG_RELOAD_HANDLE.get().is_none() {
|
||||
return Err(SyncErrorFfi::NotInitialized);
|
||||
}
|
||||
|
||||
apply_log_level(&level_lower);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ========== 任务查询 ==========
|
||||
|
||||
/// 获取活跃的同步任务列表
|
||||
#[frb]
|
||||
pub async fn get_active_tasks() -> Result<Vec<SyncTaskFfi>, SyncErrorFfi> {
|
||||
let engine = get_engine()?;
|
||||
let tasks = engine.get_active_tasks().await.map_err(error_to_ffi)?;
|
||||
tracing::trace!("[FFI] get_active_tasks → count={}", tasks.len());
|
||||
Ok(tasks.into_iter().map(task_to_ffi).collect())
|
||||
}
|
||||
|
||||
/// 获取最近同步任务列表
|
||||
#[frb]
|
||||
pub async fn get_recent_tasks(limit: u32) -> Result<Vec<SyncTaskFfi>, SyncErrorFfi> {
|
||||
tracing::trace!("[FFI] get_recent_tasks ← limit={}", limit);
|
||||
let engine = get_engine()?;
|
||||
let tasks = engine.get_recent_tasks(limit).await.map_err(error_to_ffi)?;
|
||||
tracing::trace!("[FFI] get_recent_tasks → count={}", tasks.len());
|
||||
Ok(tasks.into_iter().map(task_to_ffi).collect())
|
||||
}
|
||||
|
||||
/// 获取任务详情(任务项列表)
|
||||
#[frb]
|
||||
pub async fn get_task_detail(task_id: String) -> Result<Vec<SyncTaskItemFfi>, SyncErrorFfi> {
|
||||
tracing::trace!("[FFI] get_task_detail ← task_id={}", task_id);
|
||||
let engine = get_engine()?;
|
||||
let items = engine
|
||||
.get_task_detail(&task_id)
|
||||
.await
|
||||
.map_err(error_to_ffi)?;
|
||||
tracing::trace!("[FFI] get_task_detail → count={}", items.len());
|
||||
Ok(items.into_iter().map(task_item_to_ffi).collect())
|
||||
}
|
||||
|
||||
/// 多维度查询任务项
|
||||
#[frb]
|
||||
pub async fn query_task_items(
|
||||
filter: TaskItemFilterFfi,
|
||||
) -> Result<Vec<SyncTaskItemFfi>, SyncErrorFfi> {
|
||||
tracing::trace!(
|
||||
"[FFI] query_task_items ← task_id={:?}, action={:?}, status={:?}, limit={}",
|
||||
filter.task_id,
|
||||
filter.action_type,
|
||||
filter.status,
|
||||
filter.limit
|
||||
);
|
||||
let engine = get_engine()?;
|
||||
let model_filter = crate::models::TaskItemFilter {
|
||||
task_id: filter.task_id,
|
||||
relative_path_contains: filter.relative_path_contains,
|
||||
action_type: filter.action_type,
|
||||
status: filter.status,
|
||||
limit: filter.limit.max(1).min(1000),
|
||||
offset: filter.offset,
|
||||
};
|
||||
let items = engine
|
||||
.query_task_items(&model_filter)
|
||||
.await
|
||||
.map_err(error_to_ffi)?;
|
||||
tracing::trace!("[FFI] query_task_items → count={}", items.len());
|
||||
Ok(items.into_iter().map(task_item_to_ffi).collect())
|
||||
}
|
||||
|
||||
/// 获取累积统计(从 DB 聚合,跨所有同步任务)
|
||||
#[frb]
|
||||
pub async fn get_sync_cum_stats() -> Result<SyncCumStatsFfi, SyncErrorFfi> {
|
||||
tracing::trace!("[FFI] get_sync_cum_stats ←");
|
||||
let engine = get_engine()?;
|
||||
let stats = engine.get_cum_stats().await.map_err(error_to_ffi)?;
|
||||
tracing::trace!("[FFI] get_sync_cum_stats → uploaded={}, downloaded={}, renamed={}, moved={}, failed={}, conflicts={}, deleted_local={}, deleted_remote={}, skipped={}",
|
||||
stats.uploaded, stats.downloaded, stats.renamed, stats.moved, stats.failed, stats.conflicts, stats.deleted_local, stats.deleted_remote, stats.skipped);
|
||||
Ok(SyncCumStatsFfi {
|
||||
uploaded: stats.uploaded,
|
||||
downloaded: stats.downloaded,
|
||||
renamed: stats.renamed,
|
||||
moved: stats.moved,
|
||||
failed: stats.failed,
|
||||
conflicts: stats.conflicts,
|
||||
deleted_local: stats.deleted_local,
|
||||
deleted_remote: stats.deleted_remote,
|
||||
skipped: stats.skipped,
|
||||
})
|
||||
}
|
||||
|
||||
fn task_to_ffi(t: crate::models::SyncTask) -> SyncTaskFfi {
|
||||
SyncTaskFfi {
|
||||
id: t.id,
|
||||
trigger: t.trigger.as_str().to_string(),
|
||||
total_count: t.total_count,
|
||||
completed_count: t.completed_count,
|
||||
failed_count: t.failed_count,
|
||||
status: t.status.as_str().to_string(),
|
||||
created_at: t.created_at,
|
||||
updated_at: t.updated_at,
|
||||
finished_at: t.finished_at,
|
||||
}
|
||||
}
|
||||
|
||||
fn task_item_to_ffi(i: crate::models::SyncTaskItem) -> SyncTaskItemFfi {
|
||||
SyncTaskItemFfi {
|
||||
id: i.id,
|
||||
task_id: i.task_id,
|
||||
relative_path: i.relative_path,
|
||||
action_type: i.action_type.as_str().to_string(),
|
||||
status: i.status.as_str().to_string(),
|
||||
file_size: i.file_size,
|
||||
error_message: i.error_message,
|
||||
created_at: i.created_at,
|
||||
updated_at: i.updated_at,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/// FFI 错误类型
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncErrorFfi {
|
||||
NotInitialized,
|
||||
NetworkError { message: String },
|
||||
DiskFull { needed: u64, available: u64 },
|
||||
AuthError { message: String },
|
||||
ConflictError { count: u32 },
|
||||
InternalError { message: String },
|
||||
}
|
||||
|
||||
/// 同步配置
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncConfigFfi {
|
||||
pub base_url: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub local_root: String,
|
||||
pub remote_root: String,
|
||||
pub sync_mode: String,
|
||||
pub conflict_strategy: String,
|
||||
pub wcf_delete_mode: String,
|
||||
pub max_concurrent_transfers: u32,
|
||||
pub bandwidth_limit_kbps: u64,
|
||||
pub excluded_paths: Vec<String>,
|
||||
pub max_workers: u32,
|
||||
pub data_dir: String,
|
||||
pub client_id: String,
|
||||
pub log_level: String,
|
||||
}
|
||||
|
||||
/// 同步状态快照
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncStatusFfi {
|
||||
pub state: String,
|
||||
pub synced_files: u64,
|
||||
pub total_files: u64,
|
||||
pub uploading_count: u32,
|
||||
pub downloading_count: u32,
|
||||
pub conflict_count: u32,
|
||||
pub error_count: u32,
|
||||
pub last_sync_time: Option<String>,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
|
||||
/// 初始同步摘要
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncSummaryFfi {
|
||||
pub uploaded: u32,
|
||||
pub downloaded: u32,
|
||||
pub renamed: u32,
|
||||
pub moved: u32,
|
||||
pub conflicts: u32,
|
||||
pub failed: u32,
|
||||
pub skipped: u32,
|
||||
pub deleted_local: u32,
|
||||
pub deleted_remote: u32,
|
||||
pub duration_ms: u64,
|
||||
}
|
||||
|
||||
/// 同步事件(Rust → Dart 推送)
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum SyncEventFfi {
|
||||
StateChanged {
|
||||
new_state: String,
|
||||
},
|
||||
Progress {
|
||||
synced: u64,
|
||||
total: u64,
|
||||
current_file: String,
|
||||
},
|
||||
FileUploaded {
|
||||
local_path: String,
|
||||
remote_uri: String,
|
||||
},
|
||||
FileDownloaded {
|
||||
local_path: String,
|
||||
remote_uri: String,
|
||||
},
|
||||
ConflictDetected {
|
||||
local_path: String,
|
||||
conflict_type: String,
|
||||
},
|
||||
Error {
|
||||
message: String,
|
||||
recoverable: bool,
|
||||
},
|
||||
TokenExpired,
|
||||
DiskSpaceWarning {
|
||||
available_mb: u64,
|
||||
},
|
||||
InitialSyncComplete {
|
||||
summary: SyncSummaryFfi,
|
||||
},
|
||||
|
||||
// Worker 事件
|
||||
WorkerStarted {
|
||||
task_id: String,
|
||||
trigger: String,
|
||||
upload_count: u32,
|
||||
download_count: u32,
|
||||
},
|
||||
WorkerCompleted {
|
||||
task_id: String,
|
||||
uploaded: u32,
|
||||
downloaded: u32,
|
||||
renamed: u32,
|
||||
moved: u32,
|
||||
failed: u32,
|
||||
duration_ms: u64,
|
||||
},
|
||||
WorkerFailed {
|
||||
task_id: String,
|
||||
message: String,
|
||||
},
|
||||
TaskItemUpdated {
|
||||
task_id: String,
|
||||
relative_path: String,
|
||||
action: String,
|
||||
status: String,
|
||||
},
|
||||
}
|
||||
|
||||
/// Android: 云端相册目录检查结果
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CloudAlbumCheckResultFfi {
|
||||
pub dcim_exists: bool,
|
||||
pub pictures_exists: bool,
|
||||
pub dcim_uri: Option<String>,
|
||||
pub pictures_uri: Option<String>,
|
||||
pub camera_exists: bool,
|
||||
pub camera_uri: Option<String>,
|
||||
}
|
||||
|
||||
/// 同步任务摘要(FFI)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncTaskFfi {
|
||||
pub id: String,
|
||||
pub trigger: String,
|
||||
pub total_count: u32,
|
||||
pub completed_count: u32,
|
||||
pub failed_count: u32,
|
||||
pub status: String,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub finished_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 同步任务项(FFI)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncTaskItemFfi {
|
||||
pub id: i64,
|
||||
pub task_id: String,
|
||||
pub relative_path: String,
|
||||
pub action_type: String,
|
||||
pub status: String,
|
||||
pub file_size: u64,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 累积统计(FFI)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncCumStatsFfi {
|
||||
pub uploaded: u32,
|
||||
pub downloaded: u32,
|
||||
pub renamed: u32,
|
||||
pub moved: u32,
|
||||
pub failed: u32,
|
||||
pub conflicts: u32,
|
||||
pub deleted_local: u32,
|
||||
pub deleted_remote: u32,
|
||||
pub skipped: u32,
|
||||
}
|
||||
|
||||
/// 任务项查询过滤器(FFI)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TaskItemFilterFfi {
|
||||
pub task_id: Option<String>,
|
||||
pub relative_path_contains: Option<String>,
|
||||
pub action_type: Option<String>,
|
||||
pub status: Option<String>,
|
||||
pub limit: u32,
|
||||
pub offset: u32,
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
pub mod ffi;
|
||||
pub mod ffi_types;
|
||||
@@ -0,0 +1,826 @@
|
||||
use crate::errors::{Result, SyncError};
|
||||
use crate::models::*;
|
||||
use crate::server_error_code::api_code_to_error;
|
||||
use reqwest::Client;
|
||||
use serde::Deserialize;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct ApiResponse<T> {
|
||||
code: i32,
|
||||
data: Option<T>,
|
||||
msg: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct RefreshResponse {
|
||||
access_token: String,
|
||||
refresh_token: String,
|
||||
}
|
||||
|
||||
/// Token 刷新状态:确保并发请求只刷新一次
|
||||
struct RefreshState {
|
||||
refreshing: bool,
|
||||
notify: Arc<tokio::sync::Notify>,
|
||||
}
|
||||
|
||||
pub struct ApiClient {
|
||||
base_url: String,
|
||||
access_token: RwLock<String>,
|
||||
refresh_token: RwLock<String>,
|
||||
refresh_state: Arc<Mutex<RefreshState>>,
|
||||
client: Client,
|
||||
/// 流式下载专用 client,不设整体超时,仅限制连接和读取间隔
|
||||
download_client: Client,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(base_url: &str, access_token: &str, refresh_token: &str, client_id: &str) -> Self {
|
||||
let client = Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(15))
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
let download_client = Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.read_timeout(std::time::Duration::from_secs(300))
|
||||
.build()
|
||||
.expect("Failed to create download HTTP client");
|
||||
|
||||
Self {
|
||||
base_url: base_url.trim_end_matches('/').to_string(),
|
||||
access_token: RwLock::new(access_token.to_string()),
|
||||
refresh_token: RwLock::new(refresh_token.to_string()),
|
||||
refresh_state: Arc::new(Mutex::new(RefreshState { refreshing: false, notify: Arc::new(tokio::sync::Notify::new()) })),
|
||||
client,
|
||||
download_client,
|
||||
client_id: client_id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn update_token(&self, token: String) {
|
||||
*self.access_token.write().await = token;
|
||||
}
|
||||
|
||||
pub async fn update_tokens(&self, access: &str, refresh: &str) {
|
||||
*self.access_token.write().await = access.to_string();
|
||||
*self.refresh_token.write().await = refresh.to_string();
|
||||
}
|
||||
|
||||
pub fn base_url(&self) -> &str {
|
||||
&self.base_url
|
||||
}
|
||||
|
||||
pub fn client_id(&self) -> &str {
|
||||
&self.client_id
|
||||
}
|
||||
|
||||
pub async fn token(&self) -> String {
|
||||
self.access_token.read().await.clone()
|
||||
}
|
||||
|
||||
/// 带并发去重的 token 刷新
|
||||
/// 多个任务同时遇到 401 时,只有一个执行刷新,其他等待刷新完成后自动获取新 token
|
||||
pub async fn refresh_access_token(&self) -> Result<()> {
|
||||
let mut state = self.refresh_state.lock().await;
|
||||
|
||||
if state.refreshing {
|
||||
// 已经有另一个任务在刷新了,等待通知
|
||||
let notify = state.notify.clone();
|
||||
drop(state);
|
||||
notify.notified().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
state.refreshing = true;
|
||||
let notify = state.notify.clone();
|
||||
drop(state);
|
||||
|
||||
let result = self.do_refresh().await;
|
||||
|
||||
let mut state = self.refresh_state.lock().await;
|
||||
state.refreshing = false;
|
||||
drop(state);
|
||||
|
||||
// 通知所有等待者刷新已完成
|
||||
notify.notify_waiters();
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
async fn do_refresh(&self) -> Result<()> {
|
||||
let refresh_token = self.refresh_token.read().await.clone();
|
||||
if refresh_token.is_empty() {
|
||||
return Err(SyncError::Auth("无 refresh_token,无法刷新".into()));
|
||||
}
|
||||
|
||||
tracing::info!("正在刷新 access_token...");
|
||||
|
||||
let resp = self.client
|
||||
.post(format!("{}/session/token/refresh", self.base_url))
|
||||
.json(&serde_json::json!({
|
||||
"refresh_token": refresh_token,
|
||||
}))
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SyncError::Auth(format!("Token 刷新失败: HTTP {}", resp.status())));
|
||||
}
|
||||
|
||||
let api_resp: ApiResponse<RefreshResponse> = resp.json().await?;
|
||||
if api_resp.code != 0 {
|
||||
return Err(SyncError::Auth(format!(
|
||||
"Token 刷新失败: {}",
|
||||
api_resp.msg.unwrap_or_else(|| "未知错误".into())
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(data) = api_resp.data {
|
||||
*self.access_token.write().await = data.access_token;
|
||||
*self.refresh_token.write().await = data.refresh_token;
|
||||
tracing::info!("access_token 刷新成功");
|
||||
Ok(())
|
||||
} else {
|
||||
Err(SyncError::Auth("Token 刷新响应缺少数据".into()))
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 API 响应
|
||||
async fn parse_response(&self, resp: reqwest::Response) -> Result<serde_json::Value> {
|
||||
if !resp.status().is_success() {
|
||||
return Err(SyncError::Network(format!("HTTP {}", resp.status())));
|
||||
}
|
||||
let api_resp: ApiResponse<serde_json::Value> = resp.json().await?;
|
||||
if api_resp.code == 0 {
|
||||
return Ok(api_resp.data.unwrap_or_default());
|
||||
}
|
||||
|
||||
// 40073 锁冲突需要特殊处理 data
|
||||
if api_resp.code == 40073 {
|
||||
let items = api_resp.data
|
||||
.and_then(|d| d.as_array().cloned())
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter_map(|item| {
|
||||
let path = item.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let token = item.get("token").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
if !token.is_empty() {
|
||||
Some(crate::errors::LockConflictItem { path, token })
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
return Err(SyncError::LockConflict { tokens: items });
|
||||
}
|
||||
|
||||
let msg = api_resp.msg
|
||||
.filter(|m| !m.is_empty())
|
||||
.unwrap_or_default();
|
||||
Err(api_code_to_error(api_resp.code, &msg))
|
||||
}
|
||||
|
||||
/// 发送带认证的请求,自动处理 401(刷新 token 后重试一次)
|
||||
/// request_builder 接收当前 token,返回 RequestBuilder
|
||||
/// 所有请求自动附加 X-Cr-Client-Id header,服务端据此过滤 SSE 自身事件
|
||||
async fn send_with_auth_retry(
|
||||
&self,
|
||||
request_builder: impl Fn(String) -> reqwest::RequestBuilder,
|
||||
) -> Result<serde_json::Value> {
|
||||
let client_id = self.client_id.clone();
|
||||
|
||||
// 第一次尝试
|
||||
let token = self.token().await;
|
||||
let resp = match request_builder(token)
|
||||
.header("X-Cr-Client-Id", &client_id)
|
||||
.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;
|
||||
|
||||
if let Err(SyncError::Auth(_)) = result {
|
||||
// 刷新 token
|
||||
self.refresh_access_token().await?;
|
||||
// 用新 token 重试
|
||||
let new_token = self.token().await;
|
||||
let resp = match request_builder(new_token)
|
||||
.header("X-Cr-Client-Id", &client_id)
|
||||
.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;
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
// ===== 文件列表 =====
|
||||
|
||||
/// 递归列出指定 URI 下的所有文件和目录
|
||||
pub async fn list_all_files(&self, uri: &str) -> Result<Vec<RemoteFileEntry>> {
|
||||
let mut all_files = Vec::new();
|
||||
self.list_all_files_recursive(uri, &mut all_files).await?;
|
||||
Ok(all_files)
|
||||
}
|
||||
|
||||
fn list_all_files_recursive<'a>(
|
||||
&'a self,
|
||||
uri: &'a str,
|
||||
result: &'a mut Vec<RemoteFileEntry>,
|
||||
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> {
|
||||
Box::pin(async move {
|
||||
if result.len() > 1_000_000 {
|
||||
tracing::warn!("文件数量超过 100 万,截断初始同步");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut next_page_token: Option<String> = None;
|
||||
let mut page = 0u32;
|
||||
let page_size = 2000u32;
|
||||
let mut dirs_to_recurse = Vec::new();
|
||||
|
||||
loop {
|
||||
let resp = self.list_files_page(uri, page, page_size, next_page_token.as_deref()).await?;
|
||||
let count = resp.files.len();
|
||||
|
||||
for file in resp.files {
|
||||
if file.is_dir {
|
||||
dirs_to_recurse.push(file.uri.clone());
|
||||
}
|
||||
result.push(file);
|
||||
}
|
||||
|
||||
if let Some(token) = resp.pagination.next_page_token {
|
||||
next_page_token = Some(token);
|
||||
} else if count < page_size as usize {
|
||||
break;
|
||||
} else {
|
||||
page += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for dir_uri in dirs_to_recurse {
|
||||
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(())
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn list_files_page(
|
||||
&self,
|
||||
uri: &str,
|
||||
page: u32,
|
||||
page_size: u32,
|
||||
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> {
|
||||
let data = self.send_with_auth_retry(|token| {
|
||||
let mut req = self.client
|
||||
.get(format!("{}/file", self.base_url))
|
||||
.bearer_auth(&token)
|
||||
.query(&[
|
||||
("uri", uri),
|
||||
("page", &page.to_string()),
|
||||
("page_size", &page_size.to_string()),
|
||||
]);
|
||||
if let Some(npt) = next_page_token {
|
||||
req = req.query(&[("next_page_token", npt)]);
|
||||
}
|
||||
req
|
||||
}).await?;
|
||||
|
||||
let parent_uri = uri.to_string();
|
||||
let files: Vec<RemoteFileEntry> = if let Some(items) = data.get("files").and_then(|f| f.as_array()) {
|
||||
items.iter().filter_map(|obj| {
|
||||
let name_raw = obj.get("name")?.as_str()?.to_string();
|
||||
let name = percent_decode_str(&name_raw);
|
||||
let path_raw = obj.get("path").and_then(|v| v.as_str()).unwrap_or("").to_string();
|
||||
let path = percent_decode_str(&path_raw);
|
||||
let entry_uri = if path_raw.is_empty() {
|
||||
format!("{}/{}", parent_uri, name_raw)
|
||||
} else {
|
||||
path_raw.clone()
|
||||
};
|
||||
let size = obj.get("size").and_then(|s| s.as_u64()).unwrap_or(0);
|
||||
let file_type = obj.get("type").and_then(|t| t.as_u64()).unwrap_or(0);
|
||||
let is_dir = file_type == 1;
|
||||
let file_id = obj.get("id").and_then(|s| s.as_str()).map(String::from);
|
||||
let created_at = obj.get("created_at").and_then(|t| t.as_str()).unwrap_or("");
|
||||
let updated_at = obj.get("updated_at").and_then(|t| t.as_str()).unwrap_or("");
|
||||
|
||||
Some(RemoteFileEntry {
|
||||
uri: entry_uri,
|
||||
name,
|
||||
size,
|
||||
mtime_ms: parse_timestamp(updated_at),
|
||||
hash: None,
|
||||
is_dir,
|
||||
file_id,
|
||||
path,
|
||||
created_at_ms: parse_timestamp(created_at),
|
||||
})
|
||||
}).collect()
|
||||
} else {
|
||||
tracing::warn!("API 响应中未找到 files 数组, data keys: {:?}", data.as_object().map(|m| m.keys().collect::<Vec<_>>()));
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let pagination = data.get("pagination").cloned().unwrap_or_default();
|
||||
let next_token = pagination.get("next_page_token")
|
||||
.and_then(|t| t.as_str())
|
||||
.map(String::from);
|
||||
let is_cursor = pagination.get("is_cursor")
|
||||
.and_then(|c| c.as_bool())
|
||||
.unwrap_or(false);
|
||||
let total = pagination.get("total_items")
|
||||
.and_then(|t| t.as_u64())
|
||||
.or_else(|| pagination.get("total").and_then(|t| t.as_u64()));
|
||||
|
||||
Ok(ListFilesResponse {
|
||||
files,
|
||||
pagination: Pagination {
|
||||
next_page_token: next_token,
|
||||
is_cursor,
|
||||
total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 上传 =====
|
||||
|
||||
pub async fn create_upload_session(
|
||||
&self,
|
||||
uri: &str,
|
||||
size: u64,
|
||||
overwrite: bool,
|
||||
last_modified: Option<i64>,
|
||||
mime_type: Option<&str>,
|
||||
policy_id: Option<&str>,
|
||||
) -> Result<UploadSession> {
|
||||
let mut body = serde_json::json!({
|
||||
"uri": uri,
|
||||
"size": size,
|
||||
});
|
||||
if overwrite {
|
||||
body["entity_type"] = serde_json::Value::String("version".to_string());
|
||||
}
|
||||
if let Some(mtime) = last_modified {
|
||||
body["last_modified"] = serde_json::Value::Number(mtime.into());
|
||||
}
|
||||
if let Some(mime) = mime_type {
|
||||
body["mime_type"] = serde_json::Value::String(mime.to_string());
|
||||
}
|
||||
if let Some(pid) = policy_id {
|
||||
body["policy_id"] = serde_json::Value::String(pid.to_string());
|
||||
}
|
||||
|
||||
let data = self.send_with_auth_retry(|token| {
|
||||
self.client
|
||||
.put(format!("{}/file/upload", self.base_url))
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
}).await?;
|
||||
|
||||
let session_id = data.get("session_id")
|
||||
.and_then(|s| s.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let chunk_size = data.get("chunk_size")
|
||||
.and_then(|c| c.as_u64())
|
||||
.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 {
|
||||
session_id,
|
||||
chunk_size,
|
||||
upload_urls,
|
||||
storage_policy_type,
|
||||
callback_secret,
|
||||
file_name,
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
session_id: &str,
|
||||
index: u32,
|
||||
data: &[u8],
|
||||
) -> Result<()> {
|
||||
let chunk_data = data.to_vec();
|
||||
let content_len = data.len().to_string();
|
||||
self.send_with_auth_retry(|token| {
|
||||
self.client
|
||||
.post(format!(
|
||||
"{}/file/upload/{}/{}",
|
||||
self.base_url, session_id, index
|
||||
))
|
||||
.bearer_auth(&token)
|
||||
.header("Content-Length", &content_len)
|
||||
.body(chunk_data.clone())
|
||||
}).await?;
|
||||
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>> {
|
||||
let body = serde_json::json!({
|
||||
"uris": uris,
|
||||
"download": false,
|
||||
});
|
||||
|
||||
let data = self.send_with_auth_retry(|token| {
|
||||
self.client
|
||||
.post(format!("{}/file/url", self.base_url))
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
}).await?;
|
||||
|
||||
let urls = data.get("urls")
|
||||
.and_then(|u| u.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|item| item.get("url")?.as_str().map(String::from))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(urls)
|
||||
}
|
||||
|
||||
pub async fn stream_download(
|
||||
&self,
|
||||
url: &str,
|
||||
offset: u64,
|
||||
) -> Result<reqwest::Response> {
|
||||
let mut req = self.download_client.get(url);
|
||||
if offset > 0 {
|
||||
req = req.header("Range", format!("bytes={}-", offset));
|
||||
}
|
||||
let resp = req.send().await?;
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
// ===== 创建文件/目录 =====
|
||||
|
||||
pub async fn create_directory(&self, parent_uri: &str, name: &str) -> Result<RemoteFileEntry> {
|
||||
let uri = format!("{}/{}", parent_uri, name);
|
||||
let body = serde_json::json!({
|
||||
"uri": uri,
|
||||
"type": "folder",
|
||||
});
|
||||
|
||||
let _data = self.send_with_auth_retry(|token| {
|
||||
self.client
|
||||
.post(format!("{}/file/create", self.base_url))
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
}).await?;
|
||||
|
||||
Ok(RemoteFileEntry {
|
||||
uri,
|
||||
name: name.to_string(),
|
||||
size: 0,
|
||||
mtime_ms: 0,
|
||||
hash: None,
|
||||
is_dir: true,
|
||||
file_id: None,
|
||||
path: String::new(),
|
||||
created_at_ms: 0,
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 删除 =====
|
||||
|
||||
pub async fn delete_files(&self, uris: &[&str]) -> Result<()> {
|
||||
let body = serde_json::json!({
|
||||
"uris": uris,
|
||||
});
|
||||
|
||||
let result = self.send_with_auth_retry(|token| {
|
||||
let client = &self.client;
|
||||
let base_url = &self.base_url;
|
||||
let body = body.clone();
|
||||
client
|
||||
.delete(format!("{}/file", base_url))
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
}).await;
|
||||
|
||||
match result {
|
||||
Ok(_) => Ok(()),
|
||||
Err(SyncError::LockConflict { tokens }) => {
|
||||
for item in &tokens {
|
||||
tracing::warn!(
|
||||
"删除异常: code(40073), 进行解锁, token: {}, path: {}",
|
||||
item.token, item.path
|
||||
);
|
||||
}
|
||||
self.force_unlock_files(&tokens).await?;
|
||||
|
||||
// 解锁后重试删除
|
||||
self.send_with_auth_retry(|token| {
|
||||
let client = &self.client;
|
||||
let base_url = &self.base_url;
|
||||
let body = body.clone();
|
||||
client
|
||||
.delete(format!("{}/file", base_url))
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
}).await?;
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// 强制解锁文件 — DELETE /file/lock
|
||||
pub async fn force_unlock_files(&self, items: &[crate::errors::LockConflictItem]) -> Result<()> {
|
||||
let tokens: Vec<&str> = items.iter().map(|i| i.token.as_str()).collect();
|
||||
if tokens.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let body = serde_json::json!({
|
||||
"tokens": tokens,
|
||||
});
|
||||
|
||||
self.send_with_auth_retry(|token| {
|
||||
let client = &self.client;
|
||||
let base_url = &self.base_url;
|
||||
let body = body.clone();
|
||||
client
|
||||
.delete(format!("{}/file/lock", base_url))
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
}).await?;
|
||||
|
||||
tracing::info!("强制解锁完成: {} 个文件", items.len());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ===== 移动 =====
|
||||
|
||||
pub async fn move_files(&self, src_uris: &[&str], dst_uri: &str, copy: bool) -> Result<()> {
|
||||
let body = serde_json::json!({
|
||||
"uris": src_uris,
|
||||
"dst": dst_uri,
|
||||
"copy": copy,
|
||||
});
|
||||
|
||||
self.send_with_auth_retry(|token| {
|
||||
self.client
|
||||
.post(format!("{}/file/move", self.base_url))
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
}).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ===== 重命名 =====
|
||||
|
||||
pub async fn rename_file(&self, uri: &str, new_name: &str) -> Result<()> {
|
||||
let body = serde_json::json!({
|
||||
"uri": uri,
|
||||
"new_name": new_name,
|
||||
});
|
||||
|
||||
self.send_with_auth_retry(|token| {
|
||||
self.client
|
||||
.post(format!("{}/file/rename", self.base_url))
|
||||
.bearer_auth(&token)
|
||||
.json(&body)
|
||||
}).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ===== 获取文件信息 =====
|
||||
|
||||
pub async fn get_file_info(&self, uri: &str) -> Result<RemoteFileEntry> {
|
||||
let data = self.send_with_auth_retry(|token| {
|
||||
self.client
|
||||
.get(format!("{}/file/info", self.base_url))
|
||||
.bearer_auth(&token)
|
||||
.query(&[("uri", uri)])
|
||||
}).await?;
|
||||
|
||||
Ok(RemoteFileEntry {
|
||||
uri: data.get("uri").and_then(|u| u.as_str()).unwrap_or(uri).to_string(),
|
||||
name: percent_decode_str(data.get("name").and_then(|n| n.as_str()).unwrap_or("")),
|
||||
size: data.get("size").and_then(|s| s.as_u64()).unwrap_or(0),
|
||||
mtime_ms: data.get("updated_at").and_then(|t| t.as_str()).map(parse_timestamp).unwrap_or(0),
|
||||
hash: None,
|
||||
is_dir: data.get("type").and_then(|t| t.as_u64()).unwrap_or(0) == 1,
|
||||
file_id: data.get("id").and_then(|i| i.as_str()).map(String::from),
|
||||
path: percent_decode_str(data.get("path").and_then(|p| p.as_str()).unwrap_or("")),
|
||||
created_at_ms: data.get("created_at").and_then(|t| t.as_str()).map(parse_timestamp).unwrap_or(0),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// 解析 Cloudreve 时间戳 (ISO 8601 或 Unix ms)
|
||||
fn parse_timestamp(s: &str) -> i64 {
|
||||
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
|
||||
return dt.timestamp_millis();
|
||||
}
|
||||
if let Ok(ts) = s.parse::<i64>() {
|
||||
if ts < 10_000_000_000 {
|
||||
return ts * 1000;
|
||||
}
|
||||
return ts;
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
fn percent_decode_str(s: &str) -> String {
|
||||
urlencoding::decode(s).unwrap_or_else(|_| s.into()).to_string()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
use crate::models::*;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ConflictResolver {
|
||||
pub default_strategy: ConflictStrategy,
|
||||
}
|
||||
|
||||
impl ConflictResolver {
|
||||
pub fn new(strategy: ConflictStrategy) -> Self {
|
||||
Self {
|
||||
default_strategy: strategy,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn resolve(
|
||||
&self,
|
||||
conflict_type: ConflictType,
|
||||
local_mtime: i64,
|
||||
remote_mtime: i64,
|
||||
local_size: u64,
|
||||
remote_size: u64,
|
||||
local_name: &str,
|
||||
) -> ConflictResolution {
|
||||
self.resolve_with_strategy(
|
||||
&self.default_strategy,
|
||||
conflict_type,
|
||||
local_mtime,
|
||||
remote_mtime,
|
||||
local_size,
|
||||
remote_size,
|
||||
local_name,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn resolve_with_strategy(
|
||||
&self,
|
||||
strategy: &ConflictStrategy,
|
||||
_conflict_type: ConflictType,
|
||||
local_mtime: i64,
|
||||
remote_mtime: i64,
|
||||
local_size: u64,
|
||||
remote_size: u64,
|
||||
local_name: &str,
|
||||
) -> ConflictResolution {
|
||||
match strategy {
|
||||
ConflictStrategy::KeepLocal => ConflictResolution::UploadLocal,
|
||||
ConflictStrategy::KeepRemote => ConflictResolution::DownloadRemote,
|
||||
ConflictStrategy::KeepBoth => {
|
||||
let new_name = crate::utils::generate_conflict_name(local_name);
|
||||
ConflictResolution::RenameLocal { new_name }
|
||||
}
|
||||
ConflictStrategy::NewestWins => {
|
||||
if local_mtime > remote_mtime {
|
||||
ConflictResolution::UploadLocal
|
||||
} else {
|
||||
ConflictResolution::DownloadRemote
|
||||
}
|
||||
}
|
||||
ConflictStrategy::LargestWins => {
|
||||
if local_size > remote_size {
|
||||
ConflictResolution::UploadLocal
|
||||
} else {
|
||||
ConflictResolution::DownloadRemote
|
||||
}
|
||||
}
|
||||
ConflictStrategy::Manual => ConflictResolution::MarkManual,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
use crate::models::*;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
/// 三路差异计算: 本地 vs 远程 vs 数据库,按同步模式过滤
|
||||
pub fn compute_diff(
|
||||
local_files: &[LocalFileEntry],
|
||||
remote_files: &[RemoteFileEntry],
|
||||
db_mappings: &HashMap<String, FileMapping>,
|
||||
remote_root: &str,
|
||||
sync_mode: &SyncMode,
|
||||
) -> SyncPlan {
|
||||
let mut plan = SyncPlan::default();
|
||||
|
||||
// 构建索引: relative_path → entry(统一正斜杠)
|
||||
let local_map: HashMap<String, &LocalFileEntry> = local_files
|
||||
.iter()
|
||||
.map(|e| {
|
||||
(
|
||||
crate::utils::normalize_path(&e.relative_path.to_string_lossy()),
|
||||
e,
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let remote_map: HashMap<String, &RemoteFileEntry> = remote_files
|
||||
.iter()
|
||||
.map(|e| {
|
||||
let rel = remote_relative_path(remote_root, &e.path, &e.name, e.is_dir);
|
||||
(rel, e)
|
||||
})
|
||||
.collect();
|
||||
|
||||
// 收集所有路径
|
||||
let mut all_paths: HashSet<String> = HashSet::new();
|
||||
for k in local_map.keys() {
|
||||
all_paths.insert(k.clone());
|
||||
}
|
||||
for k in remote_map.keys() {
|
||||
all_paths.insert(k.clone());
|
||||
}
|
||||
for k in db_mappings.keys() {
|
||||
all_paths.insert(k.clone());
|
||||
}
|
||||
|
||||
for path in &all_paths {
|
||||
let local = local_map.get(path.as_str()).copied();
|
||||
let remote = remote_map.get(path.as_str()).copied();
|
||||
let db = db_mappings.get(path.as_str());
|
||||
|
||||
match (local, remote, db) {
|
||||
// 本地有,远程无 → 上传(UploadOnly、Full、MirrorWcf、AlbumUpload)
|
||||
(Some(l), None, _) => {
|
||||
if matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::AlbumDownload) {
|
||||
continue;
|
||||
}
|
||||
if !l.is_dir && l.size == 0 {
|
||||
continue;
|
||||
}
|
||||
if let Some(db_m) = db {
|
||||
if db_m.sync_status == SyncFileStatus::Synced {
|
||||
plan.uploads.push(SyncAction {
|
||||
relative_path: path.clone(),
|
||||
local_entry: Some((*l).clone()),
|
||||
remote_entry: None,
|
||||
db_mapping: Some(db_m.clone()),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
plan.uploads.push(SyncAction {
|
||||
relative_path: path.clone(),
|
||||
local_entry: Some((*l).clone()),
|
||||
remote_entry: None,
|
||||
db_mapping: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 远程有,本地无 → 下载(DownloadOnly、Full、MirrorWcf、AlbumDownload)
|
||||
(None, Some(r), _) => {
|
||||
if matches!(sync_mode, SyncMode::UploadOnly | SyncMode::AlbumUpload) {
|
||||
continue;
|
||||
}
|
||||
if r.is_dir {
|
||||
plan.mkdirs_local.push(path.clone());
|
||||
} else if r.size == 0 {
|
||||
continue;
|
||||
} else {
|
||||
plan.downloads.push(SyncAction {
|
||||
relative_path: path.clone(),
|
||||
local_entry: None,
|
||||
remote_entry: Some((*r).clone()),
|
||||
db_mapping: db.cloned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 两边都有
|
||||
(Some(l), Some(r), db_m) => {
|
||||
if l.is_dir && r.is_dir {
|
||||
continue;
|
||||
}
|
||||
|
||||
let content_match = match (&l.quick_hash, &r.hash) {
|
||||
(lh, Some(rh)) if !lh.is_empty() && !rh.is_empty() => lh == rh,
|
||||
_ => l.size == r.size,
|
||||
};
|
||||
|
||||
if content_match {
|
||||
// 内容一致,标记已同步
|
||||
} else {
|
||||
let conflict_type = if l.is_dir != r.is_dir {
|
||||
ConflictType::TypeMismatch
|
||||
} else {
|
||||
ConflictType::BothModified
|
||||
};
|
||||
|
||||
plan.conflicts.push(SyncConflict {
|
||||
relative_path: path.clone(),
|
||||
conflict_type,
|
||||
local_entry: Some((*l).clone()),
|
||||
remote_entry: Some((*r).clone()),
|
||||
db_mapping: db_m.cloned(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
(None, None, Some(_)) => {}
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 远程目录结构(UploadOnly、Full、MirrorWcf、AlbumUpload)
|
||||
if !matches!(sync_mode, SyncMode::DownloadOnly | SyncMode::AlbumDownload) {
|
||||
for (path, local) in &local_map {
|
||||
if local.is_dir && !remote_map.contains_key(path.as_str()) {
|
||||
plan.mkdirs_remote.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 按 sync_mode 自动解决冲突
|
||||
resolve_conflicts_by_mode(&mut plan, sync_mode);
|
||||
|
||||
plan
|
||||
}
|
||||
|
||||
/// 根据同步模式自动解决冲突
|
||||
fn resolve_conflicts_by_mode(plan: &mut SyncPlan, sync_mode: &SyncMode) {
|
||||
if plan.conflicts.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let conflicts = std::mem::take(&mut plan.conflicts);
|
||||
for conflict in conflicts {
|
||||
match sync_mode {
|
||||
SyncMode::UploadOnly | SyncMode::MirrorWcf | SyncMode::AlbumUpload => {
|
||||
// 冲突一律覆盖上传(MirrorWcf 本地编辑优先)
|
||||
let action = SyncAction {
|
||||
relative_path: conflict.relative_path.clone(),
|
||||
local_entry: conflict.local_entry.clone(),
|
||||
remote_entry: conflict.remote_entry.clone(),
|
||||
db_mapping: conflict.db_mapping.clone(),
|
||||
};
|
||||
plan.uploads.push(action);
|
||||
}
|
||||
SyncMode::DownloadOnly | SyncMode::AlbumDownload => {
|
||||
// 冲突一律覆盖下载
|
||||
let action = SyncAction {
|
||||
relative_path: conflict.relative_path.clone(),
|
||||
local_entry: conflict.local_entry.clone(),
|
||||
remote_entry: conflict.remote_entry.clone(),
|
||||
db_mapping: conflict.db_mapping.clone(),
|
||||
};
|
||||
plan.downloads.push(action);
|
||||
}
|
||||
_ => {
|
||||
// Full: 保留冲突,由 Worker 用 conflict_strategy 解决
|
||||
plan.conflicts.push(conflict);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 从远程 path 字段提取相对路径
|
||||
pub fn remote_relative_path(remote_root: &str, path: &str, name: &str, is_dir: bool) -> String {
|
||||
let _ = is_dir;
|
||||
// 1. 尝试匹配完整 URI 前缀(remote_root = cloudreve://my/example)
|
||||
if let Some(rel) = path.strip_prefix(remote_root) {
|
||||
return rel.trim_start_matches('/').to_string();
|
||||
}
|
||||
// 2. SSE 的 path 是相对路径(如 /Readest/Books/file.txt),直接 trim 前导 /
|
||||
if path.starts_with('/') {
|
||||
return path.trim_start_matches('/').to_string();
|
||||
}
|
||||
// 3. 回退:使用文件名
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
/// 从字符串解析 SyncFileStatus
|
||||
pub fn parse_sync_status_from_str(s: &str) -> SyncFileStatus {
|
||||
match s {
|
||||
"uploading" => SyncFileStatus::Uploading,
|
||||
"downloading" => SyncFileStatus::Downloading,
|
||||
"conflict" => SyncFileStatus::Conflict,
|
||||
"placeholder" => SyncFileStatus::Placeholder,
|
||||
_ => SyncFileStatus::Synced,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::errors::{Result, SyncError};
|
||||
use crate::file_lock::FileLockRegistry;
|
||||
use crate::models::*;
|
||||
use crate::sync_db::SyncDb;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
/// 下载单个文件(含重试 + 断点续传),受并发信号量控制
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn download_file(
|
||||
task_id: &str,
|
||||
action: &SyncAction,
|
||||
config: &WorkerConfig,
|
||||
api: &ApiClient,
|
||||
db: &SyncDb,
|
||||
file_locks: &FileLockRegistry,
|
||||
semaphore: &Semaphore,
|
||||
root_id: &str,
|
||||
) -> Result<()> {
|
||||
let remote = action.remote_entry.as_ref().ok_or_else(|| {
|
||||
SyncError::Internal("下载操作缺少远程文件信息".into())
|
||||
})?;
|
||||
|
||||
let _lock = file_locks.acquire(&action.relative_path).await;
|
||||
|
||||
if remote.is_dir {
|
||||
let local_path = config.local_root.join(&action.relative_path);
|
||||
tokio::fs::create_dir_all(&local_path).await?;
|
||||
tracing::debug!("[{}] 创建本地目录: {}", task_id, action.relative_path);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let local_path = config.local_root.join(&action.relative_path);
|
||||
|
||||
let _permit = semaphore.acquire().await
|
||||
.map_err(|e| SyncError::Internal(format!("获取传输信号量失败: {}", e)))?;
|
||||
|
||||
// 信号量获取后标记为 Running(实际开始传输)
|
||||
let _ = db
|
||||
.update_task_item_status_by_path(
|
||||
task_id,
|
||||
&action.relative_path,
|
||||
"download",
|
||||
&TaskItemStatus::Running,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
tracing::info!("[{}] 开始下载: {} ({}bytes)", task_id, action.relative_path, remote.size);
|
||||
|
||||
// 确保父目录存在
|
||||
if let Some(parent) = local_path.parent() {
|
||||
if !parent.exists() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
}
|
||||
|
||||
let max_retries = 3u32;
|
||||
let mut attempt = 0u32;
|
||||
let tmp_path = local_path.with_extension(".sync_tmp");
|
||||
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
// 检查临时文件已有大小,用于断点续传
|
||||
let resume_offset = if tmp_path.exists() {
|
||||
tokio::fs::metadata(&tmp_path).await.map(|m| m.len()).unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let urls = match api.get_download_url(&[&remote.uri]).await {
|
||||
Ok(urls) => urls,
|
||||
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
|
||||
Err(e) if attempt <= max_retries => {
|
||||
let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000);
|
||||
tracing::warn!("[{}] 下载重试 ({}/{}): {} 获取链接失败: {}", task_id, attempt, max_retries, action.relative_path, e);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
let download_url = match urls.first() {
|
||||
Some(u) => u.clone(),
|
||||
None => {
|
||||
tracing::error!("[{}] 未获取到下载链接, uri={}", task_id, remote.uri);
|
||||
return Err(SyncError::Network("未获取到下载链接".into()));
|
||||
}
|
||||
};
|
||||
|
||||
let resp = match api.stream_download(&download_url, resume_offset).await {
|
||||
Ok(r) => r,
|
||||
Err(SyncError::Auth(_)) => return Err(SyncError::Auth("Token 过期".into())),
|
||||
Err(e) if attempt <= max_retries => {
|
||||
let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000);
|
||||
tracing::warn!("[{}] 下载重试 ({}/{}): {} 连接失败: {}", task_id, attempt, max_retries, action.relative_path, e);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
|
||||
match stream_to_file(resp, &tmp_path, config.bandwidth_limit, resume_offset).await {
|
||||
Ok(_) => {
|
||||
tracing::debug!("[{}] 下载写入完成: {} ({}bytes)", task_id, tmp_path.display(), remote.size);
|
||||
tokio::fs::rename(&tmp_path, &local_path).await?;
|
||||
|
||||
if remote.mtime_ms > 0 {
|
||||
let mtime = std::time::UNIX_EPOCH + std::time::Duration::from_millis(remote.mtime_ms as u64);
|
||||
let _ = filetime::set_file_mtime(&local_path, filetime::FileTime::from_system_time(mtime));
|
||||
}
|
||||
|
||||
let local_hash = crate::utils::quick_hash(&local_path, remote.size).await.unwrap_or_default();
|
||||
db.upsert_file_mapping(&FileMapping {
|
||||
id: 0,
|
||||
sync_root_id: root_id.to_string(),
|
||||
local_path: std::path::PathBuf::from(&action.relative_path),
|
||||
remote_uri: remote.uri.clone(),
|
||||
remote_file_id: remote.file_id.clone(),
|
||||
local_hash: Some(local_hash.clone()),
|
||||
remote_hash: remote.hash.clone().or(Some(local_hash)),
|
||||
local_mtime: Some(remote.mtime_ms),
|
||||
remote_mtime: Some(remote.mtime_ms),
|
||||
local_size: Some(remote.size),
|
||||
remote_size: Some(remote.size),
|
||||
sync_status: SyncFileStatus::Synced,
|
||||
is_placeholder: false,
|
||||
}).await?;
|
||||
|
||||
tracing::info!("[{}] 下载完成: {}", task_id, action.relative_path);
|
||||
return Ok(());
|
||||
}
|
||||
Err(e) if attempt <= max_retries => {
|
||||
let delay = crate::utils::retry_delay_ms(attempt, 1000, 30000);
|
||||
// 保留临时文件用于断点续传
|
||||
let existing_size = tokio::fs::metadata(&tmp_path).await.map(|m| m.len()).unwrap_or(0);
|
||||
if existing_size > 0 {
|
||||
tracing::warn!(
|
||||
"[{}] 下载重试 ({}/{}): {} 写入失败(已下载{}bytes,将从断点续传): {}",
|
||||
task_id, attempt, max_retries, action.relative_path, existing_size, e,
|
||||
);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
"[{}] 下载重试 ({}/{}): {} 写入失败: {}",
|
||||
task_id, attempt, max_retries, action.relative_path, e,
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(delay)).await;
|
||||
continue;
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tokio::fs::remove_file(&tmp_path).await;
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 流式写入文件(含带宽限速 + 断点续传)
|
||||
/// resume_offset > 0 时以追加模式打开文件,跳过已下载部分
|
||||
pub async fn stream_to_file(
|
||||
resp: reqwest::Response,
|
||||
tmp_path: &std::path::Path,
|
||||
bandwidth_limit: Option<u64>,
|
||||
resume_offset: u64,
|
||||
) -> Result<()> {
|
||||
use tokio::io::{AsyncWriteExt, AsyncSeekExt};
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut file = if resume_offset > 0 && tmp_path.exists() {
|
||||
// 断点续传:追加模式
|
||||
let mut f = tokio::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(tmp_path)
|
||||
.await?;
|
||||
f.seek(std::io::SeekFrom::End(0)).await?;
|
||||
f
|
||||
} else {
|
||||
tokio::fs::File::create(tmp_path).await?
|
||||
};
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut total_bytes = resume_offset;
|
||||
|
||||
match bandwidth_limit {
|
||||
None => {
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
file.write_all(&chunk).await?;
|
||||
}
|
||||
}
|
||||
Some(limit) => {
|
||||
let transfer_start = std::time::Instant::now();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
total_bytes += chunk.len() as u64;
|
||||
file.write_all(&chunk).await?;
|
||||
|
||||
let expected_elapsed = std::time::Duration::from_micros(
|
||||
total_bytes * 1_000_000 / limit
|
||||
);
|
||||
let actual_elapsed = transfer_start.elapsed();
|
||||
if expected_elapsed > actual_elapsed {
|
||||
tokio::time::sleep(expected_elapsed - actual_elapsed).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 URL 下载文件到指定路径(WCF 水合使用)
|
||||
pub async fn download_file_from_url(
|
||||
download_url: &str,
|
||||
local_path: &std::path::Path,
|
||||
expected_size: u64,
|
||||
bandwidth_limit: Option<u64>,
|
||||
) -> Result<()> {
|
||||
let client = reqwest::Client::new();
|
||||
let mut request = client.get(download_url);
|
||||
|
||||
// 断点续传
|
||||
let resume_offset = if local_path.exists() {
|
||||
let metadata = tokio::fs::metadata(local_path).await?;
|
||||
if metadata.len() > 0 && metadata.len() < expected_size {
|
||||
request = request.header("Range", format!("bytes={}-", metadata.len()));
|
||||
metadata.len()
|
||||
} else {
|
||||
0
|
||||
}
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let resp = request.send().await.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
|
||||
if !resp.status().is_success() && resp.status().as_u16() != 206 {
|
||||
return Err(SyncError::Network(format!("下载失败: HTTP {}", resp.status())));
|
||||
}
|
||||
|
||||
let tmp_path = local_path.with_extension(format!(
|
||||
"{}.sync_tmp",
|
||||
local_path.extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default()
|
||||
));
|
||||
|
||||
stream_to_file(resp, &tmp_path, bandwidth_limit, resume_offset).await?;
|
||||
|
||||
// 重命名临时文件为最终文件
|
||||
if tmp_path.exists() {
|
||||
let _ = std::fs::remove_file(local_path);
|
||||
std::fs::rename(&tmp_path, local_path)
|
||||
.map_err(|e| SyncError::FileSystem(format!("重命名临时文件失败: {}", e)))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 从 URL 下载数据到内存缓冲区(WCF CfExecute 水合使用)
|
||||
pub async fn download_to_buffer(
|
||||
api: &ApiClient,
|
||||
download_url: &str,
|
||||
bandwidth_limit: Option<u64>,
|
||||
) -> Result<Vec<u8>> {
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let resp = api.stream_download(download_url, 0).await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(SyncError::Network(format!("下载失败: HTTP {}", resp.status())));
|
||||
}
|
||||
|
||||
let content_length = resp.content_length();
|
||||
let mut buffer = Vec::with_capacity(content_length.unwrap_or(0) as usize);
|
||||
let mut stream = resp.bytes_stream();
|
||||
let mut total_bytes: u64 = 0;
|
||||
|
||||
match bandwidth_limit {
|
||||
None => {
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
buffer.extend_from_slice(&chunk);
|
||||
}
|
||||
}
|
||||
Some(limit) => {
|
||||
let transfer_start = std::time::Instant::now();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| SyncError::Network(e.to_string()))?;
|
||||
total_bytes += chunk.len() as u64;
|
||||
buffer.extend_from_slice(&chunk);
|
||||
|
||||
let expected_elapsed = std::time::Duration::from_micros(
|
||||
total_bytes * 1_000_000 / limit
|
||||
);
|
||||
let actual_elapsed = transfer_start.elapsed();
|
||||
if expected_elapsed > actual_elapsed {
|
||||
tokio::time::sleep(expected_elapsed - actual_elapsed).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(buffer)
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
use std::error::Error as StdError;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum SyncError {
|
||||
#[error("网络错误: {0}")]
|
||||
Network(String),
|
||||
|
||||
#[error("磁盘空间不足: 需要 {needed} 字节, 可用 {available} 字节")]
|
||||
DiskFull { needed: u64, available: u64 },
|
||||
|
||||
#[error("认证错误: {0}")]
|
||||
Auth(String),
|
||||
|
||||
#[error("远程文件已存在")]
|
||||
ObjectExisted,
|
||||
|
||||
#[error("存储策略不允许: {0}")]
|
||||
StoragePolicyDenied(String),
|
||||
|
||||
#[error("上传失败: {0}")]
|
||||
UploadFailed(String),
|
||||
|
||||
#[error("文件未找到: {0}")]
|
||||
FileNotFound(String),
|
||||
|
||||
#[error("权限不足: {0}")]
|
||||
PermissionDenied(String),
|
||||
|
||||
#[error("文件锁定冲突")]
|
||||
LockConflict { tokens: Vec<LockConflictItem> },
|
||||
|
||||
#[error("冲突: {count} 个文件存在冲突")]
|
||||
Conflict { count: u32 },
|
||||
|
||||
#[error("数据库错误: {0}")]
|
||||
Database(String),
|
||||
|
||||
#[error("文件系统错误: {0}")]
|
||||
FileSystem(String),
|
||||
|
||||
#[error("路径遍历攻击: {path} 不在 {root} 下")]
|
||||
PathTraversal { path: String, root: String },
|
||||
|
||||
#[error("引擎未初始化")]
|
||||
NotInitialized,
|
||||
|
||||
#[error("引擎已初始化")]
|
||||
AlreadyInitialized,
|
||||
|
||||
#[error("同步已取消")]
|
||||
Cancelled,
|
||||
|
||||
#[error("内部错误: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl From<rusqlite::Error> for SyncError {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
SyncError::Database(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<r2d2::Error> for SyncError {
|
||||
fn from(e: r2d2::Error) -> Self {
|
||||
SyncError::Database(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<reqwest::Error> for SyncError {
|
||||
fn from(e: reqwest::Error) -> Self {
|
||||
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}")
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for SyncError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
SyncError::FileSystem(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl From<tokio::task::JoinError> for SyncError {
|
||||
fn from(e: tokio::task::JoinError) -> Self {
|
||||
SyncError::Internal(e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// 锁冲突条目 — 来自 40073 响应的 data 数组
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LockConflictItem {
|
||||
pub path: String,
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, SyncError>;
|
||||
@@ -0,0 +1,290 @@
|
||||
mod sse;
|
||||
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::errors::Result;
|
||||
use crate::models::{RemoteFileEntry, RemoteFileEvent};
|
||||
use sse::SseFileEvent;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
pub struct EventHandler {
|
||||
api: Arc<ApiClient>,
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
pub fn new(api: Arc<ApiClient>, client_id: String) -> Self {
|
||||
Self { api, client_id }
|
||||
}
|
||||
|
||||
/// 订阅服务端 SSE 事件流 (GET /file/events)
|
||||
pub async fn subscribe_sse(&self, uri: &str) -> Result<mpsc::Receiver<RemoteFileEvent>> {
|
||||
let (tx, rx) = mpsc::channel(256);
|
||||
|
||||
let api = self.api.clone();
|
||||
let client_id = self.client_id.clone();
|
||||
let remote_root = uri.to_string();
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let token = api.token().await;
|
||||
let base_url = api.base_url().to_string();
|
||||
|
||||
match Self::connect_sse(&base_url, &token, &client_id, &remote_root, &tx).await {
|
||||
Ok(_) => {
|
||||
tracing::info!("[SSE] 连接关闭,5秒后重连...");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[SSE] 连接错误: {},5秒后重连...", e);
|
||||
}
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(5)).await;
|
||||
|
||||
if tx.is_closed() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("[SSE] 订阅任务退出");
|
||||
});
|
||||
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// 建立 SSE 连接,使用 eventsource parser 正确解析事件
|
||||
async fn connect_sse(
|
||||
base_url: &str,
|
||||
token: &str,
|
||||
client_id: &str,
|
||||
remote_root: &str,
|
||||
tx: &mpsc::Sender<RemoteFileEvent>,
|
||||
) -> Result<()> {
|
||||
use sse::{SseEvent, SseParseResult, sse_parse_line};
|
||||
|
||||
let url = format!("{}/file/events", base_url);
|
||||
|
||||
tracing::info!("[SSE] 正在连接: {}?uri={}", url, remote_root);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let resp = client
|
||||
.get(&url)
|
||||
.bearer_auth(token)
|
||||
.header("X-Cr-Client-Id", client_id)
|
||||
.header("Accept", "text/event-stream")
|
||||
.header("Cache-Control", "no-cache")
|
||||
.query(&[("uri", remote_root)])
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(crate::errors::SyncError::Network(
|
||||
format!("[SSE] 连接失败: HTTP {}", resp.status()),
|
||||
));
|
||||
}
|
||||
|
||||
tracing::info!("[SSE] 连接成功,开始监听事件 (uri={})", remote_root);
|
||||
|
||||
let mut stream = resp.bytes_stream();
|
||||
use futures_util::StreamExt;
|
||||
|
||||
let mut line_buf = String::new();
|
||||
let mut event = SseEvent::new();
|
||||
let mut event_count: u64 = 0;
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|e| crate::errors::SyncError::Network(e.to_string()))?;
|
||||
let text = String::from_utf8_lossy(&chunk);
|
||||
line_buf.push_str(&text);
|
||||
|
||||
while let Some(newline_pos) = line_buf.find('\n') {
|
||||
let line = line_buf[..=newline_pos].to_string();
|
||||
line_buf = line_buf[newline_pos + 1..].to_string();
|
||||
|
||||
match sse_parse_line(&line, &mut event) {
|
||||
SseParseResult::Next => {}
|
||||
SseParseResult::Dispatch => {
|
||||
if event.is_empty() {
|
||||
event.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
event_count += 1;
|
||||
let event_type = event.event_type.clone().unwrap_or_default();
|
||||
|
||||
if event_type == "event" {
|
||||
let data = event.data.trim();
|
||||
let events: Vec<SseFileEvent> = if data.starts_with('[') {
|
||||
serde_json::from_str(data).unwrap_or_default()
|
||||
} else {
|
||||
serde_json::from_str(data)
|
||||
.map(|e| vec![e])
|
||||
.unwrap_or_else(|_| {
|
||||
tracing::warn!("[SSE] 无法解析事件数据: {}", data);
|
||||
Vec::new()
|
||||
})
|
||||
};
|
||||
|
||||
for ev in &events {
|
||||
tracing::info!(
|
||||
"[SSE] 原始事件: type={}, file_id={}, from={}, to={}",
|
||||
ev.event_type, ev.file_id, ev.from, ev.to
|
||||
);
|
||||
if let Some(remote_event) = Self::parse_sse_event(ev, remote_root) {
|
||||
if tx.send(remote_event).await.is_err() {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if event_type == "reconnect-required" {
|
||||
tracing::info!("[SSE] 服务端要求重连");
|
||||
return Ok(());
|
||||
} else if event_type == "subscribed" {
|
||||
tracing::info!("[SSE] 订阅确认成功");
|
||||
} else if event_type == "keep-alive" {
|
||||
} else {
|
||||
tracing::debug!(
|
||||
"[SSE] 忽略未知事件: type={:?}, data={}",
|
||||
event.event_type,
|
||||
event.data.trim()
|
||||
);
|
||||
}
|
||||
|
||||
event.clear();
|
||||
}
|
||||
SseParseResult::SetRetry(retry) => {
|
||||
tracing::debug!("[SSE] 服务端设置重试间隔: {:?}", retry);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::info!("[SSE] 流结束,共处理 {} 个事件", event_count);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 将 SSE 文件事件转为内部 RemoteFileEvent
|
||||
fn parse_sse_event(ev: &SseFileEvent, remote_root: &str) -> Option<RemoteFileEvent> {
|
||||
let full_uri = |path: &str| -> String {
|
||||
if path.starts_with("cloudreve://") || path.starts_with("http") {
|
||||
path.to_string()
|
||||
} else {
|
||||
format!("{}{}", remote_root.trim_end_matches('/'), path)
|
||||
}
|
||||
};
|
||||
|
||||
match ev.event_type.as_str() {
|
||||
"create" | "modify" => {
|
||||
let uri = full_uri(&ev.from);
|
||||
Some(RemoteFileEvent::Modified(RemoteFileEntry {
|
||||
name: ev.from.split('/').next_back()
|
||||
.unwrap_or("").to_string(),
|
||||
path: ev.from.clone(),
|
||||
uri,
|
||||
size: 0,
|
||||
mtime_ms: 0,
|
||||
hash: None,
|
||||
is_dir: false,
|
||||
file_id: Some(ev.file_id.clone()),
|
||||
created_at_ms: 0,
|
||||
}))
|
||||
}
|
||||
"delete" => {
|
||||
let uri = full_uri(&ev.from);
|
||||
Some(RemoteFileEvent::Deleted {
|
||||
uri,
|
||||
name: ev.from.split('/').next_back()
|
||||
.unwrap_or("").to_string(),
|
||||
})
|
||||
}
|
||||
"rename" | "move" => {
|
||||
let old_uri = full_uri(&ev.from);
|
||||
let new_uri = full_uri(&ev.to);
|
||||
let new_entry = RemoteFileEntry {
|
||||
name: ev.to.split('/').next_back()
|
||||
.unwrap_or("").to_string(),
|
||||
path: ev.to.clone(),
|
||||
uri: new_uri,
|
||||
size: 0,
|
||||
mtime_ms: 0,
|
||||
hash: None,
|
||||
is_dir: false,
|
||||
file_id: Some(ev.file_id.clone()),
|
||||
created_at_ms: 0,
|
||||
};
|
||||
|
||||
let from_parent = ev.from.rfind('/').map(|i| &ev.from[..i]).unwrap_or("");
|
||||
let to_parent = ev.to.rfind('/').map(|i| &ev.to[..i]).unwrap_or("");
|
||||
|
||||
if from_parent == to_parent {
|
||||
Some(RemoteFileEvent::Renamed { old_uri, new_entry })
|
||||
} else {
|
||||
Some(RemoteFileEvent::Moved { old_uri, new_entry })
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
tracing::debug!("[SSE] 忽略未知事件类型: {}", ev.event_type);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 事件防抖器:同一文件在 debounce_window 内的多次变更合并为一次
|
||||
pub struct EventDebouncer {
|
||||
pending: HashMap<PathBuf, Instant>,
|
||||
debounce_window: Duration,
|
||||
}
|
||||
|
||||
impl EventDebouncer {
|
||||
pub fn new(debounce_window: Duration) -> Self {
|
||||
Self {
|
||||
pending: HashMap::new(),
|
||||
debounce_window,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_process(&mut self, path: &PathBuf) -> bool {
|
||||
let now = Instant::now();
|
||||
if let Some(last) = self.pending.get(path) {
|
||||
if now.duration_since(*last) < self.debounce_window {
|
||||
self.pending.insert(path.clone(), now);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
self.pending.insert(path.clone(), now);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn cleanup(&mut self) {
|
||||
let now = Instant::now();
|
||||
self.pending.retain(|_, last| now.duration_since(*last) < self.debounce_window);
|
||||
}
|
||||
}
|
||||
|
||||
/// 收集时间窗口内的批量远程事件
|
||||
pub async fn batch_remote_events(
|
||||
rx: &mut mpsc::Receiver<RemoteFileEvent>,
|
||||
window: Duration,
|
||||
) -> Vec<RemoteFileEvent> {
|
||||
let mut events = Vec::new();
|
||||
|
||||
match tokio::time::timeout(window, rx.recv()).await {
|
||||
Ok(Some(event)) => events.push(event),
|
||||
_ => return events,
|
||||
}
|
||||
|
||||
let deadline = Instant::now() + window;
|
||||
while Instant::now() < deadline {
|
||||
let remaining = deadline.saturating_duration_since(Instant::now());
|
||||
match tokio::time::timeout(remaining, rx.recv()).await {
|
||||
Ok(Some(event)) => events.push(event),
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
|
||||
events
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
use std::time::Duration;
|
||||
|
||||
/// SSE 事件缓冲区
|
||||
#[derive(Default)]
|
||||
pub(crate) struct SseEvent {
|
||||
pub event_type: Option<String>,
|
||||
pub data: String,
|
||||
pub id: Option<String>,
|
||||
}
|
||||
|
||||
impl SseEvent {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.event_type.is_none() && self.data.is_empty()
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.event_type = None;
|
||||
self.data.clear();
|
||||
self.id = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum SseParseResult {
|
||||
Next,
|
||||
Dispatch,
|
||||
SetRetry(Duration),
|
||||
}
|
||||
|
||||
pub(crate) fn sse_parse_line(line: &str, event: &mut SseEvent) -> SseParseResult {
|
||||
let line = line.trim_end_matches(['\r', '\n']);
|
||||
|
||||
if line.is_empty() {
|
||||
if event.is_empty() {
|
||||
return SseParseResult::Next;
|
||||
}
|
||||
return SseParseResult::Dispatch;
|
||||
}
|
||||
|
||||
if line.starts_with(':') {
|
||||
return SseParseResult::Next;
|
||||
}
|
||||
|
||||
let (field, value) = if let Some(colon_pos) = line.find(':') {
|
||||
let field = &line[..colon_pos];
|
||||
let value = line[colon_pos + 1..].strip_prefix(' ').unwrap_or(&line[colon_pos + 1..]);
|
||||
(field, value)
|
||||
} else {
|
||||
(line, "")
|
||||
};
|
||||
|
||||
match field {
|
||||
"event" => event.event_type = Some(value.to_string()),
|
||||
"data" => {
|
||||
if !event.data.is_empty() {
|
||||
event.data.push('\n');
|
||||
}
|
||||
event.data.push_str(value);
|
||||
}
|
||||
"id" => event.id = Some(value.to_string()),
|
||||
"retry" => {
|
||||
if let Ok(ms) = value.parse::<u64>() {
|
||||
return SseParseResult::SetRetry(Duration::from_millis(ms));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
SseParseResult::Next
|
||||
}
|
||||
|
||||
/// SSE 事件中的文件变更条目
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
pub(crate) struct SseFileEvent {
|
||||
#[serde(rename = "type")]
|
||||
pub event_type: String,
|
||||
pub file_id: String,
|
||||
pub from: String,
|
||||
#[serde(default)]
|
||||
#[allow(dead_code)]
|
||||
pub to: String,
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
use crate::api::ffi_types::SyncEventFfi;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
/// Rust→Dart 事件推送 — 封装 FRB StreamSink
|
||||
///
|
||||
/// **重要**: StreamSink.add() 需要 SyncEventFfi 实现 SseEncode trait,
|
||||
/// 该 trait 由 flutter_rust_bridge_codegen 自动生成。
|
||||
/// 在运行 codegen 之前,emit() 仅写日志不实际推送。
|
||||
/// 运行 codegen 后,StreamSink 可用,emit() 将实际推送到 Dart。
|
||||
pub struct EventSink {
|
||||
sink: Arc<Mutex<Option<crate::frb_generated::StreamSink<SyncEventFfi>>>>,
|
||||
available: std::sync::atomic::AtomicBool,
|
||||
}
|
||||
|
||||
impl Default for EventSink {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventSink {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sink: Arc::new(Mutex::new(None)),
|
||||
available: std::sync::atomic::AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
/// 注册 StreamSink
|
||||
pub async fn register(&self, sink: crate::frb_generated::StreamSink<SyncEventFfi>) {
|
||||
self.available.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
*self.sink.lock().await = Some(sink);
|
||||
}
|
||||
|
||||
/// 推送事件到 Dart
|
||||
pub async fn emit(&self, event: SyncEventFfi) {
|
||||
if self.available.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
self.emit_inner(event).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// 实际推送 — 仅在 StreamSink 可用时调用
|
||||
/// 此方法在 FRB codegen 生成 SseEncode 实现后才编译通过
|
||||
#[cfg(feature = "event_sink_enabled")]
|
||||
async fn emit_inner(&self, event: SyncEventFfi) {
|
||||
if let Some(sink) = self.sink.lock().await.as_ref() {
|
||||
let _ = sink.add(event);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "event_sink_enabled"))]
|
||||
async fn emit_inner(&self, _event: SyncEventFfi) {
|
||||
// codegen 生成前为空操作,仅写日志
|
||||
tracing::debug!("EventSink: 事件未推送(FRB codegen 未运行)");
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user