二次开发源码提交
This commit is contained in:
@@ -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},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.data,response 即 {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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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: {});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user