二次开发源码提交

This commit is contained in:
2026-05-15 08:52:48 +08:00
parent 4131f7321a
commit eb35a1d067
191 changed files with 34566 additions and 0 deletions
+124
View File
@@ -0,0 +1,124 @@
import '../data/models/admin_model.dart';
import 'api_service.dart';
/// 管理员服务
class AdminService {
AdminService._internal();
static final AdminService _instance = AdminService._internal();
static AdminService get instance => _instance;
/// 获取用户组列表
Future<AdminGroupListResponse> getGroups({
int page = 1,
int pageSize = 10,
}) async {
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/admin/group',
data: {
'page': page,
'page_size': pageSize,
'order_by': '',
'order_direction': 'desc',
},
);
return AdminGroupListResponse.fromJson(response);
}
/// 获取用户列表
Future<AdminUserListResponse> getUsers({
int page = 1,
int pageSize = 10,
Map<String, dynamic>? conditions,
}) async {
final data = <String, dynamic>{
'page': page,
'page_size': pageSize,
'order_by': '',
'order_direction': 'desc',
};
if (conditions != null) data['conditions'] = conditions;
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/admin/user',
data: data,
);
return AdminUserListResponse.fromJson(response);
}
/// 创建用户组
Future<AdminGroupModel> createGroup(String name) async {
final response = await ApiService.instance.put<Map<String, dynamic>>(
'/admin/group',
data: {
'group': {
'name': name,
'permissions': 'hA==',
'max_storage': 1073741824,
'settings': {
'compress_size': 1073741824,
'decompress_size': 1073741824,
'max_walked_files': 100000,
'trash_retention': 604800,
'source_batch': 10,
'aria2_batch': 1,
'redirected_source': true,
},
'edges': {
'storage_policies': {'id': 1},
},
'id': 0,
},
},
);
return AdminGroupModel.fromJson(response);
}
/// 获取用户组详情(含用户数量)
Future<AdminGroupModel> getGroupDetail(int groupId) async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/admin/group/$groupId',
queryParameters: {'countUser': 'true'},
);
return AdminGroupModel.fromJson(response);
}
/// 删除用户组
Future<void> deleteGroup(int groupId) async {
await ApiService.instance.delete<void>(
'/admin/group/$groupId',
);
}
/// 创建用户
Future<AdminUserModel> createUser({
required String email,
required String nick,
required String password,
required int groupId,
}) async {
final response = await ApiService.instance.put<Map<String, dynamic>>(
'/admin/user',
data: {
'user': {
'edges': {},
'id': 0,
'email': email,
'nick': nick,
'password': password,
'status': 'active',
'group_users': groupId,
},
'password': password,
},
);
return AdminUserModel.fromJson(response);
}
/// 批量删除用户
Future<void> batchDeleteUsers(List<int> ids) async {
await ApiService.instance.post<void>(
'/admin/user/batch/delete',
data: {'ids': ids},
);
}
}
+452
View File
@@ -0,0 +1,452 @@
import 'dart:async';
import 'package:dio/dio.dart';
import '../config/api_config.dart';
import '../core/exceptions/app_exception.dart';
import '../core/utils/app_logger.dart';
/// API响应
class ApiResponse<T> {
final int code;
final String message;
final T? data;
final String? error;
final String? correlationId;
ApiResponse({
required this.code,
required this.message,
this.data,
this.error,
this.correlationId,
});
factory ApiResponse.fromJson(Map<String, dynamic> json) {
return ApiResponse<T>(
code: json['code'] as int? ?? 0,
message: json['msg'] as String? ?? '',
data: json['data'] as T?,
error: json['error'] as String?,
correlationId: json['correlation_id'] as String?,
);
}
bool get isSuccess => code == 0;
bool get isContinue => code == 203;
}
/// API服务
class ApiService {
late Dio _dio;
static ApiService? _instance;
bool _isRefreshing = false;
final List<Completer<void>> _refreshSubscribers = [];
bool _initialized = false;
/// 获取 token 的回调
Future<String?> Function()? getTokenCallback;
/// 刷新 token 的回调
Future<void> Function()? refreshTokenCallback;
/// 清除认证数据的回调
Future<void> Function()? clearAuthCallback;
ApiService._() {
_dio = Dio(
BaseOptions(
baseUrl: ApiConfig.defaultBaseUrl,
connectTimeout: const Duration(seconds: ApiConfig.connectTimeout),
receiveTimeout: const Duration(seconds: ApiConfig.receiveTimeout),
sendTimeout: const Duration(seconds: ApiConfig.sendTimeout),
headers: {'Content-Type': 'application/json'},
),
);
_dio.interceptors.add(_requestInterceptor());
_dio.interceptors.add(_responseInterceptor());
_dio.interceptors.add(_errorInterceptor());
}
/// 是否正在刷新token
bool get isRefreshing => _isRefreshing;
/// 暴露 Dio 实例(用于二进制下载等不走 _parseResponse 的场景)
Dio get dio => _dio;
/// 获取单例
static ApiService get instance {
_instance ??= ApiService._();
return _instance!;
}
/// 设置认证回调
/// 由 AuthProvider 在初始化时调用
static void setAuthCallbacks({
required Future<String?> Function() getToken,
required Future<void> Function() refreshToken,
required Future<void> Function() clearAuth,
}) {
final service = instance;
service.getTokenCallback = getToken;
service.refreshTokenCallback = refreshToken;
service.clearAuthCallback = clearAuth;
}
/// 初始化API服务(设置正确的baseUrl)
Future<void> init() async {
if (_initialized) return;
final baseUrl = await ApiConfig.baseUrl;
_dio.options.baseUrl = baseUrl;
_initialized = true;
}
/// 动态设置 API baseUrl
Future<void> setBaseUrl(String baseUrl) async {
_dio.options.baseUrl = baseUrl;
AppLogger.d('ApiService baseUrl 已更新为: $baseUrl');
}
/// 请求拦截器
Interceptor _requestInterceptor() {
return InterceptorsWrapper(
onRequest: (options, handler) async {
// 从回调获取 Token
if (getTokenCallback != null) {
final token = await getTokenCallback!();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
}
return handler.next(options);
},
);
}
/// 响应拦截器
Interceptor _responseInterceptor() {
return InterceptorsWrapper(
onResponse: (response, handler) {
AppLogger.d(
'API Response: ${response.statusCode} - ${response.requestOptions.uri}',
);
// 检查 JSON 响应中的 code 字段
if (response.data is Map<String, dynamic>) {
final data = response.data as Map<String, dynamic>;
final code = data['code'] as int?;
AppLogger.d('_responseInterceptor -> JSON code: $code');
if (code == 401) {
// HTTP 200 但 JSON code 是 401,需要处理未授权
final isNoAuth =
response.requestOptions.extra['noAuth'] as bool? ?? false;
AppLogger.d('_responseInterceptor -> isNoAuth: $isNoAuth');
if (!isNoAuth) {
// 直接在响应拦截器中处理 401
AppLogger.d('_responseInterceptor -> 触发 401 处理');
// 异步处理,不阻塞响应
_handle401InResponse(response.requestOptions);
}
}
}
return handler.next(response);
},
);
}
/// 在响应拦截器中处理 401 错误
Future<void> _handle401InResponse(RequestOptions requestOptions) async {
final path = requestOptions.path;
if (path.contains('/session/token/refresh')) {
return;
}
if (_isRefreshing) {
return;
}
_isRefreshing = true;
try {
AppLogger.d('_handle401InResponse -> 开始刷新 token');
if (refreshTokenCallback != null) {
await refreshTokenCallback!();
}
AppLogger.d('_handle401InResponse -> token 刷新完成');
} catch (e) {
AppLogger.d('_handle401InResponse -> 刷新失败: $e');
if (clearAuthCallback != null) {
await clearAuthCallback!();
}
} finally {
_isRefreshing = false;
}
}
/// 错误拦截器
Interceptor _errorInterceptor() {
return InterceptorsWrapper(
onError: (error, handler) async {
AppLogger.d("_errorInterceptor -> 获取files列表: response");
AppLogger.d('API Error: ${error.requestOptions.uri} - ${error.message}');
// 检查是否是 401 错误(HTTP 401 或 JSON code: 401
bool is401Error = error.response?.statusCode == 401;
if (!is401Error && error.response?.data is Map<String, dynamic>) {
final data = error.response!.data as Map<String, dynamic>;
is401Error = data['code'] == 401;
}
if (is401Error) {
final isNoAuth =
error.requestOptions.extra['noAuth'] as bool? ?? false;
if (!isNoAuth) {
// 不是noAuth请求,需要刷新token
final response = await _handle401Error(error, handler);
if (response != null) {
return handler.resolve(response);
}
}
}
// 处理错误
if (error.response == null) {
// 网络错误
throw NetworkException(
'网络连接失败,请检查网络设置',
code: error.response?.statusCode,
);
}
final statusCode = error.response?.statusCode;
final responseData = error.response?.data;
AppLogger.d('Error Response Data: $responseData');
if (responseData is Map<String, dynamic>) {
final response = ApiResponse.fromJson(responseData);
throw ServerException(response.message, code: response.code);
}
throw ServerException(
responseData?.toString() ?? '请求失败',
code: statusCode,
);
},
);
}
/// 处理401错误,尝试刷新token
Future<Response?> _handle401Error(
DioException error,
ErrorInterceptorHandler handler,
) async {
// 检查是否需要跳过token检查(如刷新token请求本身)
final path = error.requestOptions.path;
if (path.contains('/session/token/refresh')) {
// 刷新token的请求也失败了,直接返回错误
return null;
}
// 如果正在刷新token,等待刷新完成后再重试
if (_isRefreshing) {
final completer = Completer<void>();
_refreshSubscribers.add(completer);
// 等待刷新完成
await completer.future;
// 刷新完成后,移除旧的 Authorization header,让拦截器重新添加新 token
error.requestOptions.headers.remove('Authorization');
// 重试请求(拦截器会自动添加新 token)
return await _dio.fetch(error.requestOptions);
}
// 开始刷新token
_isRefreshing = true;
try {
// 调用回调刷新 token
if (refreshTokenCallback != null) {
await refreshTokenCallback!();
}
_isRefreshing = false;
// 通知所有等待的请求可以重试了
for (final subscriber in _refreshSubscribers) {
if (!subscriber.isCompleted) {
subscriber.complete();
}
}
_refreshSubscribers.clear();
// 重试当前请求:移除旧 header,让拦截器重新添加新 token
error.requestOptions.headers.remove('Authorization');
return await _dio.fetch(error.requestOptions);
} catch (e) {
AppLogger.d('Refresh token failed: $e');
_isRefreshing = false;
// 刷新失败,清除认证数据
if (clearAuthCallback != null) {
await clearAuthCallback!();
}
// 通知所有等待的请求
for (final subscriber in _refreshSubscribers) {
if (!subscriber.isCompleted) {
subscriber.completeError(e);
}
}
_refreshSubscribers.clear();
// 返回null,让原始错误继续传播
return null;
}
}
/// GET请求 , 如果是分享请求, 则不进入 _parseResponse
Future<T> get<T>(
String path, {
Map<String, dynamic>? queryParameters,
bool noAuth = false,
bool isNoData = false,
Map<String, dynamic>? headers,
}) async {
final response = await _dio.get<T>(
path,
queryParameters: queryParameters,
options: Options(extra: {'noAuth': noAuth}, headers: headers),
);
// 如果是分享请求, 则不进入 _parseResponse
if (isNoData) {
return response.data as T;
}
return _parseResponse<T>(response);
}
/// POST请求
Future<T> post<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
bool noAuth = false,
Map<String, dynamic>? headers,
bool isNoData = false,
}) async {
AppLogger.d('API POST Request: $path');
AppLogger.d('Request Data: $data');
final response = await _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: Options(extra: {'noAuth': noAuth}, headers: headers),
);
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 (isNoData || isActivEmail == 203) {
return response.data as T;
}
return _parseResponse<T>(response);
}
/// POST请求(带上传进度)
Future<T> postWithProgress<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
bool noAuth = false,
Map<String, dynamic>? headers,
ProgressCallback? onSendProgress,
}) async {
AppLogger.d('API POST Request with progress: $path');
final response = await _dio.post<T>(
path,
data: data,
queryParameters: queryParameters,
options: Options(extra: {'noAuth': noAuth}, headers: headers),
onSendProgress: onSendProgress,
);
AppLogger.d('Response Data: ${response.data}');
return _parseResponse<T>(response);
}
/// PUT请求
Future<T> put<T>(
String path, {
dynamic data,
bool noAuth = false,
bool isNoData = false,
}) async {
final response = await _dio.put<T>(
path,
data: data,
options: Options(extra: {'noAuth': noAuth}),
);
// 当请求的接口为创建分享时, 逻辑上不适合走到 _parseResponse -> ApiResponse.fromJson 直接返回结果即可
if (isNoData) {
return response.data as T;
}
return _parseResponse<T>(response);
}
/// PATCH请求
Future<T> patch<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
bool noAuth = false,
}) async {
final response = await _dio.patch<T>(
path,
data: data,
queryParameters: queryParameters,
options: Options(extra: {'noAuth': noAuth}),
);
return _parseResponse<T>(response);
}
/// DELETE请求
Future<T> delete<T>(
String path, {
dynamic data,
Map<String, dynamic>? queryParameters,
bool noAuth = false,
}) async {
final response = await _dio.delete<T>(
path,
data: data,
queryParameters: queryParameters,
options: Options(extra: {'noAuth': noAuth}),
);
return _parseResponse<T>(response);
}
/// 解析响应
T _parseResponse<T>(Response response) {
final data = response.data;
if (data is Map<String, dynamic>) {
final apiResponse = ApiResponse<T>.fromJson(data);
if (!apiResponse.isSuccess && !apiResponse.isContinue) {
throw ServerException(apiResponse.message, code: apiResponse.code);
}
return apiResponse.data as T;
}
return data as T;
}
}
+231
View File
@@ -0,0 +1,231 @@
import '../data/models/user_model.dart';
import 'api_service.dart';
import '../core/utils/app_logger.dart';
import '../core/exceptions/app_exception.dart';
/// 认证服务
class AuthService {
// 私有化构造函数,防止在外部被直接实例化
AuthService._internal();
// 存储单例的静态私有变量
static final AuthService _instance = AuthService._internal();
// 公开一个 getter 叫 instance (或者使用工厂构造函数)
static AuthService get instance => _instance;
/// 准备登录
Future<Map<String, bool>> prepareLogin(String email) async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/session/prepare',
queryParameters: {'email': email},
noAuth: true,
);
return response as Map<String, bool>;
}
/// 密码登录
Future<LoginResponseModel> passwordLogin({
required String email,
required String password,
String? captcha,
}) async {
final data = <String, dynamic>{
'email': email,
'password': password,
...captcha != null ? {'captcha': captcha} : {},
};
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/session/token',
data: data,
noAuth: true,
);
AppLogger.d('AuthService -> 登录响应: $response');
// code 203 表示需要两步验证
final code = response['code'] as int?;
if (code == 203) {
final sessionId = response['data'] as String;
throw TwoFactorRequiredException(sessionId);
}
return LoginResponseModel.fromJson(response);
}
/// 2FA登录
Future<LoginResponseModel> twoFactorLogin({
required String otp,
required String sessionId,
}) async {
final data = <String, dynamic>{'otp': otp, 'session_id': sessionId};
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/session/token/2fa',
data: data,
noAuth: true,
);
return LoginResponseModel.fromJson(response);
}
/// 刷新Token
/// 这个方法现在由 ApiService 调用,传入当前的 refreshToken
Future<TokenModel> refreshToken(String refreshToken) async {
final data = <String, dynamic>{'refresh_token': refreshToken};
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/session/token/refresh',
data: data,
noAuth: true,
);
return TokenModel.fromJson(response);
}
/// 登出
/// 现在由 ServerService 和 AuthProvider 负责清除本地数据
Future<void> logout() async {
try {
// 登出需要调用 API,但 refreshToken 由调用方提供
// 这个方法现在主要用于调用登出 API
await ApiService.instance.delete<void>(
'/session/token',
data: <String, dynamic>{},
noAuth: true,
);
} catch (e) {
// 登出失败也要清除本地数据(由调用方处理)
rethrow;
}
}
/// 获取当前用户信息
Future<UserModel> getCurrentUser() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/user/me',
);
return UserModel.fromJson(response);
}
/// 获取用户容量
Future<CapacityModel> getUserCapacity() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/user/capacity',
);
return CapacityModel.fromJson(response);
}
/// 发送重置密码邮件
Future<void> sendResetPasswordEmail({
required String email,
String? captcha,
String? ticket,
}) async {
final data = <String, dynamic>{
'email': email,
...captcha != null ? {'captcha': captcha} : {},
...ticket != null ? {'ticket': ticket} : {},
};
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/user/reset',
data: data,
noAuth: true,
isNoData: true,
);
final code = response['code'] as int?;
if (code != 0) {
final msg = response['msg'] as String? ?? '发送失败';
throw Exception(msg);
}
}
/// 用户注册
Future<SignUpResponse> signUp({
required String email,
required String password,
String? language,
String? captcha,
String? ticket,
}) async {
final data = <String, dynamic>{
'email': email,
'password': password,
...language != null ? {'language': language} : {},
...captcha != null ? {'captcha': captcha} : {},
...ticket != null ? {'ticket': ticket} : {},
};
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/user',
data: data,
noAuth: true,
);
final code = response['code'] as int?;
final msg = response['msg'] as String?;
if (code != 0 && code != 203) {
throw Exception('注册失败: $msg');
}
return SignUpResponse(
code: code ?? 0,
msg: msg,
requiresEmailActivation: code == 203,
);
}
}
/// 注册响应
class SignUpResponse {
final int code;
final String? msg;
final bool requiresEmailActivation;
SignUpResponse({
required this.code,
this.msg,
required this.requiresEmailActivation,
});
}
/// 登录响应模型
/// 这个模型现在将 token 合并到 user 中返回
class LoginResponseModel {
final UserModel user;
LoginResponseModel({required this.user});
factory LoginResponseModel.fromJson(Map<String, dynamic> json) {
// 检查是否为错误响应(包含 code 和 msg 但没有 data 或 user
final code = json['code'] as int?;
final msg = json['msg'] as String?;
final Map<String, dynamic>? data = json['data'] as Map<String, dynamic>?;
// 如果 code 不是 0,说明是错误响应
if (code != null && code != 0) {
throw Exception(msg ?? '登录失败');
}
// 如果没有 data 且也没有 user,说明是错误响应
if (data == null && json['user'] == null) {
throw Exception(msg ?? '登录失败');
}
final Map<String, dynamic> userData = data ?? json;
final userJson = userData['user'] as Map<String, dynamic>;
// 将 token 合并到 user 中
userJson['token'] = userData['token'];
return LoginResponseModel(user: UserModel.fromJson(userJson));
}
Map<String, dynamic> toJson() {
return {'user': user.toJson()};
}
}
+300
View File
@@ -0,0 +1,300 @@
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:cloudreve4_flutter/core/utils/avatar_utils.dart';
/// 头像缓存服务
/// 通过 Dio 下载头像,保存为本地文件 {userId}_avatar.webp
/// 服务器头像 200 优先,否则使用 Gravatar (identicon 兜底)
class AvatarCacheService extends ChangeNotifier {
static AvatarCacheService? _instance;
static AvatarCacheService get instance {
_instance ??= AvatarCacheService._();
return _instance!;
}
AvatarCacheService._();
Directory? _cacheDir;
final Map<String, String> _pathCache = {};
final Map<String, int> _sizeCache = {};
final Set<String> _loadingSet = {};
/// 初始化缓存目录
Future<void> init() async {
final appDir = await getApplicationSupportDirectory();
_cacheDir = Directory('${appDir.path}/avatar_cache');
if (!_cacheDir!.existsSync()) {
_cacheDir!.createSync(recursive: true);
}
_scanExistingFiles();
}
void _scanExistingFiles() {
if (_cacheDir == null) return;
for (final entity in _cacheDir!.listSync()) {
if (entity is File && entity.path.endsWith('_avatar.webp')) {
final name = entity.uri.pathSegments.last;
final userId = name.replaceAll('_avatar.webp', '');
_pathCache[userId] = entity.path;
try {
_sizeCache[userId] = entity.lengthSync();
} catch (_) {}
}
}
}
String _filePath(String userId) => '${_cacheDir!.path}/${userId}_avatar.webp';
/// 头像缓存文件是否存在(同步)
bool avatarIsExist(String userId) {
if (userId.isEmpty) return false;
final cached = _pathCache[userId];
if (cached != null && File(cached).existsSync()) return true;
return File(_filePath(userId)).existsSync();
}
/// 检查服务器头像是否有更新(HEAD 请求比对 Content-Length
/// 返回 true 表示已更新并重新缓存,false 表示无需更新或检查失败
Future<bool> avatarIsUpdated(String userId, String baseUrl, String token) async {
if (userId.isEmpty) return false;
if (!avatarIsExist(userId)) {
// 本地无缓存,直接走 getAvatar
return await getAvatar(userId, baseUrl: baseUrl, token: token);
}
try {
final url = AvatarUtils.getServerAvatarUrl(baseUrl, userId);
// 使用独立 Dio 实例,避免 ApiService 拦截器干扰
final dio = Dio();
final response = await dio.head<void>(
url,
options: Options(
headers: {'Authorization': 'Bearer $token'},
validateStatus: (s) => s != null && s < 500,
followRedirects: false,
),
);
// 非 200(包括 302 指向 Gravatar)视为无自定义头像,无需比对更新
if (response.statusCode != 200) return false;
final remoteSize = response.headers.value('content-length');
if (remoteSize == null) return false;
final remoteBytes = int.tryParse(remoteSize) ?? -1;
final localBytes = _sizeCache[userId] ??
(File(_filePath(userId)).existsSync()
? File(_filePath(userId)).lengthSync()
: -1);
AppLogger.d('头像更新检查 ($userId): 本地=$localBytes, 远程=$remoteBytes');
if (remoteBytes != localBytes) {
// 大小不一致,重新获取
return await getAvatar(userId, baseUrl: baseUrl, token: token);
}
return false;
} catch (e) {
AppLogger.d('头像更新检查失败 ($userId): $e');
return false;
}
}
/// 获取头像:并发请求服务器头像 + Gravatar,服务器 200 优先,否则用 Gravatar
/// 返回 true 表示成功获取并缓存,false 表示全部失败
Future<bool> getAvatar(String userId,
{String? baseUrl, String? token, String? email}) async {
if (userId.isEmpty || _cacheDir == null) return false;
// 防止同一 userId 并发
if (_loadingSet.contains(userId)) return false;
_loadingSet.add(userId);
try {
final futures = <Future<_AvatarResult>>[];
// 1. 服务器头像
if (baseUrl != null && baseUrl.isNotEmpty && token != null && token.isNotEmpty) {
futures.add(_fetchServerAvatar(userId, baseUrl, token));
}
// 2. Gravatar (identicon 兜底)
if (email != null && email.isNotEmpty) {
futures.add(_fetchGravatar(userId, email));
}
if (futures.isEmpty) return false;
// 并发请求
final results = await Future.wait(futures);
final serverResult = results.whereType<_ServerAvatarResult>().firstOrNull;
final gravatarResult = results.whereType<_GravatarResult>().firstOrNull;
// 服务器头像 200 优先
if (serverResult != null && serverResult.statusCode == 200 && serverResult.bytes != null) {
_saveFile(userId, serverResult.bytes!);
AppLogger.d('头像获取成功 (服务器, $userId), 大小=${serverResult.bytes!.length}');
notifyListeners();
return true;
}
// 服务器 404/302 或失败,用 Gravatar
if (gravatarResult != null && gravatarResult.bytes != null) {
_saveFile(userId, gravatarResult.bytes!);
AppLogger.d('头像获取成功 (Gravatar, $userId), 大小=${gravatarResult.bytes!.length}');
notifyListeners();
return true;
}
AppLogger.d('头像获取失败 ($userId): 服务器状态=${serverResult?.statusCode}, Gravatar=${gravatarResult != null ? '失败' : '未请求'}');
return false;
} finally {
_loadingSet.remove(userId);
}
}
Future<_AvatarResult> _fetchServerAvatar(String userId, String baseUrl, String token) async {
try {
final url = AvatarUtils.getServerAvatarUrl(baseUrl, userId);
// 使用独立 Dio 实例,避免 ApiService 拦截器干扰
final dio = Dio();
final response = await dio.get<List<int>>(
url,
options: Options(
responseType: ResponseType.bytes,
headers: {'Authorization': 'Bearer $token'},
validateStatus: (s) => s != null && s < 500,
// 不跟随重定向:302 指向官方 Gravatar,跟随会绕过镜像加速
followRedirects: false,
),
);
return _ServerAvatarResult(response.statusCode ?? 0, response.data);
} catch (e) {
AppLogger.d('请求服务器头像异常 ($userId): $e');
return _ServerAvatarResult(0, null);
}
}
Future<_AvatarResult> _fetchGravatar(String userId, String email) async {
try {
final url = await AvatarUtils.getGravatarUrl(email, size: 400);
final dio = Dio();
final response = await dio.get<List<int>>(
url,
options: Options(
responseType: ResponseType.bytes,
validateStatus: (s) => s != null && s < 500,
),
);
if (response.statusCode == 200 && response.data != null && response.data!.isNotEmpty) {
return _GravatarResult(response.data!);
}
return _GravatarResult(null);
} catch (e) {
AppLogger.d('请求Gravatar头像异常 ($userId): $e');
return _GravatarResult(null);
}
}
void _saveFile(String userId, List<int> bytes) {
if (_cacheDir == null) return;
final path = _filePath(userId);
final file = File(path);
file.writeAsBytesSync(bytes);
_pathCache[userId] = path;
_sizeCache[userId] = bytes.length;
}
/// 获取已缓存的头像文件(同步,快速)
File? getCachedFile(String userId) {
if (_cacheDir == null || userId.isEmpty) return null;
final cached = _pathCache[userId];
if (cached != null && File(cached).existsSync()) {
return File(cached);
}
final path = _filePath(userId);
if (File(path).existsSync()) {
_pathCache[userId] = path;
return File(path);
}
_pathCache.remove(userId);
_sizeCache.remove(userId);
return null;
}
/// 批量检查头像是否需要更新(限制并发,避免密集 IO/网络阻塞主线程)
Future<void> batchCheckUpdates(
List<String> userIds, {
required String baseUrl,
required String token,
int concurrency = 3,
}) async {
var index = 0;
final completer = Completer<void>();
var activeCount = 0;
var completedCount = 0;
final total = userIds.length;
void scheduleNext() {
while (activeCount < concurrency && index < total) {
final userId = userIds[index++];
activeCount++;
avatarIsUpdated(userId, baseUrl, token).whenComplete(() {
activeCount--;
completedCount++;
if (completedCount >= total && !completer.isCompleted) {
completer.complete();
} else {
scheduleNext();
}
});
}
if (completedCount >= total && !completer.isCompleted) {
completer.complete();
}
}
scheduleNext();
await completer.future;
}
/// 清除指定用户的头像缓存(头像上传/切换后调用)
Future<void> evictCache(String userId) async {
_pathCache.remove(userId);
_sizeCache.remove(userId);
final path = _filePath(userId);
final file = File(path);
if (file.existsSync()) {
await file.delete();
}
notifyListeners();
}
/// 清除所有头像缓存(登出时调用)
Future<void> clearAllCache() async {
_pathCache.clear();
_sizeCache.clear();
if (_cacheDir != null && _cacheDir!.existsSync()) {
await _cacheDir!.delete(recursive: true);
_cacheDir!.createSync(recursive: true);
}
notifyListeners();
}
}
/// 请求结果基类
abstract class _AvatarResult {}
class _ServerAvatarResult extends _AvatarResult {
final int statusCode;
final List<int>? bytes;
_ServerAvatarResult(this.statusCode, this.bytes);
}
class _GravatarResult extends _AvatarResult {
final List<int>? bytes;
_GravatarResult(this.bytes);
}
+248
View File
@@ -0,0 +1,248 @@
import 'dart:convert';
import 'dart:io';
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:path_provider/path_provider.dart';
import '../data/models/cache_settings_model.dart';
import '../core/constants/storage_keys.dart';
import 'storage_service.dart';
/// 缓存管理器服务
class CacheManagerService {
static CacheManagerService? _instance;
BaseCacheManager? _manager;
CacheSettingsModel _settings = CacheSettingsModel();
CacheManagerService._();
/// 获取单例
static CacheManagerService get instance {
_instance ??= CacheManagerService._();
return _instance!;
}
/// 初始化
Future<void> initialize() async {
try {
await loadSettings();
await _initializeManager();
} catch (e) {
// 忽略初始化错误,使用默认值
AppLogger.e('CacheManagerService initialize error: $e');
}
}
/// 初始化管理器
Future<void> _initializeManager() async {
_manager = CacheManager(
Config(
'cloudreve_cache',
stalePeriod: Duration(milliseconds: _settings.cacheExpireDuration),
maxNrOfCacheObjects: 1000,
),
);
}
/// 获取缓存管理器
BaseCacheManager get manager {
if (_manager == null) {
throw Exception('CacheManagerService 未初始化,请先调用 initialize()');
}
return _manager!;
}
/// 获取缓存目录
Future<Directory> getCacheDir() async {
final tempDir = await getTemporaryDirectory();
final cacheDir = Directory('${tempDir.path}/cloudreve_cache');
if (!cacheDir.existsSync()) {
cacheDir.createSync(recursive: true);
}
return cacheDir;
}
/// 获取当前缓存大小
Future<int> getCacheSize() async {
final cacheDir = await getCacheDir();
if (!cacheDir.existsSync()) {
return 0;
}
try {
int totalSize = 0;
final entities = cacheDir.listSync(recursive: true, followLinks: false);
for (final entity in entities) {
if (entity is File) {
try {
totalSize += entity.lengthSync();
} catch (e) {
// 忽略无法读取的文件
}
}
}
return totalSize;
} catch (e) {
return 0;
}
}
/// 清空缓存
Future<void> clearCache() async {
final cacheDir = await getCacheDir();
if (cacheDir.existsSync()) {
try {
await cacheDir.delete(recursive: true);
} on PathAccessException {
// Windows 文件占用,逐个删除可删除的文件
try {
final entities = cacheDir.listSync(recursive: true, followLinks: false);
for (final entity in entities) {
if (entity is File) {
try { await entity.delete(); } catch (_) {}
}
}
try { await cacheDir.delete(recursive: true); } catch (_) {}
} catch (_) {}
}
}
try {
await _manager?.emptyCache();
} on PathAccessException {
// nothing to do
}
await _initializeManager();
}
/// 清理过期文件
Future<void> cleanExpiredFiles() async {
try {
await manager.emptyCache();
} catch (e) {
// 忽略清理错误
}
}
/// 加载设置
Future<void> loadSettings() async {
final settingsJson = await StorageService.instance.getString(
StorageKeys.cacheSettings,
);
if (settingsJson != null && settingsJson.isNotEmpty) {
try {
final settings = CacheSettingsModel.fromJson(
jsonDecode(settingsJson) as Map<String, dynamic>,
);
_settings = settings;
} catch (e) {
// 使用默认设置
}
}
}
/// 保存设置
Future<void> saveSettings(CacheSettingsModel settings) async {
_settings = settings;
await StorageService.instance.setString(
StorageKeys.cacheSettings,
jsonEncode(settings.toJson()),
);
await _initializeManager();
}
/// 获取当前设置
CacheSettingsModel get settings => _settings;
/// 获取缓存文件信息
Future<List<CacheFileInfo>> getCacheFiles() async {
final cacheDir = await getCacheDir();
final files = <CacheFileInfo>[];
if (!cacheDir.existsSync()) {
return files;
}
await for (final entity in cacheDir.list(recursive: true, followLinks: false)) {
if (entity is File) {
try {
final stat = await entity.stat();
files.add(
CacheFileInfo(
path: entity.path,
name: entity.uri.pathSegments.last,
size: stat.size,
lastModified: stat.modified,
),
);
} catch (e) {
// 忽略无法读取的文件
}
}
}
return files;
}
/// 删除指定的缓存文件
Future<void> deleteCacheFile(String path) async {
final file = File(path);
if (file.existsSync()) {
try {
await file.delete();
} on PathAccessException {
// Windows 文件占用,忽略
}
}
}
/// 自动清理最旧文件(当超过最大缓存大小时)
Future<void> autoCleanOldFiles() async {
if (!_settings.autoCleanOldFiles) {
return;
}
final currentSize = await getCacheSize();
if (currentSize <= _settings.maxCacheSize) {
return;
}
final files = await getCacheFiles();
// 按最后修改时间排序,最旧的在前
files.sort((a, b) => a.lastModified.compareTo(b.lastModified));
int deletedSize = 0;
for (final file in files) {
if (currentSize - deletedSize <= _settings.maxCacheSize) {
break;
}
await deleteCacheFile(file.path);
deletedSize += file.size;
}
}
}
/// 缓存文件信息
class CacheFileInfo {
final String path;
final String name;
final int size;
final DateTime lastModified;
CacheFileInfo({
required this.path,
required this.name,
required this.size,
required this.lastModified,
});
String get sizeReadable {
if (size < 1024) {
return '$size B';
} else if (size < 1024 * 1024) {
return '${(size / 1024).toStringAsFixed(1)} KB';
} else if (size < 1024 * 1024 * 1024) {
return '${(size / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(size / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
}
+211
View File
@@ -0,0 +1,211 @@
import 'dart:io';
import 'package:flutter_acrylic/window.dart';
import 'package:flutter_acrylic/window_effect.dart';
import 'package:path/path.dart' as p;
import 'package:flutter/material.dart';
import 'package:window_manager/window_manager.dart';
import 'package:tray_manager/tray_manager.dart';
import '../config/app_config.dart';
import '../core/utils/app_logger.dart';
import '../presentation/providers/theme_provider.dart';
/// 桌面端服务(窗口管理 + 系统托盘)
class DesktopService with TrayListener, WindowListener {
static DesktopService? _instance;
DesktopService._();
static DesktopService get instance {
_instance ??= DesktopService._();
return _instance!;
}
static bool get isDesktopPlatform =>
Platform.isWindows || Platform.isLinux;
bool _initialized = false;
/// 初始化桌面端服务,必须在 runApp 之前调用
Future<void> initialize() async {
if (!isDesktopPlatform || _initialized) return;
// 1. 初始化窗口管理器
await windowManager.ensureInitialized();
windowManager.addListener(this);
// --- 新增:初始化 flutter_acrylic ---
if (Platform.isWindows) {
var themeProvider = ThemeProvider();
await Window.initialize();
// 设置 Mica 效果
await Window.setEffect(
effect: WindowEffect.mica,
// 根据你的 ThemeProvider 判断是深色还是浅色 Mica
dark: themeProvider.isDark, // 建议这里先写死 false 测试,后续对接 ThemeProvider
);
}
// 2. 窗口选项设置
WindowOptions windowOptions = const WindowOptions(
size: Size(1280, 720),
center: true,
backgroundColor: Colors.transparent,
skipTaskbar: false,
titleBarStyle: TitleBarStyle.hidden,
);
// 3. 等待窗口准备就绪后执行居中和显示
await windowManager.waitUntilReadyToShow(windowOptions, () async {
await windowManager.setTitle(AppConfig.appName);
await windowManager.setMinimumSize(const Size(400, 300));
await windowManager.setPreventClose(true);
// 如果设置了 center: true 仍未生效(某些 Linux 环境),可以手动补刀:
// await windowManager.center();
await windowManager.show();
await windowManager.focus();
});
// 托盘管理器
trayManager.addListener(this);
final iconPath = await _getTrayIconPath();
AppLogger.d('DesktopService: tray icon path: $iconPath');
await trayManager.setIcon(iconPath);
try {
await trayManager.setToolTip(AppConfig.appName);
} catch (e) {
AppLogger.e('DesktopService: tray icon error: $e');
}
final menu = Menu(items: [
MenuItem(key: 'show', label: '显示主窗口'),
MenuItem.separator(),
MenuItem(key: 'quit', label: '退出'),
]);
await trayManager.setContextMenu(menu);
_initialized = true;
AppLogger.d('DesktopService initialized');
}
Future<String> _getTrayIconPath() async {
if (Platform.isWindows) {
// 获取当前可执行文件 (.exe) 所在的目录
String exePath = Platform.resolvedExecutable;
String exeDir = p.dirname(exePath);
// 拼接 Windows 下 Flutter Assets 的标准物理路径
// 注意:这里的路径必须与打包后的文件夹结构一致
return p.join(exeDir, 'data', 'flutter_assets', 'assets/icons/tray_icon.ico');
} else if (Platform.isLinux) {
return '/opt/cloudreve4/data/flutter_assets/assets/icons/tray_icon.png';
}
// 调试模式下通常直接用 assets 路径
return 'assets/icons/tray_icon.png';
}
// ========== TrayListener ==========
@override
void onTrayIconMouseDown() {
showWindow();
}
@override
void onTrayIconRightMouseDown() {
trayManager.popUpContextMenu();
}
@override
void onTrayIconRightMouseUp() {}
@override
void onTrayMenuItemClick(MenuItem menuItem) {
switch (menuItem.key) {
case 'show':
showWindow();
break;
case 'quit':
_quitApp();
break;
}
}
// ========== WindowListener ==========
@override
void onWindowClose() async {
AppLogger.d('DesktopService: onWindowClose -> hiding to tray');
await windowManager.hide();
}
@override
void onWindowFocus() {}
@override
void onWindowBlur() {}
@override
void onWindowMaximize() {}
@override
void onWindowUnmaximize() {}
@override
void onWindowMinimize() {}
@override
void onWindowRestore() {}
@override
void onWindowResize() {}
@override
void onWindowResized() {}
@override
void onWindowMove() {}
@override
void onWindowMoved() {}
@override
void onWindowEnterFullScreen() {}
@override
void onWindowLeaveFullScreen() {}
// ========== Private ==========
Future<void> showWindow() async {
await windowManager.show();
await windowManager.focus();
}
Future<void> _quitApp() async {
try {
AppLogger.d('DesktopService: Cleaning up before exit...');
// 彻底解绑
windowManager.removeListener(this);
trayManager.removeListener(this);
// 彻底销毁托盘(防止残留僵尸图标)
await trayManager.destroy();
// 允许关闭并销毁窗口
await windowManager.setPreventClose(false);
// 给系统一点点时间(50ms)处理最后的事件队列
await Future.delayed(const Duration(milliseconds: 50));
await windowManager.destroy();
} catch (e) {
AppLogger.e('Exit error: $e');
} finally {
exit(0);
}
}
}
+461
View File
@@ -0,0 +1,461 @@
import 'dart:async';
import 'dart:io';
import 'package:background_downloader/background_downloader.dart' as bd;
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import '../core/constants/storage_keys.dart';
import '../data/models/download_task_model.dart';
import 'file_service.dart';
import 'storage_service.dart';
import '../core/utils/app_logger.dart';
/// 下载服务 - 单例模式
/// 所有平台统一使用 background_downloader
class DownloadService {
static final DownloadService _instance = DownloadService._internal();
factory DownloadService() => _instance;
DownloadService._internal();
// 统一映射:外部下载器 task ID → 内部 task ID
final Map<String, String> _externalTaskIdToInternalId = {};
final Map<String, String> _internalIdToExternalTaskId = {};
// 存储 background_downloader 的 DownloadTask 对象,用于暂停/恢复/取消
final Map<String, bd.DownloadTask> _bdTasks = {};
// 回调处理器
static Function(String taskId, DownloadStatus status, int progress)?
_callbackHandler;
final FileService _fileService = FileService();
final Map<String, StreamController<DownloadTaskModel>> _progressControllers =
{};
bool _isInitialized = false;
/// 设置回调处理器
static void setCallbackHandler(
Function(String taskId, DownloadStatus status, int progress) handler) {
_callbackHandler = handler;
}
/// 获取下载任务进度流
Stream<DownloadTaskModel> getProgressStream(String taskId) {
if (!_progressControllers.containsKey(taskId)) {
_progressControllers[taskId] =
StreamController<DownloadTaskModel>.broadcast();
}
return _progressControllers[taskId]!.stream;
}
/// 获取下载目录
Future<Directory> getDownloadDirectory() async {
if (defaultTargetPlatform == TargetPlatform.android) {
if (await Permission.notification.isDenied) {
await Permission.notification.request();
}
final status = await Permission.manageExternalStorage.request();
if (status.isPermanentlyDenied) {
throw Exception('存储权限被永久拒绝,请在设置中开启');
}
if (!status.isGranted) {
throw Exception('存储权限被拒绝');
}
final directory = Directory('/storage/emulated/0/Download');
if (!await directory.exists()) {
await directory.create(recursive: true);
}
return directory;
} else if (defaultTargetPlatform == TargetPlatform.iOS) {
final appDocDir = await getApplicationDocumentsDirectory();
final directory = Directory('${appDocDir.path}/Downloads');
if (!await directory.exists()) {
await directory.create(recursive: true);
}
return directory;
} else {
// Windows/Linux/macOS - 使用系统下载目录
final downloadsDir = await getDownloadsDirectory();
if (downloadsDir != null) {
return downloadsDir;
}
// 回退方案
if (Platform.isWindows) {
final userProfile = Platform.environment['USERPROFILE'] ?? '';
final dir = Directory('$userProfile\\Downloads');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
final home = Platform.environment['HOME'] ?? '';
final dir = Directory('$home/Downloads');
if (!await dir.exists()) {
await dir.create(recursive: true);
}
return dir;
}
}
/// 读取 WiFi-only 下载设置
Future<bool> isWifiOnlyEnabled() async {
return await StorageService.instance
.getBool(StorageKeys.downloadWifiOnly) ??
false;
}
/// 读取重试次数设置
Future<int> getRetries() async {
return await StorageService.instance
.getInt(StorageKeys.downloadRetries) ??
3;
}
/// 初始化下载器
Future<void> initialize(
{Function(String taskId, DownloadStatus status, int progress)?
callbackHandler}) async {
if (callbackHandler != null) {
setCallbackHandler(callbackHandler);
AppLogger.d('回调处理器已更新');
}
if (_isInitialized) {
AppLogger.d('DownloadService 已经初始化');
return;
}
// 配置通知(Android 前台服务需要通知栏显示)
if (Platform.isAndroid) {
bd.FileDownloader().configureNotification(
running: const bd.TaskNotification(
'正在下载', '文件: {filename} - {progress}'),
complete:
const bd.TaskNotification('下载完成', '文件: {filename} 已保存'),
error: const bd.TaskNotification('下载失败', '文件: {filename} 下载出错'),
paused: const bd.TaskNotification('已暂停', '文件: {filename} 已暂停'),
progressBar: true,
tapOpensFile: true,
);
AppLogger.d('background_downloader 通知已配置');
}
bd.FileDownloader().registerCallbacks(
taskStatusCallback: _handleBdStatusUpdate,
taskProgressCallback: _handleBdProgressUpdate,
);
// 启动任务追踪和恢复
await bd.FileDownloader().start(
doTrackTasks: true,
markDownloadedComplete: true,
doRescheduleKilledTasks: true,
);
_isInitialized = true;
AppLogger.d('DownloadService 初始化完成 (background_downloader)');
}
/// background_downloader 状态回调
void _handleBdStatusUpdate(bd.TaskStatusUpdate update) {
final internalId = _externalTaskIdToInternalId[update.task.taskId];
// 如果映射不存在,尝试从 metaData 恢复
if (internalId == null && update.task.metaData.isNotEmpty) {
final metaInternalId = update.task.metaData;
_externalTaskIdToInternalId[update.task.taskId] = metaInternalId;
_internalIdToExternalTaskId[metaInternalId] = update.task.taskId;
_bdTasks[metaInternalId] = update.task as bd.DownloadTask;
AppLogger.d(
'从 metaData 恢复映射: bdTaskId=${update.task.taskId}, internalId=$metaInternalId');
}
final resolvedInternalId =
_externalTaskIdToInternalId[update.task.taskId];
if (resolvedInternalId == null) {
AppLogger.d(
'background_downloader 状态回调: 未找到内部任务ID, taskId=${update.task.taskId}');
return;
}
DownloadStatus status;
switch (update.status) {
case bd.TaskStatus.enqueued:
status = DownloadStatus.waiting;
case bd.TaskStatus.running:
status = DownloadStatus.downloading;
case bd.TaskStatus.complete:
status = DownloadStatus.completed;
case bd.TaskStatus.notFound:
case bd.TaskStatus.failed:
status = DownloadStatus.failed;
case bd.TaskStatus.canceled:
status = DownloadStatus.cancelled;
case bd.TaskStatus.paused:
status = DownloadStatus.paused;
case bd.TaskStatus.waitingToRetry:
status = DownloadStatus.waiting;
}
AppLogger.d(
'background_downloader 状态更新: taskId=${update.task.taskId}, internalId=$resolvedInternalId, status=$status');
final progress = status == DownloadStatus.completed ? 100 : 0;
_callbackHandler?.call(resolvedInternalId, status, progress);
}
/// background_downloader 进度回调
void _handleBdProgressUpdate(bd.TaskProgressUpdate update) {
final internalId = _externalTaskIdToInternalId[update.task.taskId];
if (internalId == null) return;
final progress = (update.progress * 100).toInt().clamp(0, 100);
_callbackHandler?.call(internalId, DownloadStatus.downloading, progress);
}
/// 开始下载(新任务)
Future<String?> startDownload(DownloadTaskModel task) async {
try {
if (!_isInitialized) {
await initialize();
}
// 获取下载 URL
String url = task.downloadUrl ?? '';
if (url.isEmpty) {
final response = await _fileService.getDownloadUrls(
uris: [task.fileUri],
download: true,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isEmpty) {
throw Exception('无法获取下载链接');
}
final urlData = urls[0] as Map<String, dynamic>;
url = urlData['url'] as String;
}
// 确保保存目录存在
final file = File(task.savePath);
final dir = file.parent;
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// 新任务:如果完整文件已存在,删除它
if (await file.exists() && task.downloadedBytes == 0) {
await file.delete();
}
return _startBdDownload(task, url, dir);
} catch (e) {
AppLogger.d('下载失败: $e');
rethrow;
}
}
/// 使用 background_downloader 开始下载
Future<String?> _startBdDownload(
DownloadTaskModel task, String url, Directory dir) async {
final wifiOnly = await isWifiOnlyEnabled();
final retries = await getRetries();
final bdTask = bd.DownloadTask(
url: url,
filename: task.fileName,
directory: dir.path,
baseDirectory: bd.BaseDirectory.root,
updates: bd.Updates.statusAndProgress,
allowPause: true,
retries: retries,
requiresWiFi: wifiOnly,
metaData: task.id,
);
final success = await bd.FileDownloader().enqueue(bdTask);
if (!success) {
throw Exception('创建下载任务失败');
}
// 保存映射关系
_externalTaskIdToInternalId[bdTask.taskId] = task.id;
_internalIdToExternalTaskId[task.id] = bdTask.taskId;
_bdTasks[task.id] = bdTask;
// 保存 backgroundTaskId 到任务模型(持久化,用于重启后恢复映射)
task.backgroundTaskId = bdTask.taskId;
AppLogger.d(
'background_downloader 任务已添加: taskId=${bdTask.taskId}, internalId=${task.id}, requiresWiFi=$wifiOnly, retries=$retries');
return bdTask.taskId;
}
/// 恢复下载(用于重启后恢复暂停的任务)
Future<String?> resumeDownloadAfterRestart(
DownloadTaskModel task) async {
try {
if (!_isInitialized) {
await initialize();
}
// 获取下载 URL
String url = task.downloadUrl ?? '';
if (url.isEmpty) {
final response = await _fileService.getDownloadUrls(
uris: [task.fileUri],
download: true,
);
final urls = response['urls'] as List<dynamic>? ?? [];
if (urls.isEmpty) {
throw Exception('无法获取下载链接');
}
final urlData = urls[0] as Map<String, dynamic>;
url = urlData['url'] as String;
}
final file = File(task.savePath);
final dir = file.parent;
if (!await dir.exists()) {
await dir.create(recursive: true);
}
// 恢复任务:不删除部分文件,使用 resume 方式重建 bdTask
final wifiOnly = await isWifiOnlyEnabled();
final retries = await getRetries();
final bdTask = bd.DownloadTask(
url: url,
filename: task.fileName,
directory: dir.path,
baseDirectory: bd.BaseDirectory.root,
updates: bd.Updates.statusAndProgress,
allowPause: true,
retries: retries,
requiresWiFi: wifiOnly,
metaData: task.id,
);
// 恢复映射关系
_externalTaskIdToInternalId[bdTask.taskId] = task.id;
_internalIdToExternalTaskId[task.id] = bdTask.taskId;
_bdTasks[task.id] = bdTask;
task.backgroundTaskId = bdTask.taskId;
// 如果有已下载的部分,尝试 resume;否则 enqueue
final partialFile =
File('${dir.path}/${task.fileName}.part');
if (task.downloadedBytes > 0 && await partialFile.exists()) {
AppLogger.d(
'断点续传: ${task.fileName}, 已下载 ${task.downloadedBytes} bytes');
await bd.FileDownloader().resume(bdTask);
} else {
AppLogger.d('重新下载: ${task.fileName}');
final success = await bd.FileDownloader().enqueue(bdTask);
if (!success) {
throw Exception('创建下载任务失败');
}
}
return bdTask.taskId;
} catch (e) {
AppLogger.d('恢复下载失败: $e');
rethrow;
}
}
/// 暂停下载
Future<void> pauseDownload(String taskId) async {
final bdTask = _bdTasks[taskId];
if (bdTask != null) {
await bd.FileDownloader().pause(bdTask);
}
}
/// 恢复下载
Future<void> resumeDownload(String taskId) async {
if (!_isInitialized) {
await initialize();
}
final bdTask = _bdTasks[taskId];
if (bdTask != null) {
await bd.FileDownloader().resume(bdTask);
}
}
/// 取消下载
Future<void> cancelDownload(String taskId) async {
final bdTask = _bdTasks[taskId];
if (bdTask != null) {
await bd.FileDownloader().cancel(bdTask);
}
}
/// 删除下载任务
void disposeTask(String taskId) {
final externalId = _internalIdToExternalTaskId[taskId];
if (externalId != null) {
_externalTaskIdToInternalId.remove(externalId);
}
_internalIdToExternalTaskId.remove(taskId);
_bdTasks.remove(taskId);
// 关闭进度流
final controller = _progressControllers[taskId];
if (controller != null) {
controller.close();
_progressControllers.remove(taskId);
}
}
/// 删除已下载的文件
Future<void> deleteDownloadedFile(String savePath) async {
try {
final file = File(savePath);
if (await file.exists()) {
await file.delete();
}
// 同时删除部分文件
final partialFile = File('$savePath.part');
if (await partialFile.exists()) {
await partialFile.delete();
}
} catch (e) {
AppLogger.d('删除文件失败: $e');
}
}
/// 获取可读的文件大小
static String getReadableFileSize(int bytes) {
if (bytes < 1024) {
return '$bytes B';
} else if (bytes < 1024 * 1024) {
return '${(bytes / 1024).toStringAsFixed(1)} KB';
} else if (bytes < 1024 * 1024 * 1024) {
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
} else {
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
}
}
/// 清理所有资源
void dispose() {
_externalTaskIdToInternalId.clear();
_internalIdToExternalTaskId.clear();
_bdTasks.clear();
// 关闭所有流
for (final controller in _progressControllers.values) {
if (!controller.isClosed) {
controller.close();
}
}
_progressControllers.clear();
}
}
+250
View File
@@ -0,0 +1,250 @@
import 'api_service.dart';
import '../core/utils/app_logger.dart';
import '../core/utils/file_utils.dart';
/// 文件服务
class FileService {
/// 列出文件
Future<Map<String, dynamic>> listFiles({
required String uri,
int page = 0,
int? pageSize,
String? orderBy,
String? orderDirection,
String? nextPageToken,
}) async {
final params = <String, dynamic>{
'uri': FileUtils.toCloudreveUri(uri),
'page': page,
'page_size': pageSize,
'order_by': orderBy,
'order_direction': orderDirection,
'next_page_token': nextPageToken,
};
final response = await ApiService.instance
.get<Map<String, dynamic>>('/file', queryParameters: params);
return response;
}
/// 创建文件/文件夹
Future<Map<String, dynamic>> createFile({
required String uri,
required String type,
bool? errOnConflict,
Map<String, dynamic>? metadata,
}) async {
final data = <String, dynamic>{
'uri': FileUtils.toCloudreveUri(uri),
'type': type,
'err_on_conflict': ?errOnConflict,
'metadata': ?metadata,
};
final response = await ApiService.instance
.post<Map<String, dynamic>>('/file/create', data: data);
return response;
}
/// 删除文件
Future<void> deleteFiles({
required List<String> uris,
bool unlink = false,
bool skipSoftDelete = false,
}) async {
final data = <String, dynamic>{
'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(),
if (unlink) 'unlink': true,
if (skipSoftDelete) 'skip_soft_delete': true,
};
await ApiService.instance.delete<void>('/file', data: data);
}
/// 移动/复制文件
Future<void> moveFiles({
required List<String> uris,
required String dst,
bool copy = false,
}) async {
final data = <String, dynamic>{
'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(),
'dst': FileUtils.toCloudreveUri(dst),
'copy': copy,
};
await ApiService.instance.post<void>('/file/move', data: data);
}
/// 重命名文件(返回更新后的文件对象)
Future<Map<String, dynamic>> renameFile({
required String uri,
required String newName,
}) async {
final data = <String, dynamic>{
'uri': FileUtils.toCloudreveUri(uri),
'new_name': newName,
};
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/file/rename',
data: data,
);
return response;
}
/// 获取下载链接
Future<Map<String, dynamic>> getDownloadUrls({
required List<String> uris,
bool download = true,
bool? redirect,
String? entity,
bool? usePrimarySiteUrl,
bool? skipError,
bool? archive,
bool? noCache,
String? contextHint,
}) async {
final data = <String, dynamic>{
'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(),
'download': download,
'redirect': ?redirect,
'entity': ?entity,
'use_primary_site_url': ?usePrimarySiteUrl,
'skip_error': ?skipError,
'archive': ?archive,
'no_cache': ?noCache,
};
final headers = <String, dynamic>{};
if (contextHint != null) {
headers['X-Cr-Context-Hint'] = contextHint;
}
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/file/url',
data: data,
headers: headers,
);
return response;
}
/// 创建直接链接(分享链接)
Future<List<Map<String, dynamic>>> createDirectLinks({
required List<String> uris,
}) async {
final data = <String, dynamic>{
'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(),
};
final response = await ApiService.instance.put<List<Map<String, dynamic>>>(
'/file/source',
data: data,
);
return response;
}
/// 恢复文件(从回收站)
Future<void> restoreFiles({
required List<String> uris,
}) async {
final data = <String, dynamic>{
'uris': uris.map((uri) => FileUtils.toCloudreveUri(uri)).toList(),
};
await ApiService.instance.post<void>('/file/restore', data: data);
}
/// 列出回收站文件
Future<Map<String, dynamic>> listTrashFiles({
int page = 0,
int? pageSize,
}) async {
final params = <String, dynamic>{
'uri': 'cloudreve://trash',
'page': page,
'page_size': pageSize,
};
final response = await ApiService.instance
.get<Map<String, dynamic>>('/file', queryParameters: params);
return response;
}
/// 搜索文件
Future<Map<String, dynamic>> searchFiles({
String uri = '/',
required String name,
bool caseFolding = false,
int page = 0,
int? pageSize,
}) async {
// 构造搜索 URI: cloudreve://my?name=xxx
final cloudreveUri = '${FileUtils.toCloudreveUri(uri)}?name=$name';
final params = <String, dynamic>{
'uri': cloudreveUri,
'page': page,
'case_folding': caseFolding,
'page_size': pageSize,
};
final response = await ApiService.instance
.get<Map<String, dynamic>>('/file', queryParameters: params);
AppLogger.d('Search files ---------> : $response');
return response;
}
/// 获取文件/文件夹详细信息
Future<Map<String, dynamic>> getFileInfo({
String? uri,
String? id,
bool extended = false,
bool folderSummary = false,
}) async {
final params = <String, dynamic>{
if (uri != null) 'uri': FileUtils.toCloudreveUri(uri),
'id': ?id,
'extended': extended,
'folder_summary': folderSummary,
};
final response = await ApiService.instance
.get<Map<String, dynamic>>('/file/info', queryParameters: params);
AppLogger.d("getFileInfo --> $response");
return response;
}
/// 设为当前版本
Future<void> setFileVersion({
required String uri,
required String version,
}) async {
final data = <String, dynamic>{
'uri': FileUtils.toCloudreveUri(uri),
'version': version,
};
await ApiService.instance.post<void>('/file/version/current', data: data);
}
/// 删除版本
Future<void> deleteFileVersion({
required String uri,
required String version,
}) async {
final data = <String, dynamic>{
'uri': FileUtils.toCloudreveUri(uri),
'version': version,
};
await ApiService.instance.delete<void>('/file/version', data: data);
}
}
+72
View File
@@ -0,0 +1,72 @@
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
import 'api_service.dart';
import '../data/models/remote_download_task_model.dart';
/// 离线下载服务
class RemoteDownloadService {
/// 创建离线下载任务
Future<List<RemoteDownloadTaskModel>> createDownload({
required String dst,
List<String>? src,
String? srcFile,
}) async {
final data = <String, dynamic>{
'dst': dst,
if (src != null && src.isNotEmpty) 'src': src,
if (srcFile != null && srcFile.isNotEmpty) 'src_file': srcFile,
};
final response = await ApiService.instance
.post('/workflow/download', data: data);
AppLogger.i("RemoteDownloadService --> $response");
List<RemoteDownloadTaskModel> result = response.map<RemoteDownloadTaskModel>((item) =>
RemoteDownloadTaskModel.fromJson(item as Map<String, dynamic>)
).toList();
return result;
}
/// 列出任务
/// [category] 任务分类:downloading(下载中)、downloaded(已完成)、general(全部)
Future<Map<String, dynamic>> listTasks({
required String category,
int pageSize = 20,
String? nextPageToken,
}) async {
final params = <String, dynamic>{
'page_size': pageSize,
'category': category,
'next_page_token': ?nextPageToken,
};
return await ApiService.instance
.get<Map<String, dynamic>>('/workflow', queryParameters: params);
}
/// 获取任务进度
Future<Map<String, dynamic>> getProgress({required String taskId}) async {
return await ApiService.instance
.get<Map<String, dynamic>>('/workflow/progress/$taskId');
}
/// 选择种子文件
Future<void> selectFiles({
required String taskId,
required List<Map<String, dynamic>> files,
}) async {
await ApiService.instance.patch<Map<String, dynamic>>(
'/workflow/download/$taskId',
data: {'files': files},
);
}
/// 取消任务
Future<void> cancelTask({required String taskId}) async {
await ApiService.instance.delete<Map<String, dynamic>>(
'/workflow/download/$taskId',
);
}
}
+170
View File
@@ -0,0 +1,170 @@
import '../config/api_config.dart';
import '../data/models/server_model.dart';
import '../data/models/user_model.dart';
import 'storage_service.dart';
import '../core/utils/app_logger.dart';
/// 服务器服务 - 固定使用内置服务器
class ServerService {
static ServerService? _instance;
ServerService._();
static ServerService get instance {
_instance ??= ServerService._();
return _instance!;
}
static const String _defaultLabel = '共云网盘';
static const String _defaultBaseUrl = ApiConfig.defaultBaseUrl;
List<ServerModel> _servers = [];
ServerModel? _currentServer;
/// 获取服务器列表
List<ServerModel> get servers => List.unmodifiable(_servers);
/// 获取当前服务器
ServerModel? get currentServer => _currentServer;
/// 初始化服务器
Future<void> init() async {
await _loadServers();
}
/// 从存储加载服务器列表
Future<void> _loadServers() async {
try {
// 从 StorageService 加载服务器列表
final loadedServers = await StorageService.instance.servers;
_setFixedDefaultServer(loadedServers);
await _saveServers();
await _saveLastSelected();
AppLogger.d('加载了 ${_servers.length} 个服务器配置');
AppLogger.d('当前服务器: ${_currentServer?.label}');
} catch (e) {
AppLogger.d('加载服务器列表失败: $e');
// 加载失败时使用默认服务器
_servers = [
ServerModel(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
),
];
_currentServer = _servers.first;
}
}
/// 固定使用内置服务器,并保留同地址服务器上的登录信息
void _setFixedDefaultServer(List<ServerModel> loadedServers) {
ServerModel? savedDefaultServer;
for (final server in loadedServers) {
if (server.baseUrl == _defaultBaseUrl) {
savedDefaultServer = server;
break;
}
}
_currentServer = (savedDefaultServer ??
ServerModel(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
))
.copyWith(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
);
_servers = [_currentServer!];
}
/// 保存服务器列表到存储
Future<void> _saveServers() async {
try {
await StorageService.instance.setServers(_servers);
AppLogger.d('已保存 ${_servers.length} 个服务器配置');
} catch (e) {
AppLogger.d('保存服务器列表失败: $e');
}
}
/// 保存上次选中的服务器
Future<void> _saveLastSelected() async {
if (_currentServer != null) {
await StorageService.instance.setLastSelectedServerLabel(_currentServer!.label);
}
}
/// 固定服务器模式下不支持添加服务器
Future<void> addServer(ServerModel server) async {
throw UnsupportedError('当前版本不支持自定义服务器');
}
/// 固定服务器模式下不支持更新服务器
Future<void> updateServer(String oldLabel, ServerModel newServer) async {
throw UnsupportedError('当前版本不支持自定义服务器');
}
/// 固定服务器模式下不支持删除服务器
Future<void> deleteServer(String label) async {
throw UnsupportedError('当前版本不支持自定义服务器');
}
/// 固定服务器模式下始终使用内置服务器
Future<void> selectServer(String label) async {
if (_currentServer == null || _currentServer!.baseUrl != _defaultBaseUrl) {
_setFixedDefaultServer(_servers);
}
await _saveLastSelected();
AppLogger.d('当前服务器: ${_currentServer?.label}');
}
/// 更新当前服务器的服务登录信息
Future<void> updateCurrentServerLogin({
String? email,
String? password,
UserModel? user,
bool? rememberMe,
}) async {
if (_currentServer == null) {
throw Exception('没有选中的服务器');
}
_currentServer = _currentServer!.copyWith(
email: email,
password: password,
user: user,
rememberMe: rememberMe ?? _currentServer!.rememberMe,
);
// 更新列表中的引用
final index = _servers.indexWhere((s) => s.label == _currentServer!.label);
if (index != -1) {
_servers[index] = _currentServer!;
}
await _saveServers();
}
/// 清除当前服务器的服务登录信息
Future<void> clearCurrentServerLogin() async {
await updateCurrentServerLogin(
email: null,
password: null,
user: null,
);
}
/// 重置为默认服务器列表
Future<void> resetToDefault() async {
_servers = [
ServerModel(
label: _defaultLabel,
baseUrl: _defaultBaseUrl,
),
];
_currentServer = _servers.first;
await _saveServers();
await _saveLastSelected();
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'api_service.dart';
import '../data/models/share_model.dart';
import '../core/utils/app_logger.dart';
/// 分享服务
class ShareService {
/// 将文件系统路径转换为 cloudreve URI 格式
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';
}
/// 创建分享链接
Future<String> createShare({
required String uri,
bool? isPrivate,
bool? shareView,
int? expire,
int? price,
String? password,
bool? showReadme,
}) async {
final data = <String, dynamic>{
'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 直接返回结果即可
final response = await ApiService.instance.put<Map<String, dynamic>>(
'/share',
data: data,
isNoData: true,
);
return response['data'] as String;
}
/// 获取我的分享列表
Future<Map<String, dynamic>> listShares({
required int pageSize,
String? orderBy,
String? orderDirection,
String? nextPageToken,
}) async {
final queryParams = <String, dynamic>{
'page_size': pageSize,
'order_by': ?orderBy,
'order_direction': ?orderDirection,
'next_page_token': ?nextPageToken,
};
// 请求方法为get, claude 写成post, fixed
return await ApiService.instance.get<Map<String, dynamic>>(
'/share',
queryParameters: queryParams,
);
}
/// 获取分享详情
Future<ShareModel> getShareInfo({
required String id,
String? password,
bool? countViews,
bool? ownerExtended,
}) async {
final queryParams = <String, dynamic>{};
if (password != null) queryParams['password'] = password;
if (countViews != null) queryParams['count_views'] = countViews.toString();
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);
}
/// 编辑分享
Future<String> editShare({
required String id,
required String uri,
bool? isPrivate,
String? password,
bool? shareView,
int? downloads,
int? expire,
}) async {
final data = <String, dynamic>{
'uri': uri,
'is_private': ?isPrivate,
'share_view': ?shareView,
'downloads': ?downloads,
'expire': ?expire,
};
if (password != null && password.isNotEmpty) {
data['password'] = password;
}
AppLogger.d('editShare response ---> : response');
final response = await ApiService.instance.post<Map<String, dynamic>>(
'/share/$id',
data: data,
isNoData: true,
);
return response['data'] as String;
}
/// 删除分享
Future<void> deleteShare({required String id}) async {
await ApiService.instance.delete<void>('/share/$id');
}
}
+152
View File
@@ -0,0 +1,152 @@
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import '../core/constants/storage_keys.dart';
import '../data/models/server_model.dart';
/// 存储服务
class StorageService {
static StorageService? _instance;
SharedPreferences? _prefs;
StorageService._();
/// 获取单例
static StorageService get instance {
_instance ??= StorageService._();
return _instance!;
}
/// 初始化
Future<void> init() async {
_prefs ??= await SharedPreferences.getInstance();
}
/// 获取值
Future<String?> getString(String key) async {
await init();
return _prefs!.getString(key);
}
/// 设置值
Future<bool> setString(String key, String? value) async {
await init();
if (value == null) {
return _prefs!.remove(key);
}
return _prefs!.setString(key, value);
}
/// 获取整数值
Future<int?> getInt(String key) async {
await init();
return _prefs!.getInt(key);
}
/// 设置整数值
Future<bool> setInt(String key, int? value) async {
await init();
if (value == null) {
return _prefs!.remove(key);
}
return _prefs!.setInt(key, value);
}
/// 获取布尔值
Future<bool?> getBool(String key) async {
await init();
return _prefs!.getBool(key);
}
/// 设置布尔值
Future<bool> setBool(String key, bool? value) async {
await init();
if (value == null) {
return _prefs!.remove(key);
}
return _prefs!.setBool(key, value);
}
/// 删除值
Future<bool> remove(String key) async {
await init();
return _prefs!.remove(key);
}
/// 清空所有数据
Future<bool> clear() async {
await init();
return _prefs!.clear();
}
/// 设置
Future<String?> get themeMode => getString(StorageKeys.themeMode);
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> removeCustomBaseUrl() => remove(StorageKeys.customBaseUrl);
/// 服务器列表
Future<List<ServerModel>> get servers async {
final serversJson = await getString(StorageKeys.servers);
if (serversJson == null || serversJson.isEmpty) {
return [];
}
try {
final serversList = jsonDecode(serversJson) as List<dynamic>;
return serversList
.map((e) => ServerModel.fromJson(e as Map<String, dynamic>))
.toList();
} catch (e) {
return [];
}
}
Future<bool> setServers(List<ServerModel> servers) async {
try {
final serversJson = jsonEncode(servers.map((s) => s.toJson()).toList());
return await setString(StorageKeys.servers, serversJson);
} catch (e) {
return false;
}
}
/// 上次选中的服务器 label
Future<String?> get lastSelectedServerLabel => getString(StorageKeys.lastSelectedServer);
Future<bool> setLastSelectedServerLabel(String? value) => setString(StorageKeys.lastSelectedServer, value);
/// 搜索历史(最新在前,最多 20 条)
Future<List<String>> getSearchHistory() async {
final json = await getString(StorageKeys.searchHistory);
if (json == null || json.isEmpty) return [];
try {
final list = jsonDecode(json) as List<dynamic>;
return list.cast<String>();
} catch (_) {
return [];
}
}
Future<bool> setSearchHistory(List<String> history) async {
try {
final json = jsonEncode(history);
return await setString(StorageKeys.searchHistory, json);
} catch (_) {
return false;
}
}
Future<void> addToSearchHistory(String query) async {
final history = await getSearchHistory();
history.remove(query);
history.insert(0, query);
if (history.length > 20) history.removeRange(20, history.length);
await setSearchHistory(history);
}
Future<void> clearSearchHistory() async {
await remove(StorageKeys.searchHistory);
}
}
+136
View File
@@ -0,0 +1,136 @@
import 'api_service.dart';
import '../core/utils/app_logger.dart';
import '../core/utils/file_utils.dart';
import '../core/utils/time_flow_decoder.dart';
/// 缩略图缓存条目
class _ThumbCacheEntry {
final String imageUrl;
final DateTime expiresAt;
_ThumbCacheEntry({required this.imageUrl, required this.expiresAt});
bool get isExpired => DateTime.now().isAfter(expiresAt);
}
/// 缩略图服务 — 获取、解码、缓存缩略图 URL
class ThumbnailService {
static ThumbnailService? _instance;
static ThumbnailService get instance {
_instance ??= ThumbnailService._();
return _instance!;
}
ThumbnailService._();
// 内存缓存: fileUri -> cache entry
final Map<String, _ThumbCacheEntry> _urlCache = {};
// 请求去重: fileUri -> in-flight Future
final Map<String, Future<String?>> _inFlightRequests = {};
/// 获取缩略图图片 URL
/// [fileUri] 文件的 relativePath(如 /path/to/file.jpg
/// [contextHint] 可选,加速服务端 DB 查询
Future<String?> getThumbnailUrl({
required String fileUri,
String? contextHint,
}) async {
final cacheKey = fileUri;
// 1. 检查内存缓存
final cached = _urlCache[cacheKey];
if (cached != null && !cached.isExpired) {
return cached.imageUrl;
}
if (cached != null) {
_urlCache.remove(cacheKey);
}
// 2. 检查请求去重
final inFlight = _inFlightRequests[cacheKey];
if (inFlight != null) {
return inFlight;
}
// 3. 发起请求
final future = _fetchThumbnailUrl(fileUri, contextHint);
_inFlightRequests[cacheKey] = future;
try {
return await future;
} finally {
_inFlightRequests.remove(cacheKey);
}
}
Future<String?> _fetchThumbnailUrl(String fileUri, String? contextHint) async {
try {
final uri = FileUtils.toCloudreveUri(fileUri);
final headers = contextHint != null
? <String, dynamic>{'X-Cr-Context-Hint': contextHint}
: null;
// _parseResponse 已提取 ApiResponse.dataresponse 即 {url, obfuscated, expires}
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/file/thumb',
queryParameters: {'uri': uri},
headers: headers,
);
AppLogger.d('ThumbnailService: response for $fileUri = $response');
var url = response['url'] as String? ?? '';
final obfuscated = response['obfuscated'] as bool? ?? false;
final expiresStr = response['expires'] as String?;
// 解码混淆 URL
if (obfuscated && url.isNotEmpty) {
final decoded = TimeFlowDecoder.decodeTimeFlowString(url);
if (decoded == null) {
AppLogger.w('Failed to decode obfuscated thumbnail URL for $fileUri');
return null;
}
url = decoded;
}
if (url.isEmpty) return null;
AppLogger.d('ThumbnailService: resolved URL for $fileUri = $url');
// 解析过期时间,缓存提前 30 秒过期
DateTime expiresAt;
if (expiresStr != null) {
try {
expiresAt = DateTime.parse(expiresStr).subtract(const Duration(seconds: 30));
} catch (_) {
expiresAt = DateTime.now().add(const Duration(minutes: 5));
}
} else {
expiresAt = DateTime.now().add(const Duration(minutes: 5));
}
// 存入内存缓存
_urlCache[fileUri] = _ThumbCacheEntry(
imageUrl: url,
expiresAt: expiresAt,
);
return url;
} catch (e) {
AppLogger.d('ThumbnailService: failed to get thumbnail URL for $fileUri: $e');
return null;
}
}
/// 移除指定文件的缓存 URL
void evictUrl(String fileUri) {
_urlCache.remove(fileUri);
}
/// 清空所有缓存(目录切换时调用)
void clearAll() {
_urlCache.clear();
_inFlightRequests.clear();
}
}
+586
View File
@@ -0,0 +1,586 @@
import 'dart:async';
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import '../core/constants/storage_keys.dart';
import '../data/models/upload_task_model.dart';
import 'storage_service.dart';
import 'api_service.dart';
import '../core/utils/app_logger.dart';
/// 上传服务 - 单例模式
class UploadService extends ChangeNotifier {
UploadService._internal() : super();
factory UploadService() => instance;
static UploadService? _instance;
static UploadService get instance {
_instance ??= UploadService._internal();
return _instance!;
}
final Map<String, UploadTaskModel> _tasks = {};
/// 上传完成回调:参数为 (目标路径, 文件名)
void Function(String targetPath, String fileName)? onUploadCompleted;
final Map<String, CancelToken> _cancelTokens = {};
final Map<String, StreamController<double>> _progressControllers = {};
final Map<String, _SpeedTracker> _speedTrackers = {};
/// 添加任务
void addTask(UploadTaskModel task) {
_tasks[task.id] = task;
if (!_progressControllers.containsKey(task.id)) {
_progressControllers[task.id] = StreamController<double>.broadcast();
}
AppLogger.d('UploadTaskModel -> addTask > ${task.toJson()}');
_saveTasks();
notifyListeners();
}
/// 更新任务
void updateTask(UploadTaskModel task) {
if (_tasks.containsKey(task.id)) {
_tasks[task.id] = task;
_saveTasks();
notifyListeners();
}
}
/// 获取任务
UploadTaskModel? getTask(String id) => _tasks[id];
/// 获取所有任务
List<UploadTaskModel> get allTasks => _tasks.values.toList();
/// 获取进行中的任务
List<UploadTaskModel> get activeTasks => _tasks.values
.where(
(t) =>
t.status == UploadStatus.uploading ||
t.status == UploadStatus.waiting,
)
.toList();
/// 移除任务
void removeTask(String id) {
_tasks.remove(id);
_cancelTokens.remove(id);
final controller = _progressControllers.remove(id);
controller?.close();
_saveTasks();
notifyListeners();
}
/// 获取上传进度流
Stream<double> getProgressStream(String taskId) {
if (!_progressControllers.containsKey(taskId)) {
_progressControllers[taskId] = StreamController<double>.broadcast();
}
return _progressControllers[taskId]!.stream;
}
/// 清除所有任务
void clearAllTasks() {
for (final controller in _progressControllers.values) {
controller.close();
}
_tasks.clear();
_cancelTokens.clear();
_progressControllers.clear();
_saveTasks();
notifyListeners();
}
/// 清除已完成的任务
void clearCompletedTasks() {
final completedIds = _tasks.values
.where(
(t) =>
t.status == UploadStatus.completed ||
t.status == UploadStatus.cancelled,
)
.map((t) => t.id)
.toList();
for (final id in completedIds) {
removeTask(id);
}
_saveTasks();
}
/// 清除失败的任务
void clearFailedTasks() {
final failedIds = _tasks.values
.where((t) => t.status == UploadStatus.failed)
.map((t) => t.id)
.toList();
for (final id in failedIds) {
removeTask(id);
}
_saveTasks();
}
/// 初始化上传服务
Future<void> initialize() async {
await _loadTasks();
}
/// 从本地存储加载上传任务
Future<void> _loadTasks() async {
try {
final tasksJson = await StorageService.instance.getString(
StorageKeys.uploadTasks,
);
if (tasksJson == null || tasksJson.isEmpty) {
AppLogger.d('没有保存的上传任务');
return;
}
final tasksList = jsonDecode(tasksJson) as List<dynamic>;
final loadedTasks = <UploadTaskModel>[];
final now = DateTime.now();
for (final taskJson in tasksList) {
try {
final task = UploadTaskModel.fromJson(
taskJson as Map<String, dynamic>,
);
// 检查文件是否存在
if (!await task.file.exists()) {
AppLogger.d('上传任务文件不存在,跳过: ${task.fileName}');
continue;
}
// 过滤掉已取消的任务
if (task.status == UploadStatus.cancelled) {
continue;
}
// 如果任务已完成,只保留配置天数内的记录
if (task.status == UploadStatus.completed) {
if (task.completedAt == null) {
continue;
}
final retentionDays = await StorageService.instance
.getInt(StorageKeys.taskRetentionDays) ??
7;
if (retentionDays > 0) {
final daysSinceCompletion = now
.difference(task.completedAt!)
.inDays;
if (daysSinceCompletion > retentionDays) {
AppLogger.d('跳过超过$retentionDays天的已完成任务: ${task.fileName}');
continue;
}
}
}
// 对于未完成的任务,重置状态为等待(因为应用关闭后上传已停止)
if (task.status == UploadStatus.uploading ||
task.status == UploadStatus.waiting) {
loadedTasks.add(
task.copyWith(
status: UploadStatus.waiting,
uploadedBytes: 0,
progress: 0,
uploadedChunks: 0,
errorMessage: null,
),
);
} else {
loadedTasks.add(task);
}
} catch (e) {
AppLogger.d('解析上传任务失败: $e');
}
}
// 将加载的任务添加到当前任务列表
for (final task in loadedTasks) {
_tasks[task.id] = task;
if (!_progressControllers.containsKey(task.id)) {
_progressControllers[task.id] = StreamController<double>.broadcast();
}
}
AppLogger.d('从存储加载了 ${loadedTasks.length} 个上传任务');
// 通知 UI 更新
if (loadedTasks.isNotEmpty) {
notifyListeners();
}
} catch (e) {
AppLogger.d('加载上传任务失败: $e');
}
}
/// 保存上传任务到本地存储
Future<void> _saveTasks() async {
try {
final tasksList = _tasks.values.map((task) => task.toJson()).toList();
final tasksJson = jsonEncode(tasksList);
await StorageService.instance.setString(
StorageKeys.uploadTasks,
tasksJson,
);
AppLogger.d('已保存 ${_tasks.length} 个上传任务到存储');
} catch (e) {
AppLogger.d('保存上传任务失败: $e');
}
}
/// 取消上传
void cancelUpload(String taskId) {
final cancelToken = _cancelTokens[taskId];
if (cancelToken != null) {
cancelToken.cancel('上传已取消');
}
final task = _tasks[taskId];
if (task != null) {
updateTask(task.copyWith(status: UploadStatus.cancelled, speed: 0));
_cleanSpeedTracker(taskId);
}
}
/// 重试上传
Future<void> retryUpload(String taskId) async {
final task = _tasks[taskId];
if (task == null) return;
// 重置任务状态
updateTask(
task.copyWith(
status: UploadStatus.waiting,
uploadedBytes: 0,
progress: 0,
uploadedChunks: 0,
errorMessage: null,
),
);
// 开始上传
await startUpload(task);
}
/// 开始上传
Future<void> startUpload(UploadTaskModel task) async {
AppLogger.d('UploadService.startUpload: 开始上传任务 ${task.fileName}');
final cancelToken = CancelToken();
_cancelTokens[task.id] = cancelToken;
try {
// 步骤1:创建上传会话
AppLogger.d('UploadService.startUpload: 创建上传会话...');
final session = await _createUploadSession(task);
AppLogger.d(
'UploadService.startUpload: 上传会话创建成功,sessionId=${session.sessionId}, chunkSize=${session.chunkSize}',
);
// 更新任务,添加会话信息
final updatedTask = task.copyWith(
session: session,
totalChunks: task.calculateTotalChunks(session.chunkSize),
status: UploadStatus.uploading,
);
updateTask(updatedTask);
// 步骤2:上传文件
await _uploadFile(updatedTask, cancelToken);
// 上传完成
final completedTask = updatedTask.copyWith(
status: UploadStatus.completed,
progress: 1.0,
uploadedBytes: task.fileSize,
uploadedChunks: updatedTask.totalChunks,
completedAt: DateTime.now(),
speed: 0,
);
updateTask(completedTask);
_cleanSpeedTracker(task.id);
onUploadCompleted?.call(task.targetPath, task.fileName);
_emitProgress(task.id, 1.0);
} catch (e) {
AppLogger.d('Upload failed for ${task.fileName}: $e');
final isCancelled =
e is DioException && e.type == DioExceptionType.cancel;
updateTask(
task.copyWith(
status: isCancelled ? UploadStatus.cancelled : UploadStatus.failed,
errorMessage: e.toString(),
speed: 0,
),
);
_cleanSpeedTracker(task.id);
if (!isCancelled) {
_emitProgress(task.id, task.progress);
}
}
}
/// 创建上传会话
Future<UploadSessionModel> _createUploadSession(UploadTaskModel task) async {
final response = await ApiService.instance.put<Map<String, dynamic>>(
'/file/upload',
data: {
'uri': task.targetPath.endsWith('/')
? '${task.targetPath}${task.fileName}'
: '${task.targetPath}/${task.fileName}',
'size': task.fileSize,
},
);
// API: /file/upload 响应格式: {code: 0, data: {...}, msg: ''} 但在 _parseResponse 已经解析过 data, 这里直接使用 response
final Map<String, dynamic> sessionData = response;
return UploadSessionModel.fromJson(sessionData);
}
/// 上传文件(支持分片上传)
Future<void> _uploadFile(
UploadTaskModel task,
CancelToken cancelToken,
) async {
final session = task.session!;
final file = task.file;
AppLogger.d('开始上传 -> ${task.fileName}');
// 读取文件
final fileBytes = await file.readAsBytes();
// 检查是否需要分片:服务器返回了分片大小 或 文件大于20MB
const largeFileThreshold = 20 * 1024 * 1024; // 20MB
final shouldMultipart = session.isMultipartEnabled || task.fileSize > largeFileThreshold;
if (shouldMultipart) {
// 分片上传
final chunkSize = session.isMultipartEnabled ? session.chunkSize : largeFileThreshold;
await _uploadMultipart(fileBytes, chunkSize, task, cancelToken);
} else {
// 单次上传
await _uploadSinglePart(fileBytes, session, task, cancelToken);
}
}
/// 分片上传
Future<void> _uploadMultipart(
List<int> fileBytes,
int chunkSize,
UploadTaskModel task,
CancelToken cancelToken,
) async {
final session = task.session!;
final totalChunks = (fileBytes.length / chunkSize).ceil();
for (int i = 0; i < totalChunks; i++) {
if (cancelToken.isCancelled) {
throw DioException(
requestOptions: RequestOptions(path: ''),
type: DioExceptionType.cancel,
error: '上传已取消',
);
}
// 计算当前分片的范围
final start = i * chunkSize;
final end = (start + chunkSize).clamp(0, fileBytes.length);
final chunkData = fileBytes.sublist(start, end);
AppLogger.d('Uploading chunk ${i + 1}/$totalChunks for ${task.fileName}');
if (session.isRelayUpload) {
// 上传到 Cloudreve 服务器
await _uploadChunkToRelay(chunkData, i, session.sessionId, cancelToken, null);
} else {
// 上传到远程存储
await _uploadChunkToRemote(chunkData, i, session, cancelToken, null);
}
// 更新进度 - 获取最新任务状态并计算正确进度
final currentTask = getTask(task.id) ?? task;
final progress = (i + 1) / totalChunks;
final speed = _computeSpeed(task.id, end);
updateTask(
currentTask.copyWith(
uploadedBytes: end,
progress: progress,
uploadedChunks: i + 1,
speed: speed,
),
);
_emitProgress(task.id, progress);
}
// 完成上传(某些存储策略需要)
if (session.completeUrl != null && session.completeUrl!.isNotEmpty) {
await _completeMultipartUpload(session);
}
}
/// 上传分片到中继服务器
Future<void> _uploadChunkToRelay(
List<int> chunkData,
int index,
String sessionId,
CancelToken cancelToken,
void Function(int, int)? onProgress,
) async {
await ApiService.instance.postWithProgress(
'/file/upload/$sessionId/$index',
data: Stream.fromIterable([chunkData]),
headers: {
'Content-Type': 'application/octet-stream',
'Content-Length': chunkData.length.toString(),
},
onSendProgress: onProgress,
);
}
/// 上传分片到远程存储
Future<void> _uploadChunkToRemote(
List<int> chunkData,
int index,
UploadSessionModel session,
CancelToken cancelToken,
void Function(int, int)? onProgress,
) async {
final urls = session.uploadUrls ?? [];
if (urls.isEmpty) {
throw Exception('没有可用的上传 URL');
}
// 大多数远程存储使用一个 URL,通过 query 参数指定分片索引
final url = urls.first;
final uploadUrl = url.contains('?')
? '$url&chunk=$index'
: '$url?chunk=$index';
final dio = Dio(BaseOptions());
await dio.post(
uploadUrl,
data: Stream.fromIterable([chunkData]),
options: Options(
contentType: 'application/octet-stream',
headers: {
'Content-Length': chunkData.length.toString(),
if (session.credential != null) 'Authorization': session.credential,
},
),
cancelToken: cancelToken,
onSendProgress: onProgress,
);
}
/// 完成多部分上传
Future<void> _completeMultipartUpload(UploadSessionModel session) async {
await ApiService.instance.post(session.completeUrl!, data: {});
}
/// 单次上传
Future<void> _uploadSinglePart(
List<int> fileBytes,
UploadSessionModel session,
UploadTaskModel task,
CancelToken cancelToken,
) async {
if (session.isRelayUpload) {
// 上传到中继服务器
await _uploadChunkToRelay(
fileBytes,
0,
session.sessionId,
cancelToken,
(sent, total) {
final progress = sent / total;
final currentTask = getTask(task.id) ?? task;
final speed = _computeSpeed(task.id, sent);
updateTask(
currentTask.copyWith(
uploadedBytes: sent,
progress: progress,
speed: speed,
),
);
_emitProgress(task.id, progress);
},
);
} else {
// 上传到远程存储
await _uploadChunkToRemote(
fileBytes,
0,
session,
cancelToken,
(sent, total) {
final progress = sent / total;
final currentTask = getTask(task.id) ?? task;
final speed = _computeSpeed(task.id, sent);
updateTask(
currentTask.copyWith(
uploadedBytes: sent,
progress: progress,
speed: speed,
),
);
_emitProgress(task.id, progress);
},
);
}
}
/// 发送进度更新
void _emitProgress(String taskId, double progress) {
final controller = _progressControllers[taskId];
if (controller != null && !controller.isClosed) {
controller.add(progress);
}
}
/// 计算上传速度
int _computeSpeed(String taskId, int uploadedBytes) {
final tracker = _speedTrackers[taskId];
if (tracker == null) {
_speedTrackers[taskId] = _SpeedTracker(uploadedBytes);
return 0;
}
return tracker.update(uploadedBytes);
}
/// 清理速度追踪器
void _cleanSpeedTracker(String taskId) {
_speedTrackers.remove(taskId);
}
}
/// 上传速度追踪器
class _SpeedTracker {
int lastBytes;
DateTime lastTime;
_SpeedTracker(this.lastBytes) : lastTime = DateTime.now();
int update(int currentBytes) {
final now = DateTime.now();
final elapsed = now.difference(lastTime).inMilliseconds;
if (elapsed < 200) return 0; // 至少 200ms 间隔才计算
final bytesDelta = currentBytes - lastBytes;
final speed = (bytesDelta * 1000 / elapsed).round();
lastBytes = currentBytes;
lastTime = now;
return speed > 0 ? speed : 0;
}
}
+178
View File
@@ -0,0 +1,178 @@
import 'dart:typed_data';
import '../data/models/user_setting_model.dart';
import '../data/models/user_model.dart';
import 'api_service.dart';
/// 用户设置服务
class UserSettingService {
UserSettingService._internal();
static final UserSettingService _instance = UserSettingService._internal();
static UserSettingService get instance => _instance;
/// 获取当前用户设置
/// _parseResponse 已自动提取 data 字段,返回值即 json['data']
Future<UserSettingModel> getUserSetting() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/user/setting',
);
return UserSettingModel.fromJson(response);
}
/// 更新用户设置(仅发送非null字段)
/// PATCH /user/setting 返回的 data 字段可能为 null,不应强转为 Map
Future<void> updateUserSetting({
String? nick,
bool? groupExpires,
String? language,
String? preferredTheme,
bool? versionRetentionEnabled,
List<String>? versionRetentionExt,
int? versionRetentionMax,
String? currentPassword,
String? newPassword,
bool? twoFaEnabled,
String? twoFaCode,
bool? disableViewSync,
String? shareLinksInProfile,
}) async {
final data = <String, dynamic>{};
if (nick != null) data['nick'] = nick;
if (groupExpires != null) data['group_expires'] = groupExpires;
if (language != null) data['language'] = language;
if (preferredTheme != null) data['preferred_theme'] = preferredTheme;
if (versionRetentionEnabled != null) {
data['version_retention_enabled'] = versionRetentionEnabled;
}
if (versionRetentionExt != null) {
data['version_retention_ext'] = versionRetentionExt;
}
if (versionRetentionMax != null) {
data['version_retention_max'] = versionRetentionMax;
}
if (currentPassword != null) data['current_password'] = currentPassword;
if (newPassword != null) data['new_password'] = newPassword;
if (twoFaEnabled != null) data['two_fa_enabled'] = twoFaEnabled;
if (twoFaCode != null) data['two_fa_code'] = twoFaCode;
if (disableViewSync != null) data['disable_view_sync'] = disableViewSync;
if (shareLinksInProfile != null) {
data['share_links_in_profile'] = shareLinksInProfile;
}
// PATCH 响应的 data 字段通常为 null,使用 void 避免类型转换错误
await ApiService.instance.patch<void>(
'/user/setting',
data: data,
);
}
/// 更新头像(传图片二进制数据,传null则重置为Gravatar
Future<void> updateAvatar(Uint8List? imageBytes) async {
await ApiService.instance.put<void>(
'/user/setting/avatar',
data: imageBytes,
isNoData: true,
);
}
/// 获取存储用量
/// _parseResponse 已自动提取 data 字段
Future<UserCapacityModel> getUserCapacity() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/user/capacity',
);
return UserCapacityModel.fromJson(response);
}
/// 准备启用2FA(获取TOTP密钥)
/// _parseResponse 自动提取 data 字段,后端 data 是 TOTP secret 字符串
Future<String> prepare2FA() async {
return await ApiService.instance.get<String>(
'/user/setting/2fa',
);
}
/// 启用2FA
Future<void> enable2FA(String code) async {
await updateUserSetting(twoFaEnabled: true, twoFaCode: code);
}
/// 禁用2FA
Future<void> disable2FA(String code) async {
await updateUserSetting(twoFaEnabled: false, twoFaCode: code);
}
/// 修改密码
Future<void> changePassword({
required String currentPassword,
required String newPassword,
}) async {
await updateUserSetting(
currentPassword: currentPassword,
newPassword: newPassword,
);
}
/// 修改昵称
Future<void> updateNick(String nick) async {
await updateUserSetting(nick: nick);
}
/// 修改主题色偏好
Future<void> updatePreferredTheme(String themeColor) async {
await updateUserSetting(preferredTheme: themeColor);
}
/// 修改语言偏好
Future<void> updateLanguage(String language) async {
await updateUserSetting(language: language);
}
/// 获取当前用户信息(用于刷新用户资料)
/// _parseResponse 已自动提取 data 字段
Future<UserModel> getCurrentUser() async {
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/session/user',
);
return UserModel.fromJson(response);
}
/// 撤销OAuth应用授权
Future<void> revokeOAuthGrant(String appId) async {
await ApiService.instance.delete<void>(
'/session/oauth/grant/$appId',
);
}
/// 解绑OIDC提供商
Future<void> unlinkOpenId(int provider) async {
await ApiService.instance.post<void>(
'/session/oidc/unlink',
data: {'provider': provider},
);
}
/// 删除Passkey
Future<void> deletePasskey(String passkeyId) async {
await ApiService.instance.delete<void>(
'/user/authn',
queryParameters: {'id': passkeyId},
);
}
/// 获取积分变动记录
/// _parseResponse 已自动提取 data 字段
Future<CreditChangeList> getCreditChanges({
int pageSize = 20,
String? nextPageToken,
}) async {
final queryParams = <String, dynamic>{
'page_size': pageSize,
'next_page_token': ?nextPageToken,
};
final response = await ApiService.instance.get<Map<String, dynamic>>(
'/user/creditChanges',
queryParameters: queryParams,
);
return CreditChangeList.fromJson(response);
}
}
+73
View File
@@ -0,0 +1,73 @@
import '../data/models/dav_account_model.dart';
import 'api_service.dart';
import '../core/utils/app_logger.dart';
/// WebDAV 服务
class WebdavService {
/// 列出所有 WebDAV 账户
Future<Map<String, dynamic>> listAccounts({
required int pageSize,
String? nextPageToken,
}) async {
final params = <String, dynamic>{
'page_size': pageSize,
...nextPageToken != null ? {'next_page_token': nextPageToken} : {},
};
return await ApiService.instance
.get<Map<String, dynamic>>('/devices/dav', queryParameters: params);
}
/// 创建 WebDAV 账户
Future<DavAccountModel> createAccount({
required String uri,
required String name,
bool? readonly,
bool? proxy,
bool? disableSysFiles,
}) async {
final data = <String, dynamic>{
'uri': uri,
'name': name,
...readonly != null ? {'readonly': readonly} : {},
...proxy != null ? {'proxy': proxy} : {},
...disableSysFiles != null ? {'disable_sys_files': disableSysFiles} : {},
};
final response = await ApiService.instance
.put<Map<String, dynamic>>('/devices/dav', data: data);
// 已经经过 api_service.dart -> _parseResponse 处理过的数据,
// 直接不使用, 不需要 response['data'] 去取
return DavAccountModel.fromJson(response);
}
/// 更新 WebDAV 账户
Future<DavAccountModel> updateAccount({
required String id,
String? uri,
String? name,
bool? readonly,
bool? proxy,
bool? disableSysFiles,
}) async {
final data = <String, dynamic>{
...uri != null ? {'uri': uri} : {},
...name != null ? {'name': name} : {},
...readonly != null ? {'readonly': readonly} : {},
...proxy != null ? {'proxy': proxy} : {},
...disableSysFiles != null ? {'disable_sys_files': disableSysFiles} : {},
};
final response = await ApiService.instance
.patch<Map<String, dynamic>>('/devices/dav/$id', data: data);
AppLogger.d('更新 WebDAV 账户成功: $response');
// 已经经过 api_service.dart -> _parseResponse 处理过的数据,
// 直接不使用, 不需要 response['data'] 去取
return DavAccountModel.fromJson(response);
}
/// 删除 WebDAV 账户
Future<void> deleteAccount(String id) async {
await ApiService.instance.delete<void>('/devices/dav/$id', data: {});
}
}