Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
33509de4e3
|
|||
|
42db3f9683
|
|||
|
eef561796a
|
|||
|
b02daf1448
|
|||
| edcde96051 | |||
|
5ee6ba1e28
|
|||
|
61ad85f6fc
|
|||
|
8dfc22691a
|
|||
| c583c51d80 | |||
|
4d6ca139e5
|
|||
|
81864c99c2
|
|||
|
fb5fd6c9bc
|
|||
|
c208a06af5
|
|||
|
c2db8b38e9
|
|||
|
24d20cbfcb
|
|||
|
12f2c2660e
|
|||
|
98af110531
|
|||
| 886046a72b | |||
| d20f7790e3 | |||
|
17673b2862
|
|||
|
546cef5ba6
|
|||
|
39d8361080
|
@@ -140,6 +140,8 @@ jobs:
|
||||
|
||||
- name: Build Android release APK
|
||||
shell: bash
|
||||
env:
|
||||
IP_NODE_API_BASE_URL: ${{ secrets.IP_NODE_API_BASE_URL }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -151,8 +153,13 @@ jobs:
|
||||
rm -rf "$pkg_dir"
|
||||
fi
|
||||
|
||||
build_args=(--release)
|
||||
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 +198,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 +206,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 +256,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 +272,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 +360,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,200 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
### 运行项目
|
||||
|
||||
```bash
|
||||
flutter run # pdf 和 音视频会再构建过程中下载github上的依赖,自行解决网络问题
|
||||
```
|
||||
|
||||
### 构建发布
|
||||
|
||||
```bash
|
||||
# Android
|
||||
flutter build apk --release
|
||||
# Linux
|
||||
flutter build -d linux --release
|
||||
# windows
|
||||
flutter build -d windows --release
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📬 联系方式
|
||||
|
||||
- 📧 问题反馈:提交 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';
|
||||
}
|
||||
}
|
||||
@@ -27,4 +27,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;
|
||||
}
|
||||
}
|
||||
+36
-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,9 +21,11 @@ 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';
|
||||
@@ -29,12 +33,42 @@ 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();
|
||||
|
||||
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 拦截,需绑定错误处理器静默忽略
|
||||
FlutterError.onError = (details) {
|
||||
@@ -51,10 +85,6 @@ void main() async {
|
||||
return false;
|
||||
};
|
||||
|
||||
// 初始化日志
|
||||
await AppLogger.init();
|
||||
AppLogger.i("应用启动,日志系统就绪");
|
||||
|
||||
// 桌面端初始化窗口管理和系统托盘
|
||||
if (Platform.isWindows || Platform.isLinux) {
|
||||
// 实例化 FlutterSingleInstance 获取单实例句柄
|
||||
@@ -156,6 +186,7 @@ class CloudreveApp extends StatelessWidget {
|
||||
ChangeNotifierProvider(create: (_) => UserSettingProvider()),
|
||||
ChangeNotifierProvider(create: (_) => AdminProvider()),
|
||||
ChangeNotifierProvider(create: (_) => QuickAccessProvider()..load()),
|
||||
ChangeNotifierProvider(create: (_) => SyncProvider()),
|
||||
],
|
||||
child: const AppView(),
|
||||
);
|
||||
@@ -215,7 +246,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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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!);
|
||||
}
|
||||
}
|
||||
@@ -114,9 +114,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,
|
||||
|
||||
@@ -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('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +2,7 @@ import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:background_downloader/background_downloader.dart' as bd;
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:external_path/external_path.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import '../core/constants/storage_keys.dart';
|
||||
@@ -27,7 +28,7 @@ class DownloadService {
|
||||
|
||||
// 回调处理器
|
||||
static Function(String taskId, DownloadStatus status, int progress)?
|
||||
_callbackHandler;
|
||||
_callbackHandler;
|
||||
|
||||
final FileService _fileService = FileService();
|
||||
final Map<String, StreamController<DownloadTaskModel>> _progressControllers =
|
||||
@@ -36,7 +37,8 @@ class DownloadService {
|
||||
|
||||
/// 设置回调处理器
|
||||
static void setCallbackHandler(
|
||||
Function(String taskId, DownloadStatus status, int progress) handler) {
|
||||
Function(String taskId, DownloadStatus status, int progress) handler,
|
||||
) {
|
||||
_callbackHandler = handler;
|
||||
}
|
||||
|
||||
@@ -65,7 +67,10 @@ class DownloadService {
|
||||
throw Exception('存储权限被拒绝');
|
||||
}
|
||||
|
||||
final directory = Directory('/storage/emulated/0/Download');
|
||||
final downloadPath = await ExternalPath.getExternalStoragePublicDirectory(
|
||||
ExternalPath.DIRECTORY_DOWNLOAD,
|
||||
);
|
||||
final directory = Directory(downloadPath);
|
||||
if (!await directory.exists()) {
|
||||
await directory.create(recursive: true);
|
||||
}
|
||||
@@ -103,22 +108,23 @@ class DownloadService {
|
||||
|
||||
/// 读取 WiFi-only 下载设置
|
||||
Future<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 +139,10 @@ class DownloadService {
|
||||
if (Platform.isAndroid) {
|
||||
bd.FileDownloader().configureNotification(
|
||||
running: const bd.TaskNotification(
|
||||
'正在下载', '文件: {filename} - {progress}'),
|
||||
complete:
|
||||
const bd.TaskNotification('下载完成', '文件: {filename} 已保存'),
|
||||
'正在下载',
|
||||
'文件: {filename} - {progress}',
|
||||
),
|
||||
complete: const bd.TaskNotification('下载完成', '文件: {filename} 已保存'),
|
||||
error: const bd.TaskNotification('下载失败', '文件: {filename} 下载出错'),
|
||||
paused: const bd.TaskNotification('已暂停', '文件: {filename} 已暂停'),
|
||||
progressBar: true,
|
||||
@@ -170,14 +177,15 @@ class DownloadService {
|
||||
_internalIdToExternalTaskId[metaInternalId] = update.task.taskId;
|
||||
_bdTasks[metaInternalId] = update.task as bd.DownloadTask;
|
||||
AppLogger.d(
|
||||
'从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId');
|
||||
'从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId',
|
||||
);
|
||||
}
|
||||
|
||||
final resolvedInternalId =
|
||||
_externalTaskIdToInternalId[update.task.taskId];
|
||||
final resolvedInternalId = _externalTaskIdToInternalId[update.task.taskId];
|
||||
if (resolvedInternalId == null) {
|
||||
AppLogger.d(
|
||||
'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}');
|
||||
'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -201,7 +209,8 @@ class DownloadService {
|
||||
}
|
||||
|
||||
AppLogger.d(
|
||||
'background_downloader 状态更新: taskId=${update.task.taskId}, internalId=$resolvedInternalId, status=$status');
|
||||
'background_downloader 状态更新: taskId=${update.task.taskId}, internalId=$resolvedInternalId, status=$status',
|
||||
);
|
||||
|
||||
final progress = status == DownloadStatus.completed ? 100 : 0;
|
||||
_callbackHandler?.call(resolvedInternalId, status, progress);
|
||||
@@ -261,7 +270,10 @@ class DownloadService {
|
||||
|
||||
/// 使用 background_downloader 开始下载
|
||||
Future<String?> _startBdDownload(
|
||||
DownloadTaskModel task, String url, Directory dir) async {
|
||||
DownloadTaskModel task,
|
||||
String url,
|
||||
Directory dir,
|
||||
) async {
|
||||
final wifiOnly = await isWifiOnlyEnabled();
|
||||
final retries = await getRetries();
|
||||
final bdTask = bd.DownloadTask(
|
||||
@@ -291,14 +303,14 @@ class DownloadService {
|
||||
task.backgroundTaskId = bdTask.taskId;
|
||||
|
||||
AppLogger.d(
|
||||
'background_downloader 任务已添加: taskId=${bdTask.taskId}, internalId=${task.id}, requiresWiFi=$wifiOnly, retries=$retries');
|
||||
'background_downloader 任务已添加: taskId=${bdTask.taskId}, internalId=${task.id}, requiresWiFi=$wifiOnly, retries=$retries',
|
||||
);
|
||||
|
||||
return bdTask.taskId;
|
||||
}
|
||||
|
||||
/// 恢复下载(用于重启后恢复暂停的任务)
|
||||
Future<String?> resumeDownloadAfterRestart(
|
||||
DownloadTaskModel task) async {
|
||||
Future<String?> resumeDownloadAfterRestart(DownloadTaskModel task) async {
|
||||
try {
|
||||
if (!_isInitialized) {
|
||||
await initialize();
|
||||
@@ -349,11 +361,11 @@ class DownloadService {
|
||||
task.backgroundTaskId = bdTask.taskId;
|
||||
|
||||
// 如果有已下载的部分,尝试 resume;否则 enqueue
|
||||
final partialFile =
|
||||
File('${dir.path}/${task.fileName}.part');
|
||||
final partialFile = File('${dir.path}/${task.fileName}.part');
|
||||
if (task.downloadedBytes > 0 && await partialFile.exists()) {
|
||||
AppLogger.d(
|
||||
'断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes');
|
||||
'断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes',
|
||||
);
|
||||
await bd.FileDownloader().resume(bdTask);
|
||||
} else {
|
||||
AppLogger.d('重新下载: ${task.fileName}');
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import '../core/utils/file_type_utils.dart';
|
||||
import '../core/utils/file_utils.dart';
|
||||
import '../data/models/file_model.dart';
|
||||
|
||||
class FileAppViewer {
|
||||
final String id;
|
||||
final String type;
|
||||
final String displayName;
|
||||
final List<String> exts;
|
||||
final String? icon;
|
||||
final int maxSize;
|
||||
final String? url;
|
||||
|
||||
const FileAppViewer({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.displayName,
|
||||
required this.exts,
|
||||
this.icon,
|
||||
this.maxSize = 0,
|
||||
this.url,
|
||||
});
|
||||
|
||||
factory FileAppViewer.fromJson(Map<String, dynamic> json) {
|
||||
return FileAppViewer(
|
||||
id: json['id']?.toString() ?? '',
|
||||
type: json['type']?.toString() ?? '',
|
||||
displayName: json['display_name']?.toString() ??
|
||||
json['displayName']?.toString() ??
|
||||
json['name']?.toString() ??
|
||||
'文件应用',
|
||||
exts: _parseExts(json['exts'] ?? json['extensions'] ?? json['ext']),
|
||||
icon: json['icon']?.toString(),
|
||||
maxSize: (json['max_size'] as num?)?.toInt() ??
|
||||
(json['maxSize'] as num?)?.toInt() ??
|
||||
0,
|
||||
url: json['url']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _parseExts(dynamic raw) {
|
||||
if (raw is List) {
|
||||
return raw
|
||||
.map((e) => e.toString().toLowerCase().replaceAll('.', '').trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (raw is String) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((e) => e.toLowerCase().replaceAll('.', '').trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
bool supports(FileModel file) {
|
||||
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
|
||||
if (ext.isEmpty || !exts.contains(ext)) return false;
|
||||
if (maxSize > 0 && file.size > maxSize) return false;
|
||||
return id.isNotEmpty;
|
||||
}
|
||||
|
||||
bool get isWopi => type.toLowerCase().contains('wopi');
|
||||
}
|
||||
|
||||
class FileAppSession {
|
||||
final FileAppViewer viewer;
|
||||
final String wopiSrc;
|
||||
final String? accessToken;
|
||||
final int? expires;
|
||||
|
||||
const FileAppSession({
|
||||
required this.viewer,
|
||||
required this.wopiSrc,
|
||||
this.accessToken,
|
||||
this.expires,
|
||||
});
|
||||
|
||||
bool get hasAccessToken => accessToken != null && accessToken!.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Cloudreve V4 文件应用服务。
|
||||
///
|
||||
/// 注意:部分 Cloudreve V4 站点没有 `/site/config`,只有 `/site/config/basic`。
|
||||
/// 所以这里优先请求 `/site/config/basic`,如果没有 file_viewers 再尝试
|
||||
/// `/site/config`,并且对 404 做静默降级,避免 WebView 打开器直接崩溃。
|
||||
class FileAppService {
|
||||
FileAppService._();
|
||||
|
||||
static final FileAppService instance = FileAppService._();
|
||||
|
||||
List<FileAppViewer>? _cachedViewers;
|
||||
DateTime? _cachedAt;
|
||||
|
||||
Future<List<FileAppViewer>> getViewers({bool forceRefresh = false}) async {
|
||||
final cached = _cachedViewers;
|
||||
final cachedAt = _cachedAt;
|
||||
if (!forceRefresh &&
|
||||
cached != null &&
|
||||
cachedAt != null &&
|
||||
DateTime.now().difference(cachedAt).inMinutes < 10) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final viewers = await _loadViewersFromSiteConfig();
|
||||
|
||||
_cachedViewers = viewers;
|
||||
_cachedAt = DateTime.now();
|
||||
|
||||
return viewers;
|
||||
}
|
||||
|
||||
Future<List<FileAppViewer>> _loadViewersFromSiteConfig() async {
|
||||
// 当前项目登录验证码就是从 /site/config/basic 取的;
|
||||
// /site/config 在部分 Cloudreve 部署中不存在,会返回 404。
|
||||
const endpoints = <String>[
|
||||
'/site/config/basic',
|
||||
'/site/config',
|
||||
];
|
||||
|
||||
for (final endpoint in endpoints) {
|
||||
final root = await _tryGetSiteConfig(endpoint);
|
||||
if (root == null) continue;
|
||||
|
||||
final viewers = _extractViewers(root);
|
||||
if (viewers.isNotEmpty) {
|
||||
return viewers;
|
||||
}
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _tryGetSiteConfig(String endpoint) async {
|
||||
try {
|
||||
final response = await ApiService.instance.dio.get<dynamic>(
|
||||
endpoint,
|
||||
options: Options(
|
||||
extra: {'noAuth': true},
|
||||
// 404 也作为普通响应返回,由这里自己处理,避免进入全局错误拦截器。
|
||||
validateStatus: (status) => status != null && status >= 200 && status < 500,
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final raw = response.data;
|
||||
final map = _asMap(raw);
|
||||
if (map == null) return null;
|
||||
|
||||
final code = map['code'];
|
||||
if (code is int && code != 0 && code != 203) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _asMap(map['data']) ?? map;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<FileAppViewer?> findViewerForFile(
|
||||
FileModel file, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final viewers = await getViewers(forceRefresh: forceRefresh);
|
||||
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
|
||||
|
||||
final candidates = viewers.where((viewer) => viewer.supports(file)).toList();
|
||||
if (candidates.isEmpty) return null;
|
||||
|
||||
candidates.sort((a, b) {
|
||||
final aw = a.isWopi ? 0 : 1;
|
||||
final bw = b.isWopi ? 0 : 1;
|
||||
if (aw != bw) return aw.compareTo(bw);
|
||||
|
||||
final aExact = a.exts.contains(ext) ? 0 : 1;
|
||||
final bExact = b.exts.contains(ext) ? 0 : 1;
|
||||
return aExact.compareTo(bExact);
|
||||
});
|
||||
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
Future<FileAppSession> createViewerSession({
|
||||
required FileModel file,
|
||||
required FileAppViewer viewer,
|
||||
String preferredAction = 'view',
|
||||
}) async {
|
||||
final uri = FileUtils.toCloudreveUri(file.relativePath);
|
||||
final data = <String, dynamic>{
|
||||
'uri': uri,
|
||||
'viewer_id': viewer.id,
|
||||
'preferred_action': preferredAction,
|
||||
'parent_uri': _parentUri(uri),
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.put<Map<String, dynamic>>(
|
||||
'/file/viewerSession',
|
||||
data: data,
|
||||
);
|
||||
|
||||
final root = _asMap(response['data']) ?? response;
|
||||
final session = _asMap(root['session']);
|
||||
final wopiSrc = root['wopi_src']?.toString() ??
|
||||
root['wopiSrc']?.toString() ??
|
||||
root['url']?.toString() ??
|
||||
'';
|
||||
|
||||
if (wopiSrc.isEmpty) {
|
||||
throw Exception('文件应用没有返回可打开的 URL');
|
||||
}
|
||||
|
||||
return FileAppSession(
|
||||
viewer: viewer,
|
||||
wopiSrc: _absoluteUrl(wopiSrc),
|
||||
accessToken: session?['access_token']?.toString() ??
|
||||
session?['accessToken']?.toString(),
|
||||
expires: (session?['expires'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<FileAppSession> openFile({
|
||||
required FileModel file,
|
||||
String preferredAction = 'view',
|
||||
}) async {
|
||||
final viewer = await findViewerForFile(file);
|
||||
if (viewer == null) {
|
||||
throw Exception('没有找到支持 ${FileTypeUtils.getExtension(file.name)} 的 Cloudreve 文件应用');
|
||||
}
|
||||
|
||||
try {
|
||||
return await createViewerSession(
|
||||
file: file,
|
||||
viewer: viewer,
|
||||
preferredAction: preferredAction,
|
||||
);
|
||||
} catch (_) {
|
||||
if (preferredAction != 'view') {
|
||||
return createViewerSession(
|
||||
file: file,
|
||||
viewer: viewer,
|
||||
preferredAction: 'view',
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
List<FileAppViewer> _extractViewers(Map<String, dynamic> root) {
|
||||
final raw = _findValueByKey(root, const [
|
||||
'file_viewers',
|
||||
'fileViewers',
|
||||
'file_viewer',
|
||||
'viewers',
|
||||
]);
|
||||
|
||||
dynamic viewerList = raw;
|
||||
|
||||
final rawMap = _asMap(raw);
|
||||
if (rawMap != null) {
|
||||
viewerList = rawMap['viewers'] ?? rawMap['items'] ?? rawMap['data'];
|
||||
}
|
||||
|
||||
if (viewerList is! List) return const [];
|
||||
|
||||
return viewerList
|
||||
.whereType<Map>()
|
||||
.map((item) => FileAppViewer.fromJson(Map<String, dynamic>.from(item)))
|
||||
.where((viewer) => viewer.id.isNotEmpty && viewer.exts.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
dynamic _findValueByKey(dynamic value, List<String> keys) {
|
||||
if (value is Map) {
|
||||
for (final key in keys) {
|
||||
if (value.containsKey(key)) {
|
||||
return value[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (final child in value.values) {
|
||||
final found = _findValueByKey(child, keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
} else if (value is List) {
|
||||
for (final child in value) {
|
||||
final found = _findValueByKey(child, keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
String _parentUri(String uri) {
|
||||
final normalized = uri.endsWith('/') ? uri.substring(0, uri.length - 1) : uri;
|
||||
final index = normalized.lastIndexOf('/');
|
||||
if (index <= 'cloudreve://my'.length) {
|
||||
return 'cloudreve://my';
|
||||
}
|
||||
return normalized.substring(0, index);
|
||||
}
|
||||
|
||||
String _absoluteUrl(String url) {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
|
||||
final origin = Uri(
|
||||
scheme: base.scheme,
|
||||
host: base.host,
|
||||
port: base.hasPort ? base.port : null,
|
||||
).toString();
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
return '$origin$url';
|
||||
}
|
||||
|
||||
return '$origin/$url';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import '../core/utils/file_utils.dart';
|
||||
|
||||
class ShareLinkCandidate {
|
||||
final String id;
|
||||
final String url;
|
||||
final String? password;
|
||||
|
||||
const ShareLinkCandidate({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.password,
|
||||
});
|
||||
}
|
||||
|
||||
class ShareLinkInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final int visited;
|
||||
final bool expired;
|
||||
final bool unlocked;
|
||||
final bool isPrivate;
|
||||
final int sourceType;
|
||||
final String? sourceUri;
|
||||
final int size;
|
||||
final String? url;
|
||||
final String? ownerName;
|
||||
final String? ownerId;
|
||||
final String? ownerAvatar;
|
||||
final String? contextHint;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? expires;
|
||||
|
||||
const ShareLinkInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.visited,
|
||||
required this.expired,
|
||||
required this.unlocked,
|
||||
required this.isPrivate,
|
||||
required this.sourceType,
|
||||
this.sourceUri,
|
||||
this.size = 0,
|
||||
this.url,
|
||||
this.ownerName,
|
||||
this.ownerId,
|
||||
this.ownerAvatar,
|
||||
this.contextHint,
|
||||
this.createdAt,
|
||||
this.expires,
|
||||
});
|
||||
|
||||
bool get isFolder => sourceType == 1;
|
||||
bool get isFile => !isFolder;
|
||||
|
||||
factory ShareLinkInfo.fromJson(Map<String, dynamic> json) {
|
||||
final owner = _asMap(json['owner']);
|
||||
final source = _asMap(json['source']);
|
||||
final file = _asMap(json['file']);
|
||||
final object = _asMap(json['object']);
|
||||
final entity = _asMap(json['entity']);
|
||||
final rawSource = json['source'];
|
||||
|
||||
final ownerId = (owner?['id'] ??
|
||||
json['owner_id'] ??
|
||||
json['ownerId'] ??
|
||||
source?['owner_id'] ??
|
||||
file?['owner_id'])
|
||||
?.toString();
|
||||
|
||||
final name = (json['name'] ??
|
||||
file?['name'] ??
|
||||
source?['name'] ??
|
||||
object?['name'] ??
|
||||
'分享文件')
|
||||
.toString();
|
||||
|
||||
final parsedSourceUri = (json['source_uri'] ??
|
||||
json['sourceUri'] ??
|
||||
json['uri'] ??
|
||||
json['path'] ??
|
||||
(rawSource is String ? rawSource : null) ??
|
||||
source?['uri'] ??
|
||||
source?['source_uri'] ??
|
||||
source?['path'] ??
|
||||
source?['url'] ??
|
||||
file?['uri'] ??
|
||||
file?['source_uri'] ??
|
||||
file?['path'] ??
|
||||
object?['uri'] ??
|
||||
object?['source_uri'] ??
|
||||
object?['path'] ??
|
||||
entity?['uri'] ??
|
||||
entity?['source_uri'] ??
|
||||
entity?['path'])
|
||||
?.toString();
|
||||
|
||||
final resolvedSourceUri = _looksLikeCloudreveUri(parsedSourceUri)
|
||||
? parsedSourceUri
|
||||
: _fallbackSourceUri(ownerId: ownerId, name: name);
|
||||
|
||||
final size = _asInt(json['size'] ??
|
||||
json['source_size'] ??
|
||||
json['sourceSize'] ??
|
||||
source?['size'] ??
|
||||
file?['size'] ??
|
||||
object?['size'] ??
|
||||
entity?['size']);
|
||||
|
||||
return ShareLinkInfo(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: name,
|
||||
size: size,
|
||||
visited: _asInt(json['visited']),
|
||||
expired: json['expired'] as bool? ?? false,
|
||||
unlocked: json['unlocked'] as bool? ?? false,
|
||||
isPrivate: json['is_private'] as bool? ?? false,
|
||||
sourceType: _asInt(json['source_type'] ??
|
||||
source?['type'] ??
|
||||
file?['type'] ??
|
||||
object?['type']),
|
||||
sourceUri: resolvedSourceUri,
|
||||
url: json['url']?.toString(),
|
||||
ownerName: owner?['nickname']?.toString() ??
|
||||
owner?['name']?.toString() ??
|
||||
json['owner_name']?.toString(),
|
||||
ownerId: ownerId,
|
||||
ownerAvatar: owner?['avatar']?.toString(),
|
||||
contextHint: json['context_hint']?.toString() ??
|
||||
json['contextHint']?.toString() ??
|
||||
json['context']?.toString(),
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
expires: _parseDate(json['expires']),
|
||||
);
|
||||
}
|
||||
|
||||
static bool _looksLikeCloudreveUri(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return false;
|
||||
return value.trim().startsWith('cloudreve://');
|
||||
}
|
||||
|
||||
static String? _fallbackSourceUri({
|
||||
required String? ownerId,
|
||||
required String name,
|
||||
}) {
|
||||
// 不再用 owner@my 兜底。公开分享上下文不是当前账号的 my 文件系统,
|
||||
// 用 owner@my 会导致 /file/url 返回 40081 或空 urls。
|
||||
// 真正的兜底由 ShareLinkService.getShareInfo 根据分享 ID 构造
|
||||
// cloudreve://<shareId>@share/ 完成。
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
static int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic value) {
|
||||
final text = value?.toString();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return DateTime.tryParse(text);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareLinkFile {
|
||||
final int type;
|
||||
final String id;
|
||||
final String name;
|
||||
final int size;
|
||||
final String path;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final String? primaryEntity;
|
||||
final String? capability;
|
||||
final String? contextHint;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const ShareLinkFile({
|
||||
required this.type,
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.size,
|
||||
required this.path,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.primaryEntity,
|
||||
this.capability,
|
||||
this.contextHint,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
bool get isFolder => type == 1;
|
||||
bool get isFile => !isFolder;
|
||||
|
||||
factory ShareLinkFile.fromJson(Map<String, dynamic> json) {
|
||||
return ShareLinkFile(
|
||||
type: _asInt(json['type']),
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '未命名文件',
|
||||
size: _asInt(json['size']),
|
||||
path: json['path']?.toString() ?? '',
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
updatedAt: _parseDate(json['updated_at']),
|
||||
primaryEntity: json['primary_entity']?.toString() ??
|
||||
json['entity']?.toString(),
|
||||
capability: json['capability']?.toString(),
|
||||
contextHint: json['context_hint']?.toString(),
|
||||
metadata: _asMap(json['metadata']),
|
||||
);
|
||||
}
|
||||
|
||||
static int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic value) {
|
||||
final text = value?.toString();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return DateTime.tryParse(text);
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ShareLinkFileListResult {
|
||||
final List<ShareLinkFile> files;
|
||||
final ShareLinkFile? parent;
|
||||
final String? contextHint;
|
||||
final bool hasMore;
|
||||
final String? nextPageToken;
|
||||
|
||||
const ShareLinkFileListResult({
|
||||
required this.files,
|
||||
this.parent,
|
||||
this.contextHint,
|
||||
this.hasMore = false,
|
||||
this.nextPageToken,
|
||||
});
|
||||
|
||||
factory ShareLinkFileListResult.fromJson(Map<String, dynamic> json) {
|
||||
final rawFiles = json['files'];
|
||||
final rawParent = json['parent'];
|
||||
final pagination = ShareLinkService._asMap(json['pagination']);
|
||||
|
||||
return ShareLinkFileListResult(
|
||||
files: rawFiles is List
|
||||
? rawFiles
|
||||
.whereType<Map>()
|
||||
.map((item) => ShareLinkFile.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList()
|
||||
: const [],
|
||||
parent: rawParent is Map
|
||||
? ShareLinkFile.fromJson(Map<String, dynamic>.from(rawParent))
|
||||
: null,
|
||||
contextHint: json['context_hint']?.toString(),
|
||||
hasMore: pagination?['next_token'] != null,
|
||||
nextPageToken: pagination?['next_token']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareDownloadUrlResult {
|
||||
final String url;
|
||||
final DateTime? expires;
|
||||
|
||||
const ShareDownloadUrlResult({
|
||||
required this.url,
|
||||
this.expires,
|
||||
});
|
||||
}
|
||||
|
||||
class ShareLinkService {
|
||||
ShareLinkService._();
|
||||
|
||||
static final ShareLinkService instance = ShareLinkService._();
|
||||
|
||||
/// 兼容当前站点固定域名,也兼容用户自定义 Cloudreve 域名的 /s/{id}/{password?} 分享链接。
|
||||
static final RegExp _shareLinkRegExp = RegExp(
|
||||
r'https?://[^\s]+?/s/([A-Za-z0-9_-]+)(?:/([A-Za-z0-9_-]+))?',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
ShareLinkCandidate? parseShareLink(String? text) {
|
||||
if (text == null || text.trim().isEmpty) return null;
|
||||
|
||||
final match = _shareLinkRegExp.firstMatch(text.trim());
|
||||
if (match == null) return null;
|
||||
|
||||
final id = match.group(1);
|
||||
if (id == null || id.isEmpty) return null;
|
||||
|
||||
final password = match.group(2);
|
||||
final url = match.group(0)!;
|
||||
|
||||
return ShareLinkCandidate(
|
||||
id: id,
|
||||
url: url,
|
||||
password: password == null || password.isEmpty ? null : password,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ShareLinkInfo> getShareInfo(
|
||||
ShareLinkCandidate candidate, {
|
||||
String? password,
|
||||
}) async {
|
||||
final resolvedPassword = password ?? candidate.password;
|
||||
final query = <String, dynamic>{
|
||||
'count_views': true,
|
||||
'owner_extended': true,
|
||||
if (resolvedPassword != null && resolvedPassword.isNotEmpty)
|
||||
'password': resolvedPassword,
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||
'/share/info/${candidate.id}',
|
||||
queryParameters: query,
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: const {
|
||||
'X-Cr-Context-Hint': 'share',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(body['data']) ?? body;
|
||||
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||
final sourceType = ShareLinkInfo._asInt(data['source_type']);
|
||||
|
||||
// 公开分享页面访问文件时,必须使用 Cloudreve 官方 share 文件系统 URI,
|
||||
// 即 cloudreve://{shareId}[:password]@share[/...]。
|
||||
// /share/info 返回的 source_uri 往往是所有者 my 文件系统里的真实路径,
|
||||
// 匿名/noAuth 场景拿它直接请求 /file/url 会被服务端按普通文件解析,
|
||||
// 进而返回 40081 Entity not exist。
|
||||
final accessUri = shareRootUri(
|
||||
shareId: candidate.id,
|
||||
password: resolvedPassword,
|
||||
trailingSlash: sourceType != 0,
|
||||
);
|
||||
|
||||
return ShareLinkInfo.fromJson(<String, dynamic>{
|
||||
...data,
|
||||
'source_uri': accessUri,
|
||||
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
|
||||
headerContext != null &&
|
||||
headerContext.isNotEmpty)
|
||||
'context_hint': headerContext,
|
||||
});
|
||||
}
|
||||
|
||||
String? ownerAvatarUrl(ShareLinkInfo info) {
|
||||
final ownerId = info.ownerId;
|
||||
if (ownerId == null || ownerId.isEmpty) return null;
|
||||
|
||||
final avatar = info.ownerAvatar;
|
||||
if (avatar != null && avatar.startsWith(RegExp(r'https?://'))) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
final base = ApiService.instance.dio.options.baseUrl.replaceFirst(RegExp(r'/+$'), '');
|
||||
return '$base/user/avatar/${Uri.encodeComponent(ownerId)}';
|
||||
}
|
||||
|
||||
/// 按分享的 source_uri 读取目录文件。
|
||||
///
|
||||
/// Cloudreve V4 的 /file 接口支持 JWT Optional,并通过 X-Cr-Context-Hint
|
||||
/// 绑定分享上下文。首次读取时用 `share`,服务端返回 context_hint 后,后续下载
|
||||
/// 和进入子目录都继续携带该 context_hint。
|
||||
Future<ShareLinkFileListResult> listSharedFiles({
|
||||
required String uri,
|
||||
String? contextHint,
|
||||
int page = 0,
|
||||
int pageSize = 100,
|
||||
String? nextPageToken,
|
||||
}) async {
|
||||
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||
'/file',
|
||||
queryParameters: <String, dynamic>{
|
||||
'uri': uri,
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (nextPageToken != null && nextPageToken.isNotEmpty)
|
||||
'next_page_token': nextPageToken,
|
||||
},
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: <String, dynamic>{
|
||||
'X-Cr-Context-Hint': _firstContextHint(contextHint),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(body['data']) ?? body;
|
||||
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||
return ShareLinkFileListResult.fromJson(<String, dynamic>{
|
||||
...data,
|
||||
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
|
||||
headerContext != null &&
|
||||
headerContext.isNotEmpty)
|
||||
'context_hint': headerContext,
|
||||
});
|
||||
}
|
||||
|
||||
/// 读取分享上下文中文件详情,主要用于拿到 primary_entity。
|
||||
Future<ShareLinkFile> getSharedFileInfo({
|
||||
required String uri,
|
||||
String? contextHint,
|
||||
String? shareId,
|
||||
String? password,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
|
||||
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||
try {
|
||||
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||
'/file/info',
|
||||
queryParameters: <String, dynamic>{
|
||||
'uri': uri,
|
||||
'extended': true,
|
||||
},
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: <String, dynamic>{
|
||||
'X-Cr-Context-Hint': hint,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(body['data']) ?? body;
|
||||
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||
return ShareLinkFile.fromJson(<String, dynamic>{
|
||||
...data,
|
||||
'context_hint': data['context_hint'] ?? headerContext ?? hint,
|
||||
});
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception('文件详情读取失败');
|
||||
}
|
||||
|
||||
ShareLinkFile fileFromShareInfo(ShareLinkInfo info) {
|
||||
final sourceUri = info.sourceUri ??
|
||||
ShareLinkInfo._fallbackSourceUri(
|
||||
ownerId: info.ownerId,
|
||||
name: info.name,
|
||||
) ??
|
||||
'';
|
||||
return ShareLinkFile(
|
||||
type: info.sourceType,
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
size: info.size,
|
||||
path: sourceUri,
|
||||
createdAt: info.createdAt,
|
||||
updatedAt: info.createdAt,
|
||||
contextHint: info.contextHint,
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取分享上下文中的下载 URL。
|
||||
///
|
||||
/// 按 Cloudreve V4 官方 `/file/url` 方式创建临时下载链接:
|
||||
/// body 只提交 `uris`,文件夹批量下载时提交 `archive: true`;
|
||||
/// 分享访问走 JWT Optional,因此这里使用 noAuth,避免当前登录用户的
|
||||
/// Authorization 影响分享上下文。
|
||||
Future<ShareDownloadUrlResult> createShareDownloadUrl({
|
||||
required String uri,
|
||||
String? contextHint,
|
||||
String? shareId,
|
||||
String? password,
|
||||
String? entity,
|
||||
String? fileName,
|
||||
bool archive = false,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
|
||||
// Cloudreve V4 官方接口:POST /file/url
|
||||
// Header: X-Cr-Context-Hint
|
||||
// Body: { "uris": [...], "archive": true? }
|
||||
//
|
||||
// 分享下载必须不带当前账号 token;否则服务端可能按当前账号解析 URI,
|
||||
// 出现 Entity not exist (40081)。
|
||||
for (final candidateUri in _uriCandidates(
|
||||
uri,
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
fileName: fileName,
|
||||
)) {
|
||||
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||
try {
|
||||
return await _createShareDownloadUrlOnce(
|
||||
uri: candidateUri,
|
||||
contextHint: hint,
|
||||
archive: archive,
|
||||
);
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception('服务端没有返回可用的下载链接');
|
||||
}
|
||||
|
||||
Future<ShareDownloadUrlResult> _createShareDownloadUrlOnce({
|
||||
required String uri,
|
||||
required String contextHint,
|
||||
bool archive = false,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'uris': <String>[uri],
|
||||
'download': true,
|
||||
if (archive) 'archive': true,
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.dio.post<Map<String, dynamic>>(
|
||||
'/file/url',
|
||||
data: body,
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: <String, dynamic>{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Cr-Context-Hint': contextHint,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final raw = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(raw['data']) ?? raw;
|
||||
final url = _extractDownloadUrl(data) ?? _extractDownloadUrl(raw);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
return ShareDownloadUrlResult(
|
||||
url: url,
|
||||
expires: ShareLinkInfo._parseDate(data['expires'] ?? raw['expires']),
|
||||
);
|
||||
}
|
||||
|
||||
throw Exception('服务端没有返回可用的下载链接');
|
||||
}
|
||||
|
||||
/// 将分享文件转存到当前用户网盘。
|
||||
///
|
||||
/// Cloudreve V4 使用 /file/move,同一接口可移动/复制文件;传入 copy=true
|
||||
/// 时即为转存/复制到目标目录。
|
||||
Future<void> saveSharedFiles({
|
||||
required List<String> uris,
|
||||
required String destination,
|
||||
String? contextHint,
|
||||
String? shareId,
|
||||
String? password,
|
||||
String? fileName,
|
||||
}) async {
|
||||
final dst = FileUtils.toCloudreveUri(destination);
|
||||
Object? lastError;
|
||||
|
||||
final uriCandidateSets = uris
|
||||
.map((uri) => _uriCandidates(
|
||||
uri,
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
// 单文件分享的 source_uri 是 cloudreve://{id}@share 根。
|
||||
// /file/move 不允许复制 share 根目录,因此需要优先尝试
|
||||
// cloudreve://{id}@share/{fileName},并过滤掉根 URI。
|
||||
fileName: uris.length == 1 ? fileName : null,
|
||||
includeRootFallback: false,
|
||||
)
|
||||
.where((candidate) => !isShareRootUri(candidate, shareId: shareId))
|
||||
.toList())
|
||||
.where((candidates) => candidates.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (uriCandidateSets.isEmpty) {
|
||||
throw Exception('不能直接转存分享根目录,请选择具体文件或进入目录后再转存');
|
||||
}
|
||||
|
||||
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||
for (final candidateUris in _cartesianUriCandidates(uriCandidateSets)) {
|
||||
if (candidateUris.isEmpty) continue;
|
||||
try {
|
||||
await ApiService.instance.post<void>(
|
||||
'/file/move',
|
||||
data: <String, dynamic>{
|
||||
'uris': candidateUris,
|
||||
'dst': dst,
|
||||
'copy': true,
|
||||
},
|
||||
headers: <String, dynamic>{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Cr-Context-Hint': hint,
|
||||
},
|
||||
);
|
||||
return;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception('转存失败');
|
||||
}
|
||||
|
||||
static List<String> _uriCandidates(
|
||||
String uri, {
|
||||
String? shareId,
|
||||
String? password,
|
||||
String? fileName,
|
||||
bool includeRootFallback = true,
|
||||
}) {
|
||||
final values = <String>[];
|
||||
|
||||
void add(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
final shareUriCandidates = _shareScopedUriCandidates(
|
||||
uri,
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// 官方页面进入的是 cloudreve://{shareId}@share 文件系统;
|
||||
// 如果 /file 返回了 cloudreve://my/... 或 owner@my/...,
|
||||
// 转存/下载时也要先折算成 share 文件系统里的相对路径。
|
||||
for (final item in shareUriCandidates) {
|
||||
add(item);
|
||||
}
|
||||
|
||||
final name = fileName?.trim();
|
||||
final id = shareId?.trim();
|
||||
if (id != null && id.isNotEmpty && name != null && name.isNotEmpty) {
|
||||
final root = shareRootUri(
|
||||
shareId: id,
|
||||
password: password,
|
||||
trailingSlash: false,
|
||||
);
|
||||
add('$root/${Uri.encodeComponent(name)}');
|
||||
add('$root/${_encodeCloudrevePath(name)}');
|
||||
}
|
||||
|
||||
add(_withSharePassword(uri, shareId: shareId, password: password));
|
||||
add(uri);
|
||||
|
||||
if (uri.startsWith('cloudreve://')) {
|
||||
final withPassword = _withSharePassword(uri, shareId: shareId, password: password);
|
||||
if (withPassword != uri) add(withPassword);
|
||||
|
||||
final slash = uri.indexOf('/', 'cloudreve://'.length);
|
||||
if (slash > 0) {
|
||||
final prefix = uri.substring(0, slash + 1);
|
||||
final path = slash < uri.length - 1 ? uri.substring(slash + 1) : '';
|
||||
if (path.isNotEmpty) {
|
||||
final encodedPath = _encodeCloudrevePath(path);
|
||||
add(_withSharePassword('$prefix$encodedPath', shareId: shareId, password: password));
|
||||
add('$prefix$encodedPath');
|
||||
}
|
||||
if (!uri.endsWith('/')) {
|
||||
add(_withSharePassword('$uri/', shareId: shareId, password: password));
|
||||
add('$uri/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeRootFallback && shareId != null && shareId.trim().isNotEmpty) {
|
||||
add(shareRootUri(shareId: shareId, password: password, trailingSlash: true));
|
||||
add(shareRootUri(shareId: shareId, password: password, trailingSlash: false));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
static List<String> _shareScopedUriCandidates(
|
||||
String uri, {
|
||||
String? shareId,
|
||||
String? password,
|
||||
}) {
|
||||
final id = shareId?.trim();
|
||||
if (id == null || id.isEmpty || !uri.startsWith('cloudreve://')) {
|
||||
return const <String>[];
|
||||
}
|
||||
|
||||
final values = <String>[];
|
||||
void add(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
final authorityStart = 'cloudreve://'.length;
|
||||
final slash = uri.indexOf('/', authorityStart);
|
||||
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
|
||||
final rawPath = slash >= 0 && slash < uri.length - 1 ? uri.substring(slash + 1) : '';
|
||||
final decodedPath = rawPath
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.map((segment) => Uri.decodeComponent(segment))
|
||||
.join('/');
|
||||
|
||||
final isShareAuthority = authority.endsWith('@share');
|
||||
if (isShareAuthority) {
|
||||
add(_withSharePassword(uri, shareId: id, password: password));
|
||||
add(uri);
|
||||
return values;
|
||||
}
|
||||
|
||||
if (decodedPath.isEmpty) return values;
|
||||
|
||||
final pathParts = decodedPath.split('/').where((e) => e.isNotEmpty).toList();
|
||||
final root = shareRootUri(shareId: id, password: password, trailingSlash: false);
|
||||
|
||||
// 完整路径候选:cloudreve://id@share/folder/file.ext
|
||||
add('$root/${_encodeCloudrevePath(decodedPath)}');
|
||||
|
||||
// 单文件分享或列表返回 owner 的 my 路径时,官方 share 根目录下通常就是文件本身,
|
||||
// 因此还要尝试 basename 候选:cloudreve://id@share/file.ext
|
||||
if (pathParts.isNotEmpty) {
|
||||
add('$root/${Uri.encodeComponent(pathParts.last)}');
|
||||
}
|
||||
|
||||
// 如果原 path 形如 Folder/Sub/File,分享根可能是 Folder,子项在 share 中应为 Sub/File。
|
||||
if (pathParts.length > 1) {
|
||||
final withoutFirst = pathParts.skip(1).join('/');
|
||||
add('$root/${_encodeCloudrevePath(withoutFirst)}');
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
static String _encodeCloudrevePath(String path) {
|
||||
return path
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.map((segment) => Uri.encodeComponent(Uri.decodeComponent(segment)))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
static String _withSharePassword(String uri, {String? shareId, String? password}) {
|
||||
final pw = password?.trim();
|
||||
final id = shareId?.trim();
|
||||
if (pw == null || pw.isEmpty || id == null || id.isEmpty) return uri;
|
||||
if (!uri.startsWith('cloudreve://')) return uri;
|
||||
|
||||
final authorityStart = 'cloudreve://'.length;
|
||||
final slash = uri.indexOf('/', authorityStart);
|
||||
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
|
||||
final rest = slash >= 0 ? uri.substring(slash) : '';
|
||||
if (!authority.endsWith('@share')) return uri;
|
||||
if (authority.contains(':')) return uri;
|
||||
|
||||
final encodedId = Uri.encodeComponent(id);
|
||||
final encodedPw = Uri.encodeComponent(pw);
|
||||
if (authority != '$encodedId@share' && authority != '$id@share') return uri;
|
||||
return 'cloudreve://$encodedId:$encodedPw@share$rest';
|
||||
}
|
||||
|
||||
static String shareRootUri({
|
||||
required String shareId,
|
||||
String? password,
|
||||
bool trailingSlash = true,
|
||||
}) {
|
||||
final id = Uri.encodeComponent(shareId.trim());
|
||||
final pw = password?.trim();
|
||||
final userInfo = pw == null || pw.isEmpty
|
||||
? id
|
||||
: '$id:${Uri.encodeComponent(pw)}';
|
||||
return 'cloudreve://$userInfo@share${trailingSlash ? '/' : ''}';
|
||||
}
|
||||
|
||||
static bool isShareRootUri(String uri, {String? shareId}) {
|
||||
final text = uri.trim();
|
||||
const prefix = 'cloudreve://';
|
||||
if (!text.startsWith(prefix)) return false;
|
||||
|
||||
final authorityStart = prefix.length;
|
||||
final slash = text.indexOf('/', authorityStart);
|
||||
final authority = slash >= 0
|
||||
? text.substring(authorityStart, slash)
|
||||
: text.substring(authorityStart);
|
||||
if (!authority.endsWith('@share')) return false;
|
||||
|
||||
final path = slash >= 0 ? text.substring(slash) : '';
|
||||
if (path.isNotEmpty && path != '/') return false;
|
||||
|
||||
final expectedId = shareId?.trim();
|
||||
if (expectedId == null || expectedId.isEmpty) return true;
|
||||
|
||||
final userInfo = authority.substring(0, authority.length - '@share'.length);
|
||||
final rawId = userInfo.split(':').first;
|
||||
return Uri.decodeComponent(rawId) == expectedId || rawId == expectedId;
|
||||
}
|
||||
|
||||
|
||||
static String? _extractDownloadUrl(Map<String, dynamic> data) {
|
||||
final direct = data['url'] ??
|
||||
data['download_url'] ??
|
||||
data['downloadUrl'] ??
|
||||
data['src'] ??
|
||||
data['href'];
|
||||
if (direct is String && direct.trim().isNotEmpty) return direct.trim();
|
||||
|
||||
final rawUrls = data['urls'];
|
||||
if (rawUrls is String && rawUrls.trim().isNotEmpty) return rawUrls.trim();
|
||||
if (rawUrls is List) {
|
||||
for (final item in rawUrls) {
|
||||
if (item is String && item.trim().isNotEmpty) return item.trim();
|
||||
final map = _asMap(item);
|
||||
if (map == null) continue;
|
||||
final url = _extractDownloadUrl(map);
|
||||
if (url != null && url.isNotEmpty) return url;
|
||||
}
|
||||
}
|
||||
if (rawUrls is Map) {
|
||||
for (final item in rawUrls.values) {
|
||||
if (item is String && item.trim().isNotEmpty) return item.trim();
|
||||
final map = _asMap(item);
|
||||
if (map == null) continue;
|
||||
final url = _extractDownloadUrl(map);
|
||||
if (url != null && url.isNotEmpty) return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<List<String>> _cartesianUriCandidates(List<List<String>> sets) {
|
||||
if (sets.isEmpty) return const [[]];
|
||||
|
||||
var result = <List<String>>[const []];
|
||||
for (final set in sets) {
|
||||
final next = <List<String>>[];
|
||||
for (final prefix in result) {
|
||||
for (final item in set) {
|
||||
next.add(<String>[...prefix, item]);
|
||||
}
|
||||
}
|
||||
result = next;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String _firstContextHint(String? contextHint) {
|
||||
final value = contextHint?.trim();
|
||||
return value == null || value.isEmpty ? 'share' : value;
|
||||
}
|
||||
|
||||
static List<String> _contextHintCandidates(String? contextHint, {String? shareId}) {
|
||||
final values = <String>[];
|
||||
void add(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
add(contextHint);
|
||||
add(shareId);
|
||||
if (shareId != null && shareId.trim().isNotEmpty) {
|
||||
add('share:${shareId.trim()}');
|
||||
add('share_${shareId.trim()}');
|
||||
}
|
||||
add('share');
|
||||
return values;
|
||||
}
|
||||
|
||||
static String? _headerValue(Headers headers, String name) {
|
||||
final direct = headers.value(name) ?? headers.value(name.toLowerCase());
|
||||
if (direct != null && direct.isNotEmpty) return direct;
|
||||
|
||||
final lower = name.toLowerCase();
|
||||
for (final entry in headers.map.entries) {
|
||||
if (entry.key.toLowerCase() == lower && entry.value.isNotEmpty) {
|
||||
return entry.value.first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+139
-22
@@ -1,7 +1,54 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'api_service.dart';
|
||||
import '../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');
|
||||
}
|
||||
}
|
||||
@@ -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 未运行)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use dashmap::DashMap;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Weak;
|
||||
use tokio::sync::{Mutex, OwnedMutexGuard};
|
||||
|
||||
/// 文件锁注册表 — 防止同一文件被并发操作(如上传中删除)
|
||||
pub struct FileLockRegistry {
|
||||
locks: DashMap<String, Arc<Mutex<()>>>,
|
||||
}
|
||||
|
||||
impl Default for FileLockRegistry {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FileLockRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
locks: DashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 阻塞等待获取文件锁,返回守卫
|
||||
pub async fn acquire(&self, path: &str) -> FileLockGuard<'_> {
|
||||
let lock = self
|
||||
.locks
|
||||
.entry(path.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone();
|
||||
let guard = lock.lock_owned().await;
|
||||
FileLockGuard {
|
||||
registry: self,
|
||||
path: path.to_string(),
|
||||
_guard: guard,
|
||||
}
|
||||
}
|
||||
|
||||
/// 移除无人等待的锁条目(守卫 Drop 时调用)
|
||||
fn cleanup_if_unused(&self, path: &str) {
|
||||
if let Some(entry) = self.locks.get(path) {
|
||||
if Arc::strong_count(entry.value()) == 1 {
|
||||
drop(entry);
|
||||
self.locks.remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 供外部引用计数判断
|
||||
pub fn get_weak(&self, path: &str) -> Weak<Mutex<()>> {
|
||||
let lock = self
|
||||
.locks
|
||||
.entry(path.to_string())
|
||||
.or_insert_with(|| Arc::new(Mutex::new(())))
|
||||
.clone();
|
||||
Arc::downgrade(&lock)
|
||||
}
|
||||
}
|
||||
|
||||
/// 文件锁守卫 — Drop 时自动清理无人等待的锁条目
|
||||
pub struct FileLockGuard<'a> {
|
||||
registry: &'a FileLockRegistry,
|
||||
path: String,
|
||||
_guard: OwnedMutexGuard<()>,
|
||||
}
|
||||
|
||||
impl Drop for FileLockGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
self.registry.cleanup_if_unused(&self.path);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,172 @@
|
||||
use crate::errors::Result;
|
||||
use crate::models::LocalFileEntry;
|
||||
use crate::utils::quick_hash;
|
||||
#[cfg(unix)]
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// 需要跳过的文件/目录名前缀和名称
|
||||
pub const SKIP_NAMES: &[&str] = &[".DS_Store", "Thumbs.db", "desktop.ini"];
|
||||
|
||||
/// 需要跳过的文件扩展名(同步临时文件)
|
||||
pub const SKIP_EXTENSIONS: &[&str] = &["sync_tmp", "sync_temp"];
|
||||
|
||||
pub struct FsScanner;
|
||||
|
||||
impl Default for FsScanner {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl FsScanner {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// 递归扫描本地目录
|
||||
/// `compute_hash`: 是否计算文件 quick_hash(MirrorWcf 模式下可跳过以加速扫描)
|
||||
pub async fn scan(
|
||||
&self,
|
||||
root: &Path,
|
||||
depth_limit: u32,
|
||||
follow_symlinks: bool,
|
||||
compute_hash: bool,
|
||||
) -> Result<Vec<LocalFileEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
#[cfg(unix)]
|
||||
let mut visited_inodes: HashSet<(u64, u64)> = HashSet::new();
|
||||
|
||||
let walker = WalkDir::new(root)
|
||||
.max_depth(depth_limit as usize)
|
||||
.follow_links(follow_symlinks);
|
||||
|
||||
for entry in walker {
|
||||
let entry = match entry {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
tracing::warn!("扫描跳过: {}", e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let file_name = entry.file_name().to_string_lossy();
|
||||
let depth = entry.depth();
|
||||
tracing::trace!(
|
||||
"扫描: depth={}, is_dir={}, name={}",
|
||||
depth,
|
||||
entry.file_type().is_dir(),
|
||||
file_name
|
||||
);
|
||||
|
||||
// 符号链接处理
|
||||
if entry.path_is_symlink() && !follow_symlinks {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 跳过同步元数据文件和系统文件
|
||||
let file_name = entry.file_name().to_string_lossy();
|
||||
if SKIP_NAMES.iter().any(|s| file_name == *s) {
|
||||
continue;
|
||||
}
|
||||
// 跳过隐藏目录/文件(以 . 开头)
|
||||
if file_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
if file_name.starts_with(".sync_") {
|
||||
continue;
|
||||
}
|
||||
// 跳过临时文件扩展名
|
||||
if let Some(ext) = entry.path().extension() {
|
||||
if SKIP_EXTENSIONS.iter().any(|e| ext == *e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// 跳过冲突副本文件
|
||||
if crate::utils::is_conflict_file(&file_name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
tracing::warn!("无法读取元数据 {}: {}", entry.path().display(), e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// inode 去重(防止硬链接/符号链接循环)
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let key = (metadata.dev(), metadata.ino());
|
||||
if !visited_inodes.insert(key) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
let relative_path = entry
|
||||
.path()
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(entry.path())
|
||||
.to_path_buf();
|
||||
|
||||
// 跳过根目录自身(relative_path 为空)
|
||||
if relative_path.to_string_lossy().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if metadata.is_dir() {
|
||||
entries.push(LocalFileEntry {
|
||||
relative_path,
|
||||
size: 0,
|
||||
mtime_ms: 0,
|
||||
quick_hash: String::new(),
|
||||
is_dir: true,
|
||||
mime_type: None,
|
||||
});
|
||||
} else if metadata.is_file() {
|
||||
let size = metadata.len();
|
||||
let mtime_ms = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let hash = if compute_hash {
|
||||
quick_hash(entry.path(), size).await.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let mime_type = guess_mime_type(entry.path());
|
||||
|
||||
entries.push(LocalFileEntry {
|
||||
relative_path,
|
||||
size,
|
||||
mtime_ms,
|
||||
quick_hash: hash,
|
||||
is_dir: false,
|
||||
mime_type,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let dirs = entries.iter().filter(|e| e.is_dir).count();
|
||||
let files = entries.iter().filter(|e| !e.is_dir).count();
|
||||
tracing::debug!(
|
||||
"扫描完成: {} 个条目 ({} 目录, {} 文件)",
|
||||
entries.len(),
|
||||
dirs,
|
||||
files
|
||||
);
|
||||
|
||||
Ok(entries)
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据文件扩展名推断 MIME 类型
|
||||
pub fn guess_mime_type(path: &Path) -> Option<String> {
|
||||
mime_guess::from_path(path).first().map(|m| m.to_string())
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
mod frb_generated; /* AUTO INJECTED BY flutter_rust_bridge. This line may not be accurate, and you can change it according to your needs. */
|
||||
pub mod api;
|
||||
pub mod models;
|
||||
pub mod errors;
|
||||
pub mod utils;
|
||||
pub mod sync_db;
|
||||
pub mod api_client;
|
||||
pub mod server_error_code;
|
||||
pub mod fs_scanner;
|
||||
pub mod conflict_resolver;
|
||||
pub mod transfer;
|
||||
pub mod event_handler;
|
||||
pub mod sync_engine;
|
||||
pub mod file_lock;
|
||||
pub mod diff;
|
||||
pub mod uploader;
|
||||
pub mod downloader;
|
||||
pub mod worker;
|
||||
pub mod event_sink;
|
||||
pub mod platform;
|
||||
@@ -0,0 +1,669 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
// ===== 同步状态 =====
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SyncState {
|
||||
Idle,
|
||||
Initializing,
|
||||
InitialSync { progress: InitialSyncProgress },
|
||||
Continuous,
|
||||
Paused,
|
||||
Error { message: String },
|
||||
Stopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct InitialSyncProgress {
|
||||
pub scanned_local: u64,
|
||||
pub scanned_remote: u64,
|
||||
pub uploaded: u64,
|
||||
pub downloaded: u64,
|
||||
pub conflicts: u64,
|
||||
pub total_to_sync: u64,
|
||||
}
|
||||
|
||||
// ===== 同步配置 =====
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncConfig {
|
||||
pub base_url: String,
|
||||
pub access_token: String,
|
||||
pub refresh_token: String,
|
||||
pub local_root: PathBuf,
|
||||
pub remote_root: String,
|
||||
pub sync_mode: SyncMode,
|
||||
pub conflict_strategy: ConflictStrategy,
|
||||
pub wcf_delete_mode: WcfDeleteMode,
|
||||
pub max_concurrent_transfers: usize,
|
||||
pub bandwidth_limit: Option<u64>,
|
||||
pub excluded_paths: Vec<String>,
|
||||
pub max_workers: usize,
|
||||
pub data_dir: PathBuf,
|
||||
pub client_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SyncMode {
|
||||
Full,
|
||||
UploadOnly,
|
||||
DownloadOnly,
|
||||
AlbumUpload,
|
||||
AlbumDownload,
|
||||
MirrorWcf,
|
||||
}
|
||||
|
||||
// ===== 文件条目 =====
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalFileEntry {
|
||||
pub relative_path: PathBuf,
|
||||
pub size: u64,
|
||||
pub mtime_ms: i64,
|
||||
pub quick_hash: String,
|
||||
pub is_dir: bool,
|
||||
pub mime_type: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoteFileEntry {
|
||||
pub uri: String,
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
pub mtime_ms: i64,
|
||||
pub hash: Option<String>,
|
||||
pub is_dir: bool,
|
||||
pub file_id: Option<String>,
|
||||
pub path: String,
|
||||
pub created_at_ms: i64,
|
||||
}
|
||||
|
||||
// ===== 文件映射 =====
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FileMapping {
|
||||
pub id: i64,
|
||||
pub sync_root_id: String,
|
||||
pub local_path: PathBuf,
|
||||
pub remote_uri: String,
|
||||
pub remote_file_id: Option<String>,
|
||||
pub local_hash: Option<String>,
|
||||
pub remote_hash: Option<String>,
|
||||
pub local_mtime: Option<i64>,
|
||||
pub remote_mtime: Option<i64>,
|
||||
pub local_size: Option<u64>,
|
||||
pub remote_size: Option<u64>,
|
||||
pub sync_status: SyncFileStatus,
|
||||
pub is_placeholder: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum SyncFileStatus {
|
||||
Synced,
|
||||
Uploading,
|
||||
Downloading,
|
||||
Conflict,
|
||||
Placeholder,
|
||||
}
|
||||
|
||||
// ===== 冲突 =====
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConflictType {
|
||||
BothModified,
|
||||
DeleteVsModify,
|
||||
NameCollision,
|
||||
TypeMismatch,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ConflictStrategy {
|
||||
KeepLocal,
|
||||
KeepRemote,
|
||||
KeepBoth,
|
||||
NewestWins,
|
||||
LargestWins,
|
||||
Manual,
|
||||
}
|
||||
|
||||
/// 镜像同步(WCF)本地删除模式 — 仅 MirrorWcf 模式下生效
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WcfDeleteMode {
|
||||
/// 仅删除本地,保留远程(可重新水合)
|
||||
LocalOnly,
|
||||
/// 同步删除远程副本
|
||||
SyncRemote,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConflictResolution {
|
||||
UploadLocal,
|
||||
DownloadRemote,
|
||||
RenameLocal { new_name: String },
|
||||
DeleteLocal,
|
||||
DeleteRemote,
|
||||
MarkManual,
|
||||
}
|
||||
|
||||
// ===== 传输 =====
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TransferTask {
|
||||
pub id: i64,
|
||||
pub sync_root_id: String,
|
||||
pub file_mapping_id: Option<i64>,
|
||||
pub direction: TransferDirection,
|
||||
pub local_path: PathBuf,
|
||||
pub remote_uri: String,
|
||||
pub file_size: u64,
|
||||
pub bytes_done: u64,
|
||||
pub status: TransferStatus,
|
||||
pub retry_count: u32,
|
||||
pub max_retries: u32,
|
||||
pub error_message: Option<String>,
|
||||
pub session_id: Option<String>,
|
||||
pub chunk_index: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TransferDirection {
|
||||
Upload,
|
||||
Download,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TransferStatus {
|
||||
Pending,
|
||||
Active,
|
||||
Paused,
|
||||
Completed,
|
||||
Failed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TransferConfig {
|
||||
pub max_concurrent: usize,
|
||||
pub max_retries: u32,
|
||||
pub retry_base_delay_ms: u64,
|
||||
pub retry_max_delay_ms: u64,
|
||||
pub bandwidth_limit: Option<u64>,
|
||||
pub disk_space_reserve: u64,
|
||||
}
|
||||
|
||||
impl Default for TransferConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_concurrent: 3,
|
||||
max_retries: 5,
|
||||
retry_base_delay_ms: 1000,
|
||||
retry_max_delay_ms: 60000,
|
||||
bandwidth_limit: None,
|
||||
disk_space_reserve: 1024 * 1024 * 1024,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 同步摘要 =====
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SyncSummary {
|
||||
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,
|
||||
}
|
||||
|
||||
// ===== 同步状态快照 =====
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncStatusSnapshot {
|
||||
pub state: SyncState,
|
||||
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 enum LocalFileEvent {
|
||||
Created(Vec<PathBuf>),
|
||||
Modified(Vec<PathBuf>),
|
||||
Deleted(Vec<PathBuf>),
|
||||
Renamed {
|
||||
old_paths: Vec<PathBuf>,
|
||||
new_paths: Vec<PathBuf>,
|
||||
},
|
||||
Moved {
|
||||
old_paths: Vec<PathBuf>,
|
||||
new_paths: Vec<PathBuf>,
|
||||
},
|
||||
}
|
||||
|
||||
impl LocalFileEvent {
|
||||
pub fn paths(&self) -> &[PathBuf] {
|
||||
match self {
|
||||
LocalFileEvent::Created(p) => p,
|
||||
LocalFileEvent::Modified(p) => p,
|
||||
LocalFileEvent::Deleted(p) => p,
|
||||
LocalFileEvent::Renamed { .. } | LocalFileEvent::Moved { .. } => &[],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 远程文件事件 =====
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum RemoteFileEvent {
|
||||
Created(RemoteFileEntry),
|
||||
Modified(RemoteFileEntry),
|
||||
Deleted {
|
||||
uri: String,
|
||||
name: String,
|
||||
},
|
||||
Renamed {
|
||||
old_uri: String,
|
||||
new_entry: RemoteFileEntry,
|
||||
},
|
||||
Moved {
|
||||
old_uri: String,
|
||||
new_entry: RemoteFileEntry,
|
||||
},
|
||||
}
|
||||
|
||||
// ===== 平台回调事件 (Windows CFApi) =====
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PlatformCallbackEvent {
|
||||
HydrateRequested {
|
||||
local_path: String,
|
||||
transfer_key: i64,
|
||||
},
|
||||
}
|
||||
|
||||
// ===== 上传会话 =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UploadSession {
|
||||
pub session_id: String,
|
||||
pub chunk_size: u64,
|
||||
/// 远程存储的上传 URL 列表(每个分片一个 URL)
|
||||
/// 仅当 storage_policy_type != "local" 时有值
|
||||
pub upload_urls: Vec<String>,
|
||||
/// 存储策略类型:local / onedrive / s3 / oss / cos / etc.
|
||||
pub storage_policy_type: String,
|
||||
/// 回调密钥(远程存储上传完成后需要回调通知服务端)
|
||||
pub callback_secret: String,
|
||||
/// 上传文件名(仅用于日志标识,并发时区分不同文件)
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
impl UploadSession {
|
||||
/// 是否使用远程存储直接上传(非本地策略)
|
||||
pub fn is_remote_storage(&self) -> bool {
|
||||
self.storage_policy_type != "local" && !self.upload_urls.is_empty()
|
||||
}
|
||||
|
||||
/// 获取第 index 个分片的上传 URL
|
||||
/// 远程存储:返回 upload_urls[index](不足则用最后一个)
|
||||
/// 本地存储:返回 None,由调用方拼接 /file/upload/{session_id}/{index}
|
||||
pub fn chunk_upload_url(&self, index: usize) -> Option<&str> {
|
||||
if !self.is_remote_storage() {
|
||||
return None;
|
||||
}
|
||||
if self.upload_urls.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(self.upload_urls[index.min(self.upload_urls.len() - 1)].as_str())
|
||||
}
|
||||
}
|
||||
|
||||
// ===== API 分页响应 =====
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ListFilesResponse {
|
||||
pub files: Vec<RemoteFileEntry>,
|
||||
pub pagination: Pagination,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Pagination {
|
||||
pub next_page_token: Option<String>,
|
||||
pub is_cursor: bool,
|
||||
pub total: Option<u64>,
|
||||
}
|
||||
|
||||
// ===== 同步计划 =====
|
||||
|
||||
/// 重命名操作(本地触发 → 远程重命名,同目录下改文件名)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenameAction {
|
||||
pub old_relative_path: String,
|
||||
pub new_relative_path: String,
|
||||
pub remote_uri: String,
|
||||
pub new_name: String,
|
||||
}
|
||||
|
||||
/// 移动操作(本地触发 → 远程移动,跨目录)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MoveAction {
|
||||
pub old_relative_path: String,
|
||||
pub new_relative_path: String,
|
||||
pub remote_uri: String,
|
||||
pub dst_remote_dir_uri: String,
|
||||
}
|
||||
|
||||
/// 本地重命名/移动操作(远程触发 → 本地执行)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocalRenameAction {
|
||||
pub old_relative_path: String,
|
||||
pub new_relative_path: String,
|
||||
pub new_remote_uri: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SyncPlan {
|
||||
pub uploads: Vec<SyncAction>,
|
||||
pub downloads: Vec<SyncAction>,
|
||||
pub delete_local: Vec<SyncAction>,
|
||||
pub delete_remote: Vec<SyncAction>,
|
||||
pub rename_remote: Vec<RenameAction>,
|
||||
pub move_remote: Vec<MoveAction>,
|
||||
pub rename_local: Vec<LocalRenameAction>,
|
||||
pub move_local: Vec<LocalRenameAction>,
|
||||
pub conflicts: Vec<SyncConflict>,
|
||||
pub mkdirs_local: Vec<String>,
|
||||
pub mkdirs_remote: Vec<String>,
|
||||
/// 需要递归扫描的顶层目录(持续同步场景,目录整体提交给 Worker)
|
||||
pub scan_dirs: Vec<String>,
|
||||
}
|
||||
|
||||
impl SyncPlan {
|
||||
/// 判断是否有实际操作需要执行(含 scan_dirs 则为非空,因为扫描可能发现新文件)
|
||||
pub fn has_work(&self) -> bool {
|
||||
!self.uploads.is_empty()
|
||||
|| !self.downloads.is_empty()
|
||||
|| !self.delete_local.is_empty()
|
||||
|| !self.delete_remote.is_empty()
|
||||
|| !self.rename_remote.is_empty()
|
||||
|| !self.move_remote.is_empty()
|
||||
|| !self.rename_local.is_empty()
|
||||
|| !self.move_local.is_empty()
|
||||
|| !self.conflicts.is_empty()
|
||||
|| !self.scan_dirs.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncAction {
|
||||
pub relative_path: String,
|
||||
pub local_entry: Option<LocalFileEntry>,
|
||||
pub remote_entry: Option<RemoteFileEntry>,
|
||||
pub db_mapping: Option<FileMapping>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncConflict {
|
||||
pub relative_path: String,
|
||||
pub conflict_type: ConflictType,
|
||||
pub local_entry: Option<LocalFileEntry>,
|
||||
pub remote_entry: Option<RemoteFileEntry>,
|
||||
pub db_mapping: Option<FileMapping>,
|
||||
}
|
||||
|
||||
impl SyncPlan {
|
||||
pub fn total_actions(&self) -> u64 {
|
||||
self.uploads.len() as u64
|
||||
+ self.downloads.len() as u64
|
||||
+ self.delete_local.len() as u64
|
||||
+ self.delete_remote.len() as u64
|
||||
+ self.rename_remote.len() as u64
|
||||
+ self.move_remote.len() as u64
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Worker / Task 类型 =====
|
||||
|
||||
/// Worker 配置快照 — 创建时一次性读取,运行中不读任何锁
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WorkerConfig {
|
||||
pub local_root: PathBuf,
|
||||
pub remote_root: String,
|
||||
pub max_concurrent_transfers: usize,
|
||||
pub bandwidth_limit: Option<u64>,
|
||||
pub conflict_strategy: ConflictStrategy,
|
||||
pub wcf_delete_mode: WcfDeleteMode,
|
||||
pub sync_root_id: String,
|
||||
pub sync_mode: SyncMode,
|
||||
}
|
||||
|
||||
/// Worker 触发来源
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WorkerTrigger {
|
||||
InitialSync,
|
||||
Continuous,
|
||||
Manual,
|
||||
/// WCF 按需水合
|
||||
Hydration,
|
||||
/// WCF 远程事件(占位符/删除/重命名/移动)
|
||||
WcfEvent,
|
||||
}
|
||||
|
||||
impl WorkerTrigger {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
WorkerTrigger::InitialSync => "initial_sync",
|
||||
WorkerTrigger::Continuous => "continuous",
|
||||
WorkerTrigger::Manual => "manual",
|
||||
WorkerTrigger::Hydration => "hydration",
|
||||
WorkerTrigger::WcfEvent => "wcf_event",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker 级别状态
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum WorkerStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl WorkerStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
WorkerStatus::Pending => "pending",
|
||||
WorkerStatus::Running => "running",
|
||||
WorkerStatus::Completed => "completed",
|
||||
WorkerStatus::Failed => "failed",
|
||||
WorkerStatus::Cancelled => "cancelled",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for WorkerStatus {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s {
|
||||
"pending" => Ok(WorkerStatus::Pending),
|
||||
"running" => Ok(WorkerStatus::Running),
|
||||
"completed" => Ok(WorkerStatus::Completed),
|
||||
"failed" => Ok(WorkerStatus::Failed),
|
||||
"cancelled" => Ok(WorkerStatus::Cancelled),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 单文件操作状态
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TaskItemStatus {
|
||||
Pending,
|
||||
Running,
|
||||
Completed,
|
||||
Failed,
|
||||
Skipped,
|
||||
}
|
||||
|
||||
impl TaskItemStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TaskItemStatus::Pending => "pending",
|
||||
TaskItemStatus::Running => "running",
|
||||
TaskItemStatus::Completed => "completed",
|
||||
TaskItemStatus::Failed => "failed",
|
||||
TaskItemStatus::Skipped => "skipped",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for TaskItemStatus {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s {
|
||||
"pending" => Ok(TaskItemStatus::Pending),
|
||||
"running" => Ok(TaskItemStatus::Running),
|
||||
"completed" => Ok(TaskItemStatus::Completed),
|
||||
"failed" => Ok(TaskItemStatus::Failed),
|
||||
"skipped" => Ok(TaskItemStatus::Skipped),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 操作类型 — 精确记录每个文件的操作
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum TaskActionType {
|
||||
Upload,
|
||||
Download,
|
||||
DeleteLocal,
|
||||
DeleteRemote,
|
||||
Rename,
|
||||
Move,
|
||||
MkdirRemote,
|
||||
MkdirLocal,
|
||||
ConflictResolve,
|
||||
CreatePlaceholder,
|
||||
/// WCF 按需水合(用户打开占位符文件时触发下载)
|
||||
Hydration,
|
||||
}
|
||||
|
||||
impl TaskActionType {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
TaskActionType::Upload => "upload",
|
||||
TaskActionType::Download => "download",
|
||||
TaskActionType::DeleteLocal => "delete_local",
|
||||
TaskActionType::DeleteRemote => "delete_remote",
|
||||
TaskActionType::Rename => "rename",
|
||||
TaskActionType::Move => "move",
|
||||
TaskActionType::MkdirRemote => "mkdir_remote",
|
||||
TaskActionType::MkdirLocal => "mkdir_local",
|
||||
TaskActionType::ConflictResolve => "conflict_resolve",
|
||||
TaskActionType::CreatePlaceholder => "create_placeholder",
|
||||
TaskActionType::Hydration => "hydration",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::str::FromStr for TaskActionType {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
|
||||
match s {
|
||||
"upload" => Ok(TaskActionType::Upload),
|
||||
"download" => Ok(TaskActionType::Download),
|
||||
"delete_local" => Ok(TaskActionType::DeleteLocal),
|
||||
"delete_remote" => Ok(TaskActionType::DeleteRemote),
|
||||
"rename" => Ok(TaskActionType::Rename),
|
||||
"move" => Ok(TaskActionType::Move),
|
||||
"mkdir_remote" => Ok(TaskActionType::MkdirRemote),
|
||||
"mkdir_local" => Ok(TaskActionType::MkdirLocal),
|
||||
"conflict_resolve" => Ok(TaskActionType::ConflictResolve),
|
||||
"create_placeholder" => Ok(TaskActionType::CreatePlaceholder),
|
||||
"hydration" => Ok(TaskActionType::Hydration),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Worker 级别的任务摘要 — DB 持久化,供 UI 查询
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncTask {
|
||||
pub id: String,
|
||||
pub trigger: WorkerTrigger,
|
||||
pub total_count: u32,
|
||||
pub completed_count: u32,
|
||||
pub failed_count: u32,
|
||||
pub status: WorkerStatus,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
pub finished_at: Option<String>,
|
||||
}
|
||||
|
||||
/// 单文件操作记录 — DB 持久化,支持多维度查询
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SyncTaskItem {
|
||||
pub id: i64,
|
||||
pub task_id: String,
|
||||
pub relative_path: String,
|
||||
pub action_type: TaskActionType,
|
||||
pub status: TaskItemStatus,
|
||||
pub file_size: u64,
|
||||
pub error_message: Option<String>,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 累积统计(从 DB 聚合,跨所有同步任务)
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SyncCumStats {
|
||||
pub uploaded: u32,
|
||||
pub downloaded: u32,
|
||||
pub renamed: u32,
|
||||
pub moved: u32,
|
||||
pub failed: u32,
|
||||
pub conflicts: u32,
|
||||
pub deleted_local: u32,
|
||||
pub deleted_remote: u32,
|
||||
pub skipped: u32,
|
||||
}
|
||||
|
||||
/// 任务项查询过滤器
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TaskItemFilter {
|
||||
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,
|
||||
}
|
||||
|
||||
// ===== 云端相册检查结果 =====
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CloudAlbumCheckResult {
|
||||
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>,
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,39 @@
|
||||
#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
|
||||
use crate::errors::Result;
|
||||
#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
|
||||
use crate::models::{LocalFileEvent, RemoteFileEntry};
|
||||
/// 平台适配器 trait — 各平台(Windows WCF / Linux FUSE / Android)实现此接口
|
||||
#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
|
||||
use async_trait::async_trait;
|
||||
#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
|
||||
use std::path::Path;
|
||||
#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[cfg(any(feature = "windows-cfapi", feature = "linux-fuse"))]
|
||||
#[async_trait]
|
||||
pub trait PlatformAdapter: Send + Sync {
|
||||
/// 初始同步后的平台初始化
|
||||
async fn post_initial_sync(&self, config: &crate::models::SyncConfig) -> Result<()>;
|
||||
|
||||
/// 监听本地文件变化,返回事件接收器
|
||||
fn watch_local_changes(&self) -> Result<mpsc::Receiver<LocalFileEvent>>;
|
||||
|
||||
/// 创建本地文件占位符(Windows)或实际文件(Linux)
|
||||
async fn create_local_entry(&self, entry: &RemoteFileEntry, local_path: &Path) -> Result<()>;
|
||||
|
||||
/// 水合文件
|
||||
async fn hydrate_file(&self, local_path: &Path, remote_url: &str) -> Result<()>;
|
||||
|
||||
/// 脱水文件
|
||||
async fn dehydrate_file(&self, local_path: &Path) -> Result<()>;
|
||||
|
||||
/// 关闭平台监听
|
||||
async fn shutdown(&self) -> Result<()>;
|
||||
}
|
||||
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
pub mod wcf;
|
||||
|
||||
#[cfg(feature = "linux-fuse")]
|
||||
pub mod fuse;
|
||||
@@ -0,0 +1,152 @@
|
||||
/// Windows Cloud Filter API 平台适配器
|
||||
/// 仅在 windows-cfapi feature 启用时编译
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
use std::path::Path;
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
use std::sync::Arc;
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
use crate::api_client::ApiClient;
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
use crate::errors::Result;
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
use crate::models::SyncConfig;
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
use crate::sync_db::SyncDb;
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
use crate::worker::PlaceholderCreator;
|
||||
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
pub struct WcfPlatformAdapter {
|
||||
adapter: std::sync::Mutex<sync_windows::WindowsAdapter>,
|
||||
fetch_rx: std::sync::Mutex<Option<mpsc::Receiver<sync_windows::FetchDataRequest>>>,
|
||||
#[allow(dead_code)]
|
||||
db: Arc<SyncDb>,
|
||||
#[allow(dead_code)]
|
||||
api: Arc<ApiClient>,
|
||||
#[allow(dead_code)]
|
||||
config: SyncConfig,
|
||||
}
|
||||
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
impl WcfPlatformAdapter {
|
||||
pub fn new(
|
||||
db: Arc<SyncDb>,
|
||||
api: Arc<ApiClient>,
|
||||
config: SyncConfig,
|
||||
) -> anyhow::Result<Self> {
|
||||
let mut adapter = sync_windows::WindowsAdapter::new();
|
||||
|
||||
// 注册同步根目录
|
||||
adapter.register_sync_root(
|
||||
&config.local_root,
|
||||
"Cloudreve4",
|
||||
"1.0",
|
||||
)?;
|
||||
|
||||
// 连接同步根,注册回调
|
||||
adapter.connect_sync_root(&config.local_root)?;
|
||||
let fetch_rx = adapter.take_callback_receiver();
|
||||
|
||||
tracing::info!("WcfPlatformAdapter 初始化完成: {}", config.local_root.display());
|
||||
|
||||
Ok(Self {
|
||||
adapter: std::sync::Mutex::new(adapter),
|
||||
fetch_rx: std::sync::Mutex::new(fetch_rx),
|
||||
db,
|
||||
api,
|
||||
config,
|
||||
})
|
||||
}
|
||||
|
||||
/// 取走 FETCH_DATA 回调接收端(供 SyncEngine 持续同步消费)
|
||||
pub fn take_fetch_receiver(&self) -> Option<mpsc::Receiver<sync_windows::FetchDataRequest>> {
|
||||
self.fetch_rx.lock().unwrap().take()
|
||||
}
|
||||
|
||||
/// 创建占位符文件
|
||||
pub fn create_placeholder_for_remote(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
file_name: &str,
|
||||
file_size: u64,
|
||||
remote_uri: &str,
|
||||
remote_hash: Option<&str>,
|
||||
remote_mtime_ms: i64,
|
||||
) -> Result<()> {
|
||||
let file_identity = serde_json::to_vec(&serde_json::json!({
|
||||
"uri": remote_uri,
|
||||
"size": file_size,
|
||||
"hash": remote_hash.unwrap_or(""),
|
||||
"mtime_ms": remote_mtime_ms,
|
||||
})).unwrap_or_default();
|
||||
|
||||
self.adapter.lock().unwrap().create_single_placeholder(
|
||||
base_dir,
|
||||
file_name,
|
||||
file_size,
|
||||
&file_identity,
|
||||
).map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 水合文件(按需下载)
|
||||
pub fn hydrate_file(&self, local_path: &Path) -> Result<()> {
|
||||
self.adapter.lock().unwrap().hydrate_placeholder(local_path)
|
||||
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
|
||||
}
|
||||
|
||||
/// 脱水文件(释放本地空间)
|
||||
pub fn dehydrate_file(&self, local_path: &Path) -> Result<()> {
|
||||
self.adapter.lock().unwrap().dehydrate_placeholder(local_path)
|
||||
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
|
||||
}
|
||||
|
||||
/// 通过 CfExecute 将数据推送回 CFApi(内核层写入,绕过文件锁)
|
||||
pub fn fulfill_fetch_data(
|
||||
connection_key: i64,
|
||||
transfer_key: i64,
|
||||
data: &[u8],
|
||||
offset: i64,
|
||||
) -> Result<()> {
|
||||
sync_windows::WindowsAdapter::fulfill_fetch_data(connection_key, transfer_key, data, offset)
|
||||
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
|
||||
}
|
||||
|
||||
/// 通过 CfExecute 报告水合失败
|
||||
pub fn reject_fetch_data(
|
||||
connection_key: i64,
|
||||
transfer_key: i64,
|
||||
) -> Result<()> {
|
||||
sync_windows::WindowsAdapter::reject_fetch_data(connection_key, transfer_key)
|
||||
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
|
||||
}
|
||||
|
||||
/// 断开连接
|
||||
pub fn disconnect(&self) -> Result<()> {
|
||||
self.adapter.lock().unwrap().disconnect()
|
||||
.map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "windows-cfapi")]
|
||||
#[async_trait::async_trait]
|
||||
impl PlaceholderCreator for WcfPlatformAdapter {
|
||||
async fn create_placeholder_file(
|
||||
&self,
|
||||
base_dir: &Path,
|
||||
file_name: String,
|
||||
file_size: u64,
|
||||
file_identity: &[u8],
|
||||
) -> Result<()> {
|
||||
self.adapter.lock().unwrap().create_single_placeholder(
|
||||
base_dir,
|
||||
&file_name,
|
||||
file_size,
|
||||
file_identity,
|
||||
).map_err(|e| crate::errors::SyncError::FileSystem(e.to_string()))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
use crate::errors::SyncError;
|
||||
|
||||
/// Cloudreve 服务端错误码 → 中文描述映射
|
||||
pub fn server_code_desc(code: i32) -> Option<&'static str> {
|
||||
Some(match code {
|
||||
// HTTP 语义码
|
||||
203 => "部分操作未成功",
|
||||
401 => "未登录",
|
||||
403 => "无权限访问",
|
||||
404 => "资源不存在",
|
||||
409 => "资源冲突",
|
||||
// 4xxxx 业务码
|
||||
40001 => "参数错误",
|
||||
40002 => "上传失败",
|
||||
40003 => "文件夹创建失败",
|
||||
40004 => "对象已存在",
|
||||
40005 => "签名已过期",
|
||||
40006 => "当前存储策略不允许",
|
||||
40007 => "用户组不允许此操作",
|
||||
40008 => "需要管理员权限",
|
||||
40009 => "主节点未注册",
|
||||
40010 => "需要绑定手机",
|
||||
40011 => "上传会话已过期",
|
||||
40012 => "无效的分片序号",
|
||||
40013 => "无效的 Content-Length",
|
||||
40014 => "批量源大小超限",
|
||||
40016 => "父目录不存在",
|
||||
40017 => "用户被封禁",
|
||||
40018 => "用户未激活",
|
||||
40019 => "功能未启用",
|
||||
40020 => "凭据无效",
|
||||
40021 => "用户不存在",
|
||||
40022 => "两步验证码错误",
|
||||
40023 => "登录会话不存在",
|
||||
40026 => "验证码错误",
|
||||
40027 => "验证码需要刷新",
|
||||
40035 => "存储策略不存在",
|
||||
40044 => "文件未找到",
|
||||
40045 => "列出文件失败",
|
||||
40049 => "文件过大",
|
||||
40050 => "文件类型不允许",
|
||||
40051 => "用户容量不足",
|
||||
40052 => "非法对象名",
|
||||
40053 => "根目录受保护",
|
||||
40054 => "同名文件正在上传",
|
||||
40055 => "元数据不匹配",
|
||||
40057 => "可用存储策略已变更",
|
||||
40071 => "签名无效",
|
||||
40073 => "文件锁定冲突",
|
||||
40074 => "URI 数量过多",
|
||||
40075 => "锁令牌已过期",
|
||||
40077 => "实体不存在",
|
||||
40078 => "文件在回收站中",
|
||||
40079 => "文件数量已达上限",
|
||||
40081 => "批量操作未完全完成",
|
||||
40082 => "仅所有者可操作",
|
||||
// 5xxxx 服务端内部错误
|
||||
50001 => "数据库操作失败",
|
||||
50002 => "加密失败",
|
||||
50004 => "IO 操作失败",
|
||||
50006 => "缓存操作失败",
|
||||
50007 => "回调失败",
|
||||
50010 => "节点离线",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// 将服务端业务错误码转为 SyncError
|
||||
pub fn api_code_to_error(code: i32, msg: &str) -> SyncError {
|
||||
let desc = server_code_desc(code);
|
||||
let detail = if msg.is_empty() {
|
||||
desc.unwrap_or("未知错误").to_string()
|
||||
} else if let Some(d) = desc {
|
||||
if msg != d {
|
||||
format!("{}: {}", d, msg)
|
||||
} else {
|
||||
msg.to_string()
|
||||
}
|
||||
} else {
|
||||
msg.to_string()
|
||||
};
|
||||
|
||||
match code {
|
||||
401 => SyncError::Auth(detail),
|
||||
40004 => SyncError::ObjectExisted,
|
||||
40006 | 40035 | 40057 => SyncError::StoragePolicyDenied(detail),
|
||||
40002 | 40011 | 40012 | 40013 | 40054 => SyncError::UploadFailed(detail),
|
||||
40044 | 40077 => SyncError::FileNotFound(detail),
|
||||
40017 | 40018 | 40007 | 40008 => SyncError::PermissionDenied(detail),
|
||||
_ => SyncError::Network(format!("[{}] {}", code, detail)),
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user