merge new features
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:crypto/crypto.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import 'storage_service.dart';
|
||||
import '../core/constants/storage_keys.dart';
|
||||
|
||||
class SiteAnnouncement {
|
||||
final String title;
|
||||
final String html;
|
||||
final String baseUrl;
|
||||
final String fingerprint;
|
||||
|
||||
const SiteAnnouncement({
|
||||
required this.title,
|
||||
required this.html,
|
||||
required this.baseUrl,
|
||||
required this.fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
/// 登录后公告服务。
|
||||
///
|
||||
/// Cloudreve V4 SiteConfig 的公告字段是 site_notice。
|
||||
/// 不读取 custom_html.headless_bottom / sidebar_bottom,那些是页面装饰 HTML。
|
||||
///
|
||||
/// 弹窗策略:
|
||||
/// - 公告为空:不弹;
|
||||
/// - 当前公告内容和用户上次关闭时一致:不弹;
|
||||
/// - 公告内容变化:重新弹。
|
||||
class AnnouncementService {
|
||||
AnnouncementService._();
|
||||
|
||||
static final AnnouncementService instance = AnnouncementService._();
|
||||
|
||||
bool _shownInSession = false;
|
||||
|
||||
/// 兼容旧调用。新的判断以持久化 fingerprint 为准。
|
||||
bool get hasShown => _shownInSession;
|
||||
|
||||
/// 兼容旧调用。新的业务请使用 [markDismissed]。
|
||||
void markShown() {
|
||||
_shownInSession = true;
|
||||
}
|
||||
|
||||
Future<SiteAnnouncement?> getSiteNotice() async {
|
||||
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||
'/site/config/basic',
|
||||
);
|
||||
|
||||
final data = _asMap(response['data']) ?? response;
|
||||
final notice = data['site_notice']?.toString().trim();
|
||||
|
||||
if (notice == null || notice.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final baseUrl = ApiService.instance.dio.options.baseUrl;
|
||||
return SiteAnnouncement(
|
||||
title: '公告',
|
||||
html: notice,
|
||||
baseUrl: baseUrl,
|
||||
fingerprint: _fingerprint(baseUrl: baseUrl, html: notice),
|
||||
);
|
||||
}
|
||||
|
||||
/// 只返回需要弹出的公告。
|
||||
///
|
||||
/// 用户关闭过同一条公告后,后续启动不会重复弹;站点公告内容改变后会再次弹。
|
||||
Future<SiteAnnouncement?> getChangedSiteNotice() async {
|
||||
final notice = await getSiteNotice();
|
||||
if (notice == null) return null;
|
||||
|
||||
if (_shownInSession) return null;
|
||||
|
||||
final dismissedFingerprint = await StorageService.instance.getString(
|
||||
StorageKeys.siteAnnouncementDismissedFingerprint,
|
||||
);
|
||||
|
||||
if (dismissedFingerprint == notice.fingerprint) {
|
||||
_shownInSession = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
return notice;
|
||||
}
|
||||
|
||||
/// 用户关闭公告后调用。
|
||||
Future<void> markDismissed(SiteAnnouncement announcement) async {
|
||||
_shownInSession = true;
|
||||
await StorageService.instance.setString(
|
||||
StorageKeys.siteAnnouncementDismissedFingerprint,
|
||||
announcement.fingerprint,
|
||||
);
|
||||
}
|
||||
|
||||
String _fingerprint({required String baseUrl, required String html}) {
|
||||
final normalized = '${baseUrl.trim()}\n${html.trim()}';
|
||||
return sha256.convert(utf8.encode(normalized)).toString();
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import '../core/utils/app_logger.dart';
|
||||
class ApiResponse<T> {
|
||||
final int code;
|
||||
final String message;
|
||||
final T? data;
|
||||
final dynamic data;
|
||||
final String? error;
|
||||
final String? correlationId;
|
||||
|
||||
@@ -26,7 +26,7 @@ class ApiResponse<T> {
|
||||
return ApiResponse<T>(
|
||||
code: json['code'] as int? ?? 0,
|
||||
message: json['msg'] as String? ?? '',
|
||||
data: json['data'] as T?,
|
||||
data: json['data'],
|
||||
error: json['error'] as String?,
|
||||
correlationId: json['correlation_id'] as String?,
|
||||
);
|
||||
@@ -198,6 +198,12 @@ class ApiService {
|
||||
AppLogger.d('API Error: ${error.requestOptions.uri} - ${error.message}');
|
||||
|
||||
// 检查是否是 401 错误(HTTP 401 或 JSON code: 401)
|
||||
final silent404 =
|
||||
error.requestOptions.extra['silent404'] as bool? ?? false;
|
||||
if (silent404 && error.response?.statusCode == 404) {
|
||||
return handler.next(error);
|
||||
}
|
||||
|
||||
bool is401Error = error.response?.statusCode == 401;
|
||||
if (!is401Error && error.response?.data is Map<String, dynamic>) {
|
||||
final data = error.response!.data as Map<String, dynamic>;
|
||||
@@ -320,12 +326,16 @@ class ApiService {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
bool noAuth = false,
|
||||
bool isNoData = false,
|
||||
bool silent404 = false,
|
||||
Map<String, dynamic>? headers,
|
||||
}) async {
|
||||
final response = await _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: Options(extra: {'noAuth': noAuth}, headers: headers),
|
||||
options: Options(
|
||||
extra: {'noAuth': noAuth, 'silent404': silent404},
|
||||
headers: headers,
|
||||
),
|
||||
);
|
||||
// 如果是分享请求, 则不进入 _parseResponse
|
||||
if (isNoData) {
|
||||
@@ -356,9 +366,12 @@ class ApiService {
|
||||
AppLogger.d('Response Data: ${response.data}');
|
||||
|
||||
var isActivEmail = 0;
|
||||
if (response.statusCode == 200) {
|
||||
Map<String, dynamic>? tmp = response.data as Map<String, dynamic>?;
|
||||
isActivEmail = tmp?['code'] as int;
|
||||
if (response.statusCode == 200 && response.data is Map) {
|
||||
final tmp = Map<String, dynamic>.from(response.data as Map);
|
||||
final code = tmp['code'];
|
||||
if (code is int) {
|
||||
isActivEmail = code;
|
||||
}
|
||||
}
|
||||
|
||||
if (isNoData || isActivEmail == 203) {
|
||||
@@ -447,11 +460,18 @@ class ApiService {
|
||||
T _parseResponse<T>(Response response) {
|
||||
final data = response.data;
|
||||
if (data is Map<String, dynamic>) {
|
||||
final apiResponse = ApiResponse<T>.fromJson(data);
|
||||
final apiResponse = ApiResponse<dynamic>.fromJson(data);
|
||||
if (!apiResponse.isSuccess && !apiResponse.isContinue) {
|
||||
throw ServerException(apiResponse.message, code: apiResponse.code);
|
||||
}
|
||||
return apiResponse.data as T;
|
||||
final payload = apiResponse.data;
|
||||
if (payload is T) {
|
||||
return payload;
|
||||
}
|
||||
if (data is T) {
|
||||
return data as T;
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../presentation/pages/auth/captcha_challenge_page.dart';
|
||||
@@ -30,6 +32,11 @@ class CaptchaService {
|
||||
String? _captchaToken;
|
||||
bool _isLoadingCaptcha = false;
|
||||
|
||||
// 代理配置(仅 Windows 桌面端)
|
||||
CaptchaProxyConfig? _proxyConfig;
|
||||
|
||||
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
|
||||
|
||||
bool get isLoadingCaptcha => _isLoadingCaptcha;
|
||||
String? get captchaImage => _captchaImage;
|
||||
String? get captchaTicket => _captchaTicket;
|
||||
@@ -103,13 +110,15 @@ class CaptchaService {
|
||||
Map<String, dynamic> config = <String, dynamic>{};
|
||||
|
||||
try {
|
||||
config = await AuthService.instance.getBasicSiteConfig().timeout(
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
config = await AuthService.instance
|
||||
.getBasicSiteConfig()
|
||||
.timeout(const Duration(seconds: 10));
|
||||
} catch (_) {}
|
||||
|
||||
final captchaType = _normalizeCaptchaType(
|
||||
(config['captcha_type'] ?? config['captchaType'] ?? config['captcha'])
|
||||
(config['captcha_type'] ??
|
||||
config['captchaType'] ??
|
||||
config['captcha'])
|
||||
?.toString(),
|
||||
);
|
||||
|
||||
@@ -151,8 +160,7 @@ class CaptchaService {
|
||||
'capAssetServer',
|
||||
]);
|
||||
|
||||
final isExternalCaptcha =
|
||||
captchaType == 'turnstile' ||
|
||||
final isExternalCaptcha = captchaType == 'turnstile' ||
|
||||
captchaType == 'recaptcha' ||
|
||||
captchaType == 'cap';
|
||||
|
||||
@@ -199,7 +207,7 @@ class CaptchaService {
|
||||
}
|
||||
|
||||
/// 跳转到 Web 验证码页面
|
||||
Future<void> openCaptchaChallenge(BuildContext context) async {
|
||||
Future<void> openCaptchaChallenge(BuildContext context, {VoidCallback? onVerified}) async {
|
||||
final server = ServerService.instance.currentServer;
|
||||
final config = captchaWebConfig;
|
||||
|
||||
@@ -210,14 +218,18 @@ class CaptchaService {
|
||||
|
||||
final token = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) =>
|
||||
CaptchaChallengePage(config: config, baseUrl: server.baseUrl),
|
||||
builder: (_) => CaptchaChallengePage(
|
||||
config: config,
|
||||
baseUrl: server.baseUrl,
|
||||
proxyConfig: _isDesktop ? _proxyConfig : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (token != null && token.isNotEmpty) {
|
||||
_captchaToken = token;
|
||||
ToastHelper.success('人机验证完成');
|
||||
onVerified?.call();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,16 +253,21 @@ class CaptchaService {
|
||||
Map<String, String> getCaptchaParams() {
|
||||
if (isWebCaptcha) {
|
||||
if (_captchaToken == null || _captchaToken!.isEmpty) return {};
|
||||
return {'captcha': _captchaToken!, 'ticket': _captchaToken!};
|
||||
return {
|
||||
'captcha': _captchaToken!,
|
||||
'ticket': _captchaToken!,
|
||||
};
|
||||
}
|
||||
|
||||
final userInput = captchaController.text.trim();
|
||||
if (userInput.isEmpty &&
|
||||
(_captchaTicket == null || _captchaTicket!.isEmpty)) {
|
||||
if (userInput.isEmpty && (_captchaTicket == null || _captchaTicket!.isEmpty)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return {'captcha': userInput, 'ticket': _captchaTicket ?? ''};
|
||||
return {
|
||||
'captcha': userInput,
|
||||
'ticket': _captchaTicket ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/// Web 验证码是否已通过
|
||||
@@ -263,30 +280,44 @@ class CaptchaService {
|
||||
final config = captchaWebConfig;
|
||||
final displayName = config?.displayName ?? '人机验证';
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoadingCaptcha
|
||||
? null
|
||||
: () => openCaptchaChallenge(context),
|
||||
icon: Icon(
|
||||
_captchaToken == null
|
||||
? Icons.verified_user_outlined
|
||||
: Icons.verified,
|
||||
),
|
||||
label: Text(
|
||||
_captchaToken == null
|
||||
? '点击完成 $displayName'
|
||||
: '$displayName 已完成,点击重新验证',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'当前验证码类型:$displayName',
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(context).hintColor),
|
||||
),
|
||||
],
|
||||
return StatefulBuilder(
|
||||
builder: (context, setState) {
|
||||
final hasProxy = _proxyConfig != null;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: _isLoadingCaptcha ? null : () async {
|
||||
await openCaptchaChallenge(context);
|
||||
setState(() {});
|
||||
},
|
||||
onLongPress: _isDesktop && Platform.isWindows
|
||||
? () => _showProxyDialog(context, setState)
|
||||
: null,
|
||||
icon: Icon(
|
||||
_captchaToken == null
|
||||
? Icons.verified_user_outlined
|
||||
: Icons.verified,
|
||||
),
|
||||
label: Text(
|
||||
_captchaToken == null
|
||||
? '点击完成 $displayName'
|
||||
: '$displayName 已完成,点击重新验证',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_isDesktop && Platform.isWindows
|
||||
? '当前验证码类型:$displayName${hasProxy ? ' (代理: $_proxyConfig)' : ''}\n网络问题验证失败可长按上方按钮可以设置代理(仅windows)'
|
||||
: '当前验证码类型:$displayName',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -392,4 +423,80 @@ class CaptchaService {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Windows 桌面端长按弹出代理设置对话框
|
||||
void _showProxyDialog(BuildContext context, StateSetter setState) {
|
||||
final hostCtrl = TextEditingController(text: _proxyConfig?.host ?? '');
|
||||
final portCtrl = TextEditingController(text: _proxyConfig?.port.toString() ?? '');
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) {
|
||||
return AlertDialog(
|
||||
title: const Text('WebView 代理设置'),
|
||||
content: SizedBox(
|
||||
width: 320,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'仅支持无认证代理(HTTP/SOCKS5)',
|
||||
style: TextStyle(fontSize: 12, color: Theme.of(ctx).hintColor),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: hostCtrl,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '代理地址',
|
||||
hintText: '127.0.0.1',
|
||||
prefixIcon: Icon(Icons.dns_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextFormField(
|
||||
controller: portCtrl,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '端口',
|
||||
hintText: '7890',
|
||||
prefixIcon: Icon(Icons.numbers),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
_proxyConfig = null;
|
||||
setState(() {});
|
||||
Navigator.of(ctx).pop();
|
||||
ToastHelper.success('已清除代理配置');
|
||||
},
|
||||
child: const Text('清除'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final host = hostCtrl.text.trim();
|
||||
final port = int.tryParse(portCtrl.text.trim()) ?? 0;
|
||||
if (host.isEmpty || port <= 0) {
|
||||
ToastHelper.failure('请输入有效的代理地址和端口');
|
||||
return;
|
||||
}
|
||||
_proxyConfig = CaptchaProxyConfig(host: host, port: port);
|
||||
setState(() {});
|
||||
Navigator.of(ctx).pop();
|
||||
ToastHelper.success('代理已设置: $host:$port');
|
||||
},
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'dart:async';
|
||||
|
||||
/// 全局弹窗队列。
|
||||
///
|
||||
/// 用于登录后自动弹出的公告、更新、剪贴板分享链接等场景,保证同一时间
|
||||
/// 只有一个弹窗/引导流程在前台显示。前一个流程结束后,后一个流程才会执行。
|
||||
class DialogQueueService {
|
||||
DialogQueueService._();
|
||||
|
||||
static final DialogQueueService instance = DialogQueueService._();
|
||||
|
||||
Future<void> _tail = Future<void>.value();
|
||||
|
||||
Future<T?> enqueue<T>(Future<T?> Function() task) {
|
||||
final completer = Completer<T?>();
|
||||
|
||||
_tail = _tail.catchError((_) {}).then((_) async {
|
||||
try {
|
||||
final result = await task();
|
||||
if (!completer.isCompleted) {
|
||||
completer.complete(result);
|
||||
}
|
||||
} catch (error, stackTrace) {
|
||||
if (!completer.isCompleted) {
|
||||
completer.completeError(error, stackTrace);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,338 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import '../core/utils/file_type_utils.dart';
|
||||
import '../core/utils/file_utils.dart';
|
||||
import '../data/models/file_model.dart';
|
||||
|
||||
class FileAppViewer {
|
||||
final String id;
|
||||
final String type;
|
||||
final String displayName;
|
||||
final List<String> exts;
|
||||
final String? icon;
|
||||
final int maxSize;
|
||||
final String? url;
|
||||
|
||||
const FileAppViewer({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.displayName,
|
||||
required this.exts,
|
||||
this.icon,
|
||||
this.maxSize = 0,
|
||||
this.url,
|
||||
});
|
||||
|
||||
factory FileAppViewer.fromJson(Map<String, dynamic> json) {
|
||||
return FileAppViewer(
|
||||
id: json['id']?.toString() ?? '',
|
||||
type: json['type']?.toString() ?? '',
|
||||
displayName: json['display_name']?.toString() ??
|
||||
json['displayName']?.toString() ??
|
||||
json['name']?.toString() ??
|
||||
'文件应用',
|
||||
exts: _parseExts(json['exts'] ?? json['extensions'] ?? json['ext']),
|
||||
icon: json['icon']?.toString(),
|
||||
maxSize: (json['max_size'] as num?)?.toInt() ??
|
||||
(json['maxSize'] as num?)?.toInt() ??
|
||||
0,
|
||||
url: json['url']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
static List<String> _parseExts(dynamic raw) {
|
||||
if (raw is List) {
|
||||
return raw
|
||||
.map((e) => e.toString().toLowerCase().replaceAll('.', '').trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
if (raw is String) {
|
||||
return raw
|
||||
.split(',')
|
||||
.map((e) => e.toLowerCase().replaceAll('.', '').trim())
|
||||
.where((e) => e.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
bool supports(FileModel file) {
|
||||
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
|
||||
if (ext.isEmpty || !exts.contains(ext)) return false;
|
||||
if (maxSize > 0 && file.size > maxSize) return false;
|
||||
return id.isNotEmpty;
|
||||
}
|
||||
|
||||
bool get isWopi => type.toLowerCase().contains('wopi');
|
||||
}
|
||||
|
||||
class FileAppSession {
|
||||
final FileAppViewer viewer;
|
||||
final String wopiSrc;
|
||||
final String? accessToken;
|
||||
final int? expires;
|
||||
|
||||
const FileAppSession({
|
||||
required this.viewer,
|
||||
required this.wopiSrc,
|
||||
this.accessToken,
|
||||
this.expires,
|
||||
});
|
||||
|
||||
bool get hasAccessToken => accessToken != null && accessToken!.isNotEmpty;
|
||||
}
|
||||
|
||||
/// Cloudreve V4 文件应用服务。
|
||||
///
|
||||
/// 注意:部分 Cloudreve V4 站点没有 `/site/config`,只有 `/site/config/basic`。
|
||||
/// 所以这里优先请求 `/site/config/basic`,如果没有 file_viewers 再尝试
|
||||
/// `/site/config`,并且对 404 做静默降级,避免 WebView 打开器直接崩溃。
|
||||
class FileAppService {
|
||||
FileAppService._();
|
||||
|
||||
static final FileAppService instance = FileAppService._();
|
||||
|
||||
List<FileAppViewer>? _cachedViewers;
|
||||
DateTime? _cachedAt;
|
||||
|
||||
Future<List<FileAppViewer>> getViewers({bool forceRefresh = false}) async {
|
||||
final cached = _cachedViewers;
|
||||
final cachedAt = _cachedAt;
|
||||
if (!forceRefresh &&
|
||||
cached != null &&
|
||||
cachedAt != null &&
|
||||
DateTime.now().difference(cachedAt).inMinutes < 10) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
final viewers = await _loadViewersFromSiteConfig();
|
||||
|
||||
_cachedViewers = viewers;
|
||||
_cachedAt = DateTime.now();
|
||||
|
||||
return viewers;
|
||||
}
|
||||
|
||||
Future<List<FileAppViewer>> _loadViewersFromSiteConfig() async {
|
||||
// 当前项目登录验证码就是从 /site/config/basic 取的;
|
||||
// /site/config 在部分 Cloudreve 部署中不存在,会返回 404。
|
||||
const endpoints = <String>[
|
||||
'/site/config/basic',
|
||||
'/site/config',
|
||||
];
|
||||
|
||||
for (final endpoint in endpoints) {
|
||||
final root = await _tryGetSiteConfig(endpoint);
|
||||
if (root == null) continue;
|
||||
|
||||
final viewers = _extractViewers(root);
|
||||
if (viewers.isNotEmpty) {
|
||||
return viewers;
|
||||
}
|
||||
}
|
||||
|
||||
return const [];
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _tryGetSiteConfig(String endpoint) async {
|
||||
try {
|
||||
final response = await ApiService.instance.dio.get<dynamic>(
|
||||
endpoint,
|
||||
options: Options(
|
||||
extra: {'noAuth': true},
|
||||
// 404 也作为普通响应返回,由这里自己处理,避免进入全局错误拦截器。
|
||||
validateStatus: (status) => status != null && status >= 200 && status < 500,
|
||||
),
|
||||
);
|
||||
|
||||
if (response.statusCode == 404) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final raw = response.data;
|
||||
final map = _asMap(raw);
|
||||
if (map == null) return null;
|
||||
|
||||
final code = map['code'];
|
||||
if (code is int && code != 0 && code != 203) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return _asMap(map['data']) ?? map;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<FileAppViewer?> findViewerForFile(
|
||||
FileModel file, {
|
||||
bool forceRefresh = false,
|
||||
}) async {
|
||||
final viewers = await getViewers(forceRefresh: forceRefresh);
|
||||
final ext = FileTypeUtils.getExtension(file.name).toLowerCase();
|
||||
|
||||
final candidates = viewers.where((viewer) => viewer.supports(file)).toList();
|
||||
if (candidates.isEmpty) return null;
|
||||
|
||||
candidates.sort((a, b) {
|
||||
final aw = a.isWopi ? 0 : 1;
|
||||
final bw = b.isWopi ? 0 : 1;
|
||||
if (aw != bw) return aw.compareTo(bw);
|
||||
|
||||
final aExact = a.exts.contains(ext) ? 0 : 1;
|
||||
final bExact = b.exts.contains(ext) ? 0 : 1;
|
||||
return aExact.compareTo(bExact);
|
||||
});
|
||||
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
Future<FileAppSession> createViewerSession({
|
||||
required FileModel file,
|
||||
required FileAppViewer viewer,
|
||||
String preferredAction = 'view',
|
||||
}) async {
|
||||
final uri = FileUtils.toCloudreveUri(file.relativePath);
|
||||
final data = <String, dynamic>{
|
||||
'uri': uri,
|
||||
'viewer_id': viewer.id,
|
||||
'preferred_action': preferredAction,
|
||||
'parent_uri': _parentUri(uri),
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.put<Map<String, dynamic>>(
|
||||
'/file/viewerSession',
|
||||
data: data,
|
||||
);
|
||||
|
||||
final root = _asMap(response['data']) ?? response;
|
||||
final session = _asMap(root['session']);
|
||||
final wopiSrc = root['wopi_src']?.toString() ??
|
||||
root['wopiSrc']?.toString() ??
|
||||
root['url']?.toString() ??
|
||||
'';
|
||||
|
||||
if (wopiSrc.isEmpty) {
|
||||
throw Exception('文件应用没有返回可打开的 URL');
|
||||
}
|
||||
|
||||
return FileAppSession(
|
||||
viewer: viewer,
|
||||
wopiSrc: _absoluteUrl(wopiSrc),
|
||||
accessToken: session?['access_token']?.toString() ??
|
||||
session?['accessToken']?.toString(),
|
||||
expires: (session?['expires'] as num?)?.toInt(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<FileAppSession> openFile({
|
||||
required FileModel file,
|
||||
String preferredAction = 'view',
|
||||
}) async {
|
||||
final viewer = await findViewerForFile(file);
|
||||
if (viewer == null) {
|
||||
throw Exception('没有找到支持 ${FileTypeUtils.getExtension(file.name)} 的 Cloudreve 文件应用');
|
||||
}
|
||||
|
||||
try {
|
||||
return await createViewerSession(
|
||||
file: file,
|
||||
viewer: viewer,
|
||||
preferredAction: preferredAction,
|
||||
);
|
||||
} catch (_) {
|
||||
if (preferredAction != 'view') {
|
||||
return createViewerSession(
|
||||
file: file,
|
||||
viewer: viewer,
|
||||
preferredAction: 'view',
|
||||
);
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
List<FileAppViewer> _extractViewers(Map<String, dynamic> root) {
|
||||
final raw = _findValueByKey(root, const [
|
||||
'file_viewers',
|
||||
'fileViewers',
|
||||
'file_viewer',
|
||||
'viewers',
|
||||
]);
|
||||
|
||||
dynamic viewerList = raw;
|
||||
|
||||
final rawMap = _asMap(raw);
|
||||
if (rawMap != null) {
|
||||
viewerList = rawMap['viewers'] ?? rawMap['items'] ?? rawMap['data'];
|
||||
}
|
||||
|
||||
if (viewerList is! List) return const [];
|
||||
|
||||
return viewerList
|
||||
.whereType<Map>()
|
||||
.map((item) => FileAppViewer.fromJson(Map<String, dynamic>.from(item)))
|
||||
.where((viewer) => viewer.id.isNotEmpty && viewer.exts.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
dynamic _findValueByKey(dynamic value, List<String> keys) {
|
||||
if (value is Map) {
|
||||
for (final key in keys) {
|
||||
if (value.containsKey(key)) {
|
||||
return value[key];
|
||||
}
|
||||
}
|
||||
|
||||
for (final child in value.values) {
|
||||
final found = _findValueByKey(child, keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
} else if (value is List) {
|
||||
for (final child in value) {
|
||||
final found = _findValueByKey(child, keys);
|
||||
if (found != null) return found;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
String _parentUri(String uri) {
|
||||
final normalized = uri.endsWith('/') ? uri.substring(0, uri.length - 1) : uri;
|
||||
final index = normalized.lastIndexOf('/');
|
||||
if (index <= 'cloudreve://my'.length) {
|
||||
return 'cloudreve://my';
|
||||
}
|
||||
return normalized.substring(0, index);
|
||||
}
|
||||
|
||||
String _absoluteUrl(String url) {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return url;
|
||||
}
|
||||
|
||||
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
|
||||
final origin = Uri(
|
||||
scheme: base.scheme,
|
||||
host: base.host,
|
||||
port: base.hasPort ? base.port : null,
|
||||
).toString();
|
||||
|
||||
if (url.startsWith('/')) {
|
||||
return '$origin$url';
|
||||
}
|
||||
|
||||
return '$origin/$url';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,906 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import 'api_service.dart';
|
||||
import '../core/utils/file_utils.dart';
|
||||
|
||||
class ShareLinkCandidate {
|
||||
final String id;
|
||||
final String url;
|
||||
final String? password;
|
||||
|
||||
const ShareLinkCandidate({
|
||||
required this.id,
|
||||
required this.url,
|
||||
this.password,
|
||||
});
|
||||
}
|
||||
|
||||
class ShareLinkInfo {
|
||||
final String id;
|
||||
final String name;
|
||||
final int visited;
|
||||
final bool expired;
|
||||
final bool unlocked;
|
||||
final bool isPrivate;
|
||||
final int sourceType;
|
||||
final String? sourceUri;
|
||||
final int size;
|
||||
final String? url;
|
||||
final String? ownerName;
|
||||
final String? ownerId;
|
||||
final String? ownerAvatar;
|
||||
final String? contextHint;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? expires;
|
||||
|
||||
const ShareLinkInfo({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.visited,
|
||||
required this.expired,
|
||||
required this.unlocked,
|
||||
required this.isPrivate,
|
||||
required this.sourceType,
|
||||
this.sourceUri,
|
||||
this.size = 0,
|
||||
this.url,
|
||||
this.ownerName,
|
||||
this.ownerId,
|
||||
this.ownerAvatar,
|
||||
this.contextHint,
|
||||
this.createdAt,
|
||||
this.expires,
|
||||
});
|
||||
|
||||
bool get isFolder => sourceType == 1;
|
||||
bool get isFile => !isFolder;
|
||||
|
||||
factory ShareLinkInfo.fromJson(Map<String, dynamic> json) {
|
||||
final owner = _asMap(json['owner']);
|
||||
final source = _asMap(json['source']);
|
||||
final file = _asMap(json['file']);
|
||||
final object = _asMap(json['object']);
|
||||
final entity = _asMap(json['entity']);
|
||||
final rawSource = json['source'];
|
||||
|
||||
final ownerId = (owner?['id'] ??
|
||||
json['owner_id'] ??
|
||||
json['ownerId'] ??
|
||||
source?['owner_id'] ??
|
||||
file?['owner_id'])
|
||||
?.toString();
|
||||
|
||||
final name = (json['name'] ??
|
||||
file?['name'] ??
|
||||
source?['name'] ??
|
||||
object?['name'] ??
|
||||
'分享文件')
|
||||
.toString();
|
||||
|
||||
final parsedSourceUri = (json['source_uri'] ??
|
||||
json['sourceUri'] ??
|
||||
json['uri'] ??
|
||||
json['path'] ??
|
||||
(rawSource is String ? rawSource : null) ??
|
||||
source?['uri'] ??
|
||||
source?['source_uri'] ??
|
||||
source?['path'] ??
|
||||
source?['url'] ??
|
||||
file?['uri'] ??
|
||||
file?['source_uri'] ??
|
||||
file?['path'] ??
|
||||
object?['uri'] ??
|
||||
object?['source_uri'] ??
|
||||
object?['path'] ??
|
||||
entity?['uri'] ??
|
||||
entity?['source_uri'] ??
|
||||
entity?['path'])
|
||||
?.toString();
|
||||
|
||||
final resolvedSourceUri = _looksLikeCloudreveUri(parsedSourceUri)
|
||||
? parsedSourceUri
|
||||
: _fallbackSourceUri(ownerId: ownerId, name: name);
|
||||
|
||||
final size = _asInt(json['size'] ??
|
||||
json['source_size'] ??
|
||||
json['sourceSize'] ??
|
||||
source?['size'] ??
|
||||
file?['size'] ??
|
||||
object?['size'] ??
|
||||
entity?['size']);
|
||||
|
||||
return ShareLinkInfo(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: name,
|
||||
size: size,
|
||||
visited: _asInt(json['visited']),
|
||||
expired: json['expired'] as bool? ?? false,
|
||||
unlocked: json['unlocked'] as bool? ?? false,
|
||||
isPrivate: json['is_private'] as bool? ?? false,
|
||||
sourceType: _asInt(json['source_type'] ??
|
||||
source?['type'] ??
|
||||
file?['type'] ??
|
||||
object?['type']),
|
||||
sourceUri: resolvedSourceUri,
|
||||
url: json['url']?.toString(),
|
||||
ownerName: owner?['nickname']?.toString() ??
|
||||
owner?['name']?.toString() ??
|
||||
json['owner_name']?.toString(),
|
||||
ownerId: ownerId,
|
||||
ownerAvatar: owner?['avatar']?.toString(),
|
||||
contextHint: json['context_hint']?.toString() ??
|
||||
json['contextHint']?.toString() ??
|
||||
json['context']?.toString(),
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
expires: _parseDate(json['expires']),
|
||||
);
|
||||
}
|
||||
|
||||
static bool _looksLikeCloudreveUri(String? value) {
|
||||
if (value == null || value.trim().isEmpty) return false;
|
||||
return value.trim().startsWith('cloudreve://');
|
||||
}
|
||||
|
||||
static String? _fallbackSourceUri({
|
||||
required String? ownerId,
|
||||
required String name,
|
||||
}) {
|
||||
// 不再用 owner@my 兜底。公开分享上下文不是当前账号的 my 文件系统,
|
||||
// 用 owner@my 会导致 /file/url 返回 40081 或空 urls。
|
||||
// 真正的兜底由 ShareLinkService.getShareInfo 根据分享 ID 构造
|
||||
// cloudreve://<shareId>@share/ 完成。
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
static int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic value) {
|
||||
final text = value?.toString();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return DateTime.tryParse(text);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareLinkFile {
|
||||
final int type;
|
||||
final String id;
|
||||
final String name;
|
||||
final int size;
|
||||
final String path;
|
||||
final DateTime? createdAt;
|
||||
final DateTime? updatedAt;
|
||||
final String? primaryEntity;
|
||||
final String? capability;
|
||||
final String? contextHint;
|
||||
final Map<String, dynamic>? metadata;
|
||||
|
||||
const ShareLinkFile({
|
||||
required this.type,
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.size,
|
||||
required this.path,
|
||||
this.createdAt,
|
||||
this.updatedAt,
|
||||
this.primaryEntity,
|
||||
this.capability,
|
||||
this.contextHint,
|
||||
this.metadata,
|
||||
});
|
||||
|
||||
bool get isFolder => type == 1;
|
||||
bool get isFile => !isFolder;
|
||||
|
||||
factory ShareLinkFile.fromJson(Map<String, dynamic> json) {
|
||||
return ShareLinkFile(
|
||||
type: _asInt(json['type']),
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: json['name']?.toString() ?? '未命名文件',
|
||||
size: _asInt(json['size']),
|
||||
path: json['path']?.toString() ?? '',
|
||||
createdAt: _parseDate(json['created_at']),
|
||||
updatedAt: _parseDate(json['updated_at']),
|
||||
primaryEntity: json['primary_entity']?.toString() ??
|
||||
json['entity']?.toString(),
|
||||
capability: json['capability']?.toString(),
|
||||
contextHint: json['context_hint']?.toString(),
|
||||
metadata: _asMap(json['metadata']),
|
||||
);
|
||||
}
|
||||
|
||||
static int _asInt(dynamic value) {
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) return int.tryParse(value) ?? 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static DateTime? _parseDate(dynamic value) {
|
||||
final text = value?.toString();
|
||||
if (text == null || text.isEmpty) return null;
|
||||
return DateTime.tryParse(text);
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class ShareLinkFileListResult {
|
||||
final List<ShareLinkFile> files;
|
||||
final ShareLinkFile? parent;
|
||||
final String? contextHint;
|
||||
final bool hasMore;
|
||||
final String? nextPageToken;
|
||||
|
||||
const ShareLinkFileListResult({
|
||||
required this.files,
|
||||
this.parent,
|
||||
this.contextHint,
|
||||
this.hasMore = false,
|
||||
this.nextPageToken,
|
||||
});
|
||||
|
||||
factory ShareLinkFileListResult.fromJson(Map<String, dynamic> json) {
|
||||
final rawFiles = json['files'];
|
||||
final rawParent = json['parent'];
|
||||
final pagination = ShareLinkService._asMap(json['pagination']);
|
||||
|
||||
return ShareLinkFileListResult(
|
||||
files: rawFiles is List
|
||||
? rawFiles
|
||||
.whereType<Map>()
|
||||
.map((item) => ShareLinkFile.fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList()
|
||||
: const [],
|
||||
parent: rawParent is Map
|
||||
? ShareLinkFile.fromJson(Map<String, dynamic>.from(rawParent))
|
||||
: null,
|
||||
contextHint: json['context_hint']?.toString(),
|
||||
hasMore: pagination?['next_token'] != null,
|
||||
nextPageToken: pagination?['next_token']?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ShareDownloadUrlResult {
|
||||
final String url;
|
||||
final DateTime? expires;
|
||||
|
||||
const ShareDownloadUrlResult({
|
||||
required this.url,
|
||||
this.expires,
|
||||
});
|
||||
}
|
||||
|
||||
class ShareLinkService {
|
||||
ShareLinkService._();
|
||||
|
||||
static final ShareLinkService instance = ShareLinkService._();
|
||||
|
||||
/// 兼容当前站点固定域名,也兼容用户自定义 Cloudreve 域名的 /s/{id}/{password?} 分享链接。
|
||||
static final RegExp _shareLinkRegExp = RegExp(
|
||||
r'https?://[^\s]+?/s/([A-Za-z0-9_-]+)(?:/([A-Za-z0-9_-]+))?',
|
||||
caseSensitive: false,
|
||||
);
|
||||
|
||||
ShareLinkCandidate? parseShareLink(String? text) {
|
||||
if (text == null || text.trim().isEmpty) return null;
|
||||
|
||||
final match = _shareLinkRegExp.firstMatch(text.trim());
|
||||
if (match == null) return null;
|
||||
|
||||
final id = match.group(1);
|
||||
if (id == null || id.isEmpty) return null;
|
||||
|
||||
final password = match.group(2);
|
||||
final url = match.group(0)!;
|
||||
|
||||
return ShareLinkCandidate(
|
||||
id: id,
|
||||
url: url,
|
||||
password: password == null || password.isEmpty ? null : password,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ShareLinkInfo> getShareInfo(
|
||||
ShareLinkCandidate candidate, {
|
||||
String? password,
|
||||
}) async {
|
||||
final resolvedPassword = password ?? candidate.password;
|
||||
final query = <String, dynamic>{
|
||||
'count_views': true,
|
||||
'owner_extended': true,
|
||||
if (resolvedPassword != null && resolvedPassword.isNotEmpty)
|
||||
'password': resolvedPassword,
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||
'/share/info/${candidate.id}',
|
||||
queryParameters: query,
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: const {
|
||||
'X-Cr-Context-Hint': 'share',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(body['data']) ?? body;
|
||||
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||
final sourceType = ShareLinkInfo._asInt(data['source_type']);
|
||||
|
||||
// 公开分享页面访问文件时,必须使用 Cloudreve 官方 share 文件系统 URI,
|
||||
// 即 cloudreve://{shareId}[:password]@share[/...]。
|
||||
// /share/info 返回的 source_uri 往往是所有者 my 文件系统里的真实路径,
|
||||
// 匿名/noAuth 场景拿它直接请求 /file/url 会被服务端按普通文件解析,
|
||||
// 进而返回 40081 Entity not exist。
|
||||
final accessUri = shareRootUri(
|
||||
shareId: candidate.id,
|
||||
password: resolvedPassword,
|
||||
trailingSlash: sourceType != 0,
|
||||
);
|
||||
|
||||
return ShareLinkInfo.fromJson(<String, dynamic>{
|
||||
...data,
|
||||
'source_uri': accessUri,
|
||||
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
|
||||
headerContext != null &&
|
||||
headerContext.isNotEmpty)
|
||||
'context_hint': headerContext,
|
||||
});
|
||||
}
|
||||
|
||||
String? ownerAvatarUrl(ShareLinkInfo info) {
|
||||
final ownerId = info.ownerId;
|
||||
if (ownerId == null || ownerId.isEmpty) return null;
|
||||
|
||||
final avatar = info.ownerAvatar;
|
||||
if (avatar != null && avatar.startsWith(RegExp(r'https?://'))) {
|
||||
return avatar;
|
||||
}
|
||||
|
||||
final base = ApiService.instance.dio.options.baseUrl.replaceFirst(RegExp(r'/+$'), '');
|
||||
return '$base/user/avatar/${Uri.encodeComponent(ownerId)}';
|
||||
}
|
||||
|
||||
/// 按分享的 source_uri 读取目录文件。
|
||||
///
|
||||
/// Cloudreve V4 的 /file 接口支持 JWT Optional,并通过 X-Cr-Context-Hint
|
||||
/// 绑定分享上下文。首次读取时用 `share`,服务端返回 context_hint 后,后续下载
|
||||
/// 和进入子目录都继续携带该 context_hint。
|
||||
Future<ShareLinkFileListResult> listSharedFiles({
|
||||
required String uri,
|
||||
String? contextHint,
|
||||
int page = 0,
|
||||
int pageSize = 100,
|
||||
String? nextPageToken,
|
||||
}) async {
|
||||
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||
'/file',
|
||||
queryParameters: <String, dynamic>{
|
||||
'uri': uri,
|
||||
'page': page,
|
||||
'page_size': pageSize,
|
||||
if (nextPageToken != null && nextPageToken.isNotEmpty)
|
||||
'next_page_token': nextPageToken,
|
||||
},
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: <String, dynamic>{
|
||||
'X-Cr-Context-Hint': _firstContextHint(contextHint),
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(body['data']) ?? body;
|
||||
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||
return ShareLinkFileListResult.fromJson(<String, dynamic>{
|
||||
...data,
|
||||
if ((data['context_hint'] == null || data['context_hint'].toString().isEmpty) &&
|
||||
headerContext != null &&
|
||||
headerContext.isNotEmpty)
|
||||
'context_hint': headerContext,
|
||||
});
|
||||
}
|
||||
|
||||
/// 读取分享上下文中文件详情,主要用于拿到 primary_entity。
|
||||
Future<ShareLinkFile> getSharedFileInfo({
|
||||
required String uri,
|
||||
String? contextHint,
|
||||
String? shareId,
|
||||
String? password,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
|
||||
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||
try {
|
||||
final response = await ApiService.instance.dio.get<Map<String, dynamic>>(
|
||||
'/file/info',
|
||||
queryParameters: <String, dynamic>{
|
||||
'uri': uri,
|
||||
'extended': true,
|
||||
},
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: <String, dynamic>{
|
||||
'X-Cr-Context-Hint': hint,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final body = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(body['data']) ?? body;
|
||||
final headerContext = _headerValue(response.headers, 'X-Cr-Context-Hint');
|
||||
return ShareLinkFile.fromJson(<String, dynamic>{
|
||||
...data,
|
||||
'context_hint': data['context_hint'] ?? headerContext ?? hint,
|
||||
});
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception('文件详情读取失败');
|
||||
}
|
||||
|
||||
ShareLinkFile fileFromShareInfo(ShareLinkInfo info) {
|
||||
final sourceUri = info.sourceUri ??
|
||||
ShareLinkInfo._fallbackSourceUri(
|
||||
ownerId: info.ownerId,
|
||||
name: info.name,
|
||||
) ??
|
||||
'';
|
||||
return ShareLinkFile(
|
||||
type: info.sourceType,
|
||||
id: info.id,
|
||||
name: info.name,
|
||||
size: info.size,
|
||||
path: sourceUri,
|
||||
createdAt: info.createdAt,
|
||||
updatedAt: info.createdAt,
|
||||
contextHint: info.contextHint,
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取分享上下文中的下载 URL。
|
||||
///
|
||||
/// 按 Cloudreve V4 官方 `/file/url` 方式创建临时下载链接:
|
||||
/// body 只提交 `uris`,文件夹批量下载时提交 `archive: true`;
|
||||
/// 分享访问走 JWT Optional,因此这里使用 noAuth,避免当前登录用户的
|
||||
/// Authorization 影响分享上下文。
|
||||
Future<ShareDownloadUrlResult> createShareDownloadUrl({
|
||||
required String uri,
|
||||
String? contextHint,
|
||||
String? shareId,
|
||||
String? password,
|
||||
String? entity,
|
||||
String? fileName,
|
||||
bool archive = false,
|
||||
}) async {
|
||||
Object? lastError;
|
||||
|
||||
// Cloudreve V4 官方接口:POST /file/url
|
||||
// Header: X-Cr-Context-Hint
|
||||
// Body: { "uris": [...], "archive": true? }
|
||||
//
|
||||
// 分享下载必须不带当前账号 token;否则服务端可能按当前账号解析 URI,
|
||||
// 出现 Entity not exist (40081)。
|
||||
for (final candidateUri in _uriCandidates(
|
||||
uri,
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
fileName: fileName,
|
||||
)) {
|
||||
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||
try {
|
||||
return await _createShareDownloadUrlOnce(
|
||||
uri: candidateUri,
|
||||
contextHint: hint,
|
||||
archive: archive,
|
||||
);
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception('服务端没有返回可用的下载链接');
|
||||
}
|
||||
|
||||
Future<ShareDownloadUrlResult> _createShareDownloadUrlOnce({
|
||||
required String uri,
|
||||
required String contextHint,
|
||||
bool archive = false,
|
||||
}) async {
|
||||
final body = <String, dynamic>{
|
||||
'uris': <String>[uri],
|
||||
'download': true,
|
||||
if (archive) 'archive': true,
|
||||
};
|
||||
|
||||
final response = await ApiService.instance.dio.post<Map<String, dynamic>>(
|
||||
'/file/url',
|
||||
data: body,
|
||||
options: Options(
|
||||
extra: const {'noAuth': true},
|
||||
headers: <String, dynamic>{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Cr-Context-Hint': contextHint,
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final raw = response.data ?? <String, dynamic>{};
|
||||
final data = _asMap(raw['data']) ?? raw;
|
||||
final url = _extractDownloadUrl(data) ?? _extractDownloadUrl(raw);
|
||||
if (url != null && url.isNotEmpty) {
|
||||
return ShareDownloadUrlResult(
|
||||
url: url,
|
||||
expires: ShareLinkInfo._parseDate(data['expires'] ?? raw['expires']),
|
||||
);
|
||||
}
|
||||
|
||||
throw Exception('服务端没有返回可用的下载链接');
|
||||
}
|
||||
|
||||
/// 将分享文件转存到当前用户网盘。
|
||||
///
|
||||
/// Cloudreve V4 使用 /file/move,同一接口可移动/复制文件;传入 copy=true
|
||||
/// 时即为转存/复制到目标目录。
|
||||
Future<void> saveSharedFiles({
|
||||
required List<String> uris,
|
||||
required String destination,
|
||||
String? contextHint,
|
||||
String? shareId,
|
||||
String? password,
|
||||
String? fileName,
|
||||
}) async {
|
||||
final dst = FileUtils.toCloudreveUri(destination);
|
||||
Object? lastError;
|
||||
|
||||
final uriCandidateSets = uris
|
||||
.map((uri) => _uriCandidates(
|
||||
uri,
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
// 单文件分享的 source_uri 是 cloudreve://{id}@share 根。
|
||||
// /file/move 不允许复制 share 根目录,因此需要优先尝试
|
||||
// cloudreve://{id}@share/{fileName},并过滤掉根 URI。
|
||||
fileName: uris.length == 1 ? fileName : null,
|
||||
includeRootFallback: false,
|
||||
)
|
||||
.where((candidate) => !isShareRootUri(candidate, shareId: shareId))
|
||||
.toList())
|
||||
.where((candidates) => candidates.isNotEmpty)
|
||||
.toList();
|
||||
|
||||
if (uriCandidateSets.isEmpty) {
|
||||
throw Exception('不能直接转存分享根目录,请选择具体文件或进入目录后再转存');
|
||||
}
|
||||
|
||||
for (final hint in _contextHintCandidates(contextHint, shareId: shareId)) {
|
||||
for (final candidateUris in _cartesianUriCandidates(uriCandidateSets)) {
|
||||
if (candidateUris.isEmpty) continue;
|
||||
try {
|
||||
await ApiService.instance.post<void>(
|
||||
'/file/move',
|
||||
data: <String, dynamic>{
|
||||
'uris': candidateUris,
|
||||
'dst': dst,
|
||||
'copy': true,
|
||||
},
|
||||
headers: <String, dynamic>{
|
||||
'Content-Type': 'application/json',
|
||||
'X-Cr-Context-Hint': hint,
|
||||
},
|
||||
);
|
||||
return;
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError ?? Exception('转存失败');
|
||||
}
|
||||
|
||||
static List<String> _uriCandidates(
|
||||
String uri, {
|
||||
String? shareId,
|
||||
String? password,
|
||||
String? fileName,
|
||||
bool includeRootFallback = true,
|
||||
}) {
|
||||
final values = <String>[];
|
||||
|
||||
void add(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
final shareUriCandidates = _shareScopedUriCandidates(
|
||||
uri,
|
||||
shareId: shareId,
|
||||
password: password,
|
||||
);
|
||||
|
||||
// 官方页面进入的是 cloudreve://{shareId}@share 文件系统;
|
||||
// 如果 /file 返回了 cloudreve://my/... 或 owner@my/...,
|
||||
// 转存/下载时也要先折算成 share 文件系统里的相对路径。
|
||||
for (final item in shareUriCandidates) {
|
||||
add(item);
|
||||
}
|
||||
|
||||
final name = fileName?.trim();
|
||||
final id = shareId?.trim();
|
||||
if (id != null && id.isNotEmpty && name != null && name.isNotEmpty) {
|
||||
final root = shareRootUri(
|
||||
shareId: id,
|
||||
password: password,
|
||||
trailingSlash: false,
|
||||
);
|
||||
add('$root/${Uri.encodeComponent(name)}');
|
||||
add('$root/${_encodeCloudrevePath(name)}');
|
||||
}
|
||||
|
||||
add(_withSharePassword(uri, shareId: shareId, password: password));
|
||||
add(uri);
|
||||
|
||||
if (uri.startsWith('cloudreve://')) {
|
||||
final withPassword = _withSharePassword(uri, shareId: shareId, password: password);
|
||||
if (withPassword != uri) add(withPassword);
|
||||
|
||||
final slash = uri.indexOf('/', 'cloudreve://'.length);
|
||||
if (slash > 0) {
|
||||
final prefix = uri.substring(0, slash + 1);
|
||||
final path = slash < uri.length - 1 ? uri.substring(slash + 1) : '';
|
||||
if (path.isNotEmpty) {
|
||||
final encodedPath = _encodeCloudrevePath(path);
|
||||
add(_withSharePassword('$prefix$encodedPath', shareId: shareId, password: password));
|
||||
add('$prefix$encodedPath');
|
||||
}
|
||||
if (!uri.endsWith('/')) {
|
||||
add(_withSharePassword('$uri/', shareId: shareId, password: password));
|
||||
add('$uri/');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (includeRootFallback && shareId != null && shareId.trim().isNotEmpty) {
|
||||
add(shareRootUri(shareId: shareId, password: password, trailingSlash: true));
|
||||
add(shareRootUri(shareId: shareId, password: password, trailingSlash: false));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
static List<String> _shareScopedUriCandidates(
|
||||
String uri, {
|
||||
String? shareId,
|
||||
String? password,
|
||||
}) {
|
||||
final id = shareId?.trim();
|
||||
if (id == null || id.isEmpty || !uri.startsWith('cloudreve://')) {
|
||||
return const <String>[];
|
||||
}
|
||||
|
||||
final values = <String>[];
|
||||
void add(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
final authorityStart = 'cloudreve://'.length;
|
||||
final slash = uri.indexOf('/', authorityStart);
|
||||
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
|
||||
final rawPath = slash >= 0 && slash < uri.length - 1 ? uri.substring(slash + 1) : '';
|
||||
final decodedPath = rawPath
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.map((segment) => Uri.decodeComponent(segment))
|
||||
.join('/');
|
||||
|
||||
final isShareAuthority = authority.endsWith('@share');
|
||||
if (isShareAuthority) {
|
||||
add(_withSharePassword(uri, shareId: id, password: password));
|
||||
add(uri);
|
||||
return values;
|
||||
}
|
||||
|
||||
if (decodedPath.isEmpty) return values;
|
||||
|
||||
final pathParts = decodedPath.split('/').where((e) => e.isNotEmpty).toList();
|
||||
final root = shareRootUri(shareId: id, password: password, trailingSlash: false);
|
||||
|
||||
// 完整路径候选:cloudreve://id@share/folder/file.ext
|
||||
add('$root/${_encodeCloudrevePath(decodedPath)}');
|
||||
|
||||
// 单文件分享或列表返回 owner 的 my 路径时,官方 share 根目录下通常就是文件本身,
|
||||
// 因此还要尝试 basename 候选:cloudreve://id@share/file.ext
|
||||
if (pathParts.isNotEmpty) {
|
||||
add('$root/${Uri.encodeComponent(pathParts.last)}');
|
||||
}
|
||||
|
||||
// 如果原 path 形如 Folder/Sub/File,分享根可能是 Folder,子项在 share 中应为 Sub/File。
|
||||
if (pathParts.length > 1) {
|
||||
final withoutFirst = pathParts.skip(1).join('/');
|
||||
add('$root/${_encodeCloudrevePath(withoutFirst)}');
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
static String _encodeCloudrevePath(String path) {
|
||||
return path
|
||||
.split('/')
|
||||
.where((segment) => segment.isNotEmpty)
|
||||
.map((segment) => Uri.encodeComponent(Uri.decodeComponent(segment)))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
static String _withSharePassword(String uri, {String? shareId, String? password}) {
|
||||
final pw = password?.trim();
|
||||
final id = shareId?.trim();
|
||||
if (pw == null || pw.isEmpty || id == null || id.isEmpty) return uri;
|
||||
if (!uri.startsWith('cloudreve://')) return uri;
|
||||
|
||||
final authorityStart = 'cloudreve://'.length;
|
||||
final slash = uri.indexOf('/', authorityStart);
|
||||
final authority = slash >= 0 ? uri.substring(authorityStart, slash) : uri.substring(authorityStart);
|
||||
final rest = slash >= 0 ? uri.substring(slash) : '';
|
||||
if (!authority.endsWith('@share')) return uri;
|
||||
if (authority.contains(':')) return uri;
|
||||
|
||||
final encodedId = Uri.encodeComponent(id);
|
||||
final encodedPw = Uri.encodeComponent(pw);
|
||||
if (authority != '$encodedId@share' && authority != '$id@share') return uri;
|
||||
return 'cloudreve://$encodedId:$encodedPw@share$rest';
|
||||
}
|
||||
|
||||
static String shareRootUri({
|
||||
required String shareId,
|
||||
String? password,
|
||||
bool trailingSlash = true,
|
||||
}) {
|
||||
final id = Uri.encodeComponent(shareId.trim());
|
||||
final pw = password?.trim();
|
||||
final userInfo = pw == null || pw.isEmpty
|
||||
? id
|
||||
: '$id:${Uri.encodeComponent(pw)}';
|
||||
return 'cloudreve://$userInfo@share${trailingSlash ? '/' : ''}';
|
||||
}
|
||||
|
||||
static bool isShareRootUri(String uri, {String? shareId}) {
|
||||
final text = uri.trim();
|
||||
const prefix = 'cloudreve://';
|
||||
if (!text.startsWith(prefix)) return false;
|
||||
|
||||
final authorityStart = prefix.length;
|
||||
final slash = text.indexOf('/', authorityStart);
|
||||
final authority = slash >= 0
|
||||
? text.substring(authorityStart, slash)
|
||||
: text.substring(authorityStart);
|
||||
if (!authority.endsWith('@share')) return false;
|
||||
|
||||
final path = slash >= 0 ? text.substring(slash) : '';
|
||||
if (path.isNotEmpty && path != '/') return false;
|
||||
|
||||
final expectedId = shareId?.trim();
|
||||
if (expectedId == null || expectedId.isEmpty) return true;
|
||||
|
||||
final userInfo = authority.substring(0, authority.length - '@share'.length);
|
||||
final rawId = userInfo.split(':').first;
|
||||
return Uri.decodeComponent(rawId) == expectedId || rawId == expectedId;
|
||||
}
|
||||
|
||||
|
||||
static String? _extractDownloadUrl(Map<String, dynamic> data) {
|
||||
final direct = data['url'] ??
|
||||
data['download_url'] ??
|
||||
data['downloadUrl'] ??
|
||||
data['src'] ??
|
||||
data['href'];
|
||||
if (direct is String && direct.trim().isNotEmpty) return direct.trim();
|
||||
|
||||
final rawUrls = data['urls'];
|
||||
if (rawUrls is String && rawUrls.trim().isNotEmpty) return rawUrls.trim();
|
||||
if (rawUrls is List) {
|
||||
for (final item in rawUrls) {
|
||||
if (item is String && item.trim().isNotEmpty) return item.trim();
|
||||
final map = _asMap(item);
|
||||
if (map == null) continue;
|
||||
final url = _extractDownloadUrl(map);
|
||||
if (url != null && url.isNotEmpty) return url;
|
||||
}
|
||||
}
|
||||
if (rawUrls is Map) {
|
||||
for (final item in rawUrls.values) {
|
||||
if (item is String && item.trim().isNotEmpty) return item.trim();
|
||||
final map = _asMap(item);
|
||||
if (map == null) continue;
|
||||
final url = _extractDownloadUrl(map);
|
||||
if (url != null && url.isNotEmpty) return url;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static List<List<String>> _cartesianUriCandidates(List<List<String>> sets) {
|
||||
if (sets.isEmpty) return const [[]];
|
||||
|
||||
var result = <List<String>>[const []];
|
||||
for (final set in sets) {
|
||||
final next = <List<String>>[];
|
||||
for (final prefix in result) {
|
||||
for (final item in set) {
|
||||
next.add(<String>[...prefix, item]);
|
||||
}
|
||||
}
|
||||
result = next;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
static String _firstContextHint(String? contextHint) {
|
||||
final value = contextHint?.trim();
|
||||
return value == null || value.isEmpty ? 'share' : value;
|
||||
}
|
||||
|
||||
static List<String> _contextHintCandidates(String? contextHint, {String? shareId}) {
|
||||
final values = <String>[];
|
||||
void add(String? value) {
|
||||
final text = value?.trim();
|
||||
if (text != null && text.isNotEmpty && !values.contains(text)) {
|
||||
values.add(text);
|
||||
}
|
||||
}
|
||||
|
||||
add(contextHint);
|
||||
add(shareId);
|
||||
if (shareId != null && shareId.trim().isNotEmpty) {
|
||||
add('share:${shareId.trim()}');
|
||||
add('share_${shareId.trim()}');
|
||||
}
|
||||
add('share');
|
||||
return values;
|
||||
}
|
||||
|
||||
static String? _headerValue(Headers headers, String name) {
|
||||
final direct = headers.value(name) ?? headers.value(name.toLowerCase());
|
||||
if (direct != null && direct.isNotEmpty) return direct;
|
||||
|
||||
final lower = name.toLowerCase();
|
||||
for (final entry in headers.map.entries) {
|
||||
if (entry.key.toLowerCase() == lower && entry.value.isNotEmpty) {
|
||||
return entry.value.first;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+139
-22
@@ -1,7 +1,54 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import 'api_service.dart';
|
||||
import '../core/exceptions/app_exception.dart';
|
||||
import '../data/models/share_model.dart';
|
||||
import '../core/utils/app_logger.dart';
|
||||
|
||||
/// 分享授权对象类型。
|
||||
enum SharePrincipalType { user, group }
|
||||
|
||||
/// 分享弹窗中搜索并加入的用户/用户组。
|
||||
class SharePrincipal {
|
||||
final String id;
|
||||
final String name;
|
||||
final String? email;
|
||||
final String? groupName;
|
||||
final SharePrincipalType type;
|
||||
|
||||
const SharePrincipal({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.type,
|
||||
this.email,
|
||||
this.groupName,
|
||||
});
|
||||
|
||||
factory SharePrincipal.userFromJson(Map<String, dynamic> json) {
|
||||
final group = _asMap(json['group']);
|
||||
return SharePrincipal(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: (json['nickname'] ?? json['email'] ?? json['id'] ?? '用户').toString(),
|
||||
email: json['email']?.toString(),
|
||||
groupName: group?['name']?.toString(),
|
||||
type: SharePrincipalType.user,
|
||||
);
|
||||
}
|
||||
|
||||
factory SharePrincipal.groupFromJson(Map<String, dynamic> json) {
|
||||
return SharePrincipal(
|
||||
id: json['id']?.toString() ?? '',
|
||||
name: (json['name'] ?? json['id'] ?? '用户组').toString(),
|
||||
type: SharePrincipalType.group,
|
||||
);
|
||||
}
|
||||
|
||||
static Map<String, dynamic>? _asMap(dynamic value) {
|
||||
if (value is Map<String, dynamic>) return value;
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 分享服务
|
||||
class ShareService {
|
||||
/// 将文件系统路径转换为 cloudreve URI 格式
|
||||
@@ -17,33 +64,98 @@ class ShareService {
|
||||
return 'cloudreve://my/$cleanPath';
|
||||
}
|
||||
|
||||
/// 创建分享链接
|
||||
/// 创建分享链接。
|
||||
///
|
||||
/// Cloudreve V4 接口为 PUT /share,核心字段包括 permissions、uri、
|
||||
/// is_private、share_view、expire、price、password、show_readme。
|
||||
Future<String> createShare({
|
||||
required String uri,
|
||||
Map<String, dynamic>? permissions,
|
||||
bool? isPrivate,
|
||||
bool? shareView,
|
||||
int? expire,
|
||||
int? downloads,
|
||||
int? price,
|
||||
String? password,
|
||||
bool? showReadme,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'permissions': {'anonymous': 'BQ==', 'everyone': 'AQ=='},
|
||||
'permissions': permissions ?? {'anonymous': 'BQ==', 'everyone': 'AQ=='},
|
||||
'uri': _toCloudreveUri(uri),
|
||||
'is_private': ?isPrivate,
|
||||
'share_view': ?shareView,
|
||||
'expire': ?expire,
|
||||
'price': ?price,
|
||||
'password': ?password,
|
||||
'show_readme': ?showReadme,
|
||||
};
|
||||
// 当请求的接口为创建分享时, 逻辑上不适合走到 _parseResponse -> ApiResponse.fromJson 直接返回结果即可
|
||||
|
||||
if (isPrivate != null) data['is_private'] = isPrivate;
|
||||
if (shareView != null) data['share_view'] = shareView;
|
||||
if (expire != null) data['expire'] = expire;
|
||||
if (downloads != null) data['downloads'] = downloads;
|
||||
if (price != null) data['price'] = price;
|
||||
if (password != null && password.isNotEmpty) data['password'] = password;
|
||||
if (showReadme != null) data['show_readme'] = showReadme;
|
||||
|
||||
final response = await ApiService.instance.put<Map<String, dynamic>>(
|
||||
'/share',
|
||||
data: data,
|
||||
isNoData: true,
|
||||
);
|
||||
return response['data'] as String;
|
||||
final raw = response['data'];
|
||||
return raw?.toString() ?? '';
|
||||
}
|
||||
|
||||
/// 搜索用户,用于分享权限显式授权。
|
||||
Future<List<SharePrincipal>> searchUsers(String keyword) async {
|
||||
final trimmed = keyword.trim();
|
||||
if (trimmed.isEmpty) return const [];
|
||||
|
||||
final response = await ApiService.instance.get<dynamic>(
|
||||
'/user/search',
|
||||
queryParameters: {'keyword': trimmed},
|
||||
);
|
||||
|
||||
return _extractList(response)
|
||||
.whereType<Map>()
|
||||
.map((e) => SharePrincipal.userFromJson(Map<String, dynamic>.from(e)))
|
||||
.where((e) => e.id.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// 列出用户组,用于分享权限显式授权。
|
||||
///
|
||||
/// Cloudreve Pro 才支持 /group/list,非 Pro 或无权限时返回 null。
|
||||
/// 调用方据此决定是否提示用户。
|
||||
Future<List<SharePrincipal>?> listGroups() async {
|
||||
try {
|
||||
final response = await ApiService.instance.get<dynamic>(
|
||||
'/group/list',
|
||||
silent404: true,
|
||||
);
|
||||
|
||||
return _extractList(response)
|
||||
.whereType<Map>()
|
||||
.map((e) => SharePrincipal.groupFromJson(Map<String, dynamic>.from(e)))
|
||||
.where((e) => e.id.isNotEmpty)
|
||||
.toList();
|
||||
} on DioException catch (e) {
|
||||
if (e.response?.statusCode == 404) return null;
|
||||
rethrow;
|
||||
} on AppException catch (e) {
|
||||
if (e.code == 404) return null;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
List<dynamic> _extractList(dynamic value) {
|
||||
if (value is List) return value;
|
||||
if (value is Map) {
|
||||
final data = value['data'];
|
||||
if (data is List) return data;
|
||||
final items = value['items'];
|
||||
if (items is List) return items;
|
||||
final groups = value['groups'];
|
||||
if (groups is List) return groups;
|
||||
final users = value['users'];
|
||||
if (users is List) return users;
|
||||
}
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// 获取我的分享列表
|
||||
@@ -55,11 +167,11 @@ class ShareService {
|
||||
}) async {
|
||||
final queryParams = <String, dynamic>{
|
||||
'page_size': pageSize,
|
||||
'order_by': ?orderBy,
|
||||
'order_direction': ?orderDirection,
|
||||
'next_page_token': ?nextPageToken,
|
||||
};
|
||||
// 请求方法为get, claude 写成post, fixed
|
||||
if (orderBy != null) queryParams['order_by'] = orderBy;
|
||||
if (orderDirection != null) queryParams['order_direction'] = orderDirection;
|
||||
if (nextPageToken != null) queryParams['next_page_token'] = nextPageToken;
|
||||
|
||||
return await ApiService.instance.get<Map<String, dynamic>>(
|
||||
'/share',
|
||||
queryParameters: queryParams,
|
||||
@@ -79,13 +191,10 @@ class ShareService {
|
||||
if (ownerExtended != null) {
|
||||
queryParams['owner_extended'] = ownerExtended.toString();
|
||||
}
|
||||
// 获取分享详情是 GET 请求
|
||||
final response = await ApiService.instance.get<Map<String, dynamic>>(
|
||||
'/share/info/$id',
|
||||
queryParameters: queryParams,
|
||||
);
|
||||
// 获取分享详情返回的 response 已经经过 _parseResponse -> ApiResponse.fromJson 处理, 不需要再通过 ['data'] 获取数据
|
||||
// return ShareModel.fromJson(response['data'] as Map<String, dynamic>);
|
||||
return ShareModel.fromJson(response);
|
||||
}
|
||||
|
||||
@@ -93,19 +202,26 @@ class ShareService {
|
||||
Future<String> editShare({
|
||||
required String id,
|
||||
required String uri,
|
||||
Map<String, dynamic>? permissions,
|
||||
bool? isPrivate,
|
||||
String? password,
|
||||
bool? shareView,
|
||||
int? downloads,
|
||||
int? expire,
|
||||
int? price,
|
||||
bool? showReadme,
|
||||
}) async {
|
||||
final data = <String, dynamic>{
|
||||
'uri': uri,
|
||||
'is_private': ?isPrivate,
|
||||
'share_view': ?shareView,
|
||||
'downloads': ?downloads,
|
||||
'expire': ?expire,
|
||||
};
|
||||
|
||||
if (permissions != null) data['permissions'] = permissions;
|
||||
if (isPrivate != null) data['is_private'] = isPrivate;
|
||||
if (shareView != null) data['share_view'] = shareView;
|
||||
if (downloads != null) data['downloads'] = downloads;
|
||||
if (expire != null) data['expire'] = expire;
|
||||
if (price != null) data['price'] = price;
|
||||
if (showReadme != null) data['show_readme'] = showReadme;
|
||||
if (password != null && password.isNotEmpty) {
|
||||
data['password'] = password;
|
||||
}
|
||||
@@ -116,7 +232,8 @@ class ShareService {
|
||||
data: data,
|
||||
isNoData: true,
|
||||
);
|
||||
return response['data'] as String;
|
||||
final raw = response['data'];
|
||||
return raw?.toString() ?? '';
|
||||
}
|
||||
|
||||
/// 删除分享
|
||||
|
||||
Reference in New Issue
Block a user