merge new features
This commit is contained in:
@@ -1,7 +1,59 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cloudreve4_flutter/core/utils/app_logger.dart';
|
||||
import 'package:cloudreve4_flutter/core/utils/win_env.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart' as mobile;
|
||||
|
||||
// ═════════════════════════════════════════════════════
|
||||
// WebView 代理配置(仅 Windows,无认证)
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
class CaptchaProxyConfig {
|
||||
final String host;
|
||||
final int port;
|
||||
|
||||
const CaptchaProxyConfig({required this.host, required this.port});
|
||||
|
||||
String get proxyArg => '--proxy-server=http://$host:$port';
|
||||
|
||||
@override
|
||||
String toString() => '$host:$port';
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════
|
||||
// WebView2 代理环境变量管理
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
const _envVarName = 'WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS';
|
||||
|
||||
/// 为 WebView2 设置代理环境变量(进程级,不影响其他程序)
|
||||
void _applyWebView2Proxy(CaptchaProxyConfig? proxy) {
|
||||
if (!Platform.isWindows) return;
|
||||
|
||||
if (proxy != null) {
|
||||
final existing = Platform.environment[_envVarName];
|
||||
final newValue = existing != null && existing.isNotEmpty
|
||||
? '$existing ${proxy.proxyArg}'
|
||||
: proxy.proxyArg;
|
||||
winSetEnvVar(_envVarName, newValue);
|
||||
AppLogger.i('WebView2 代理环境变量已设置: $newValue');
|
||||
}
|
||||
}
|
||||
|
||||
/// 清除 WebView2 代理环境变量
|
||||
void _clearWebView2Proxy() {
|
||||
if (!Platform.isWindows) return;
|
||||
winSetEnvVar(_envVarName, null);
|
||||
AppLogger.i('WebView2 代理环境变量已清除');
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════
|
||||
// CaptchaWebConfig
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
class CaptchaWebConfig {
|
||||
final String type;
|
||||
@@ -21,12 +73,20 @@ class CaptchaWebConfig {
|
||||
const CaptchaWebConfig.recaptchaV2({
|
||||
required String siteKey,
|
||||
String displayName = 'reCAPTCHA V2',
|
||||
}) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey);
|
||||
}) : this._(
|
||||
type: 'recaptcha',
|
||||
displayName: displayName,
|
||||
siteKey: siteKey,
|
||||
);
|
||||
|
||||
const CaptchaWebConfig.turnstile({
|
||||
required String siteKey,
|
||||
String displayName = 'Cloudflare Turnstile',
|
||||
}) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey);
|
||||
}) : this._(
|
||||
type: 'turnstile',
|
||||
displayName: displayName,
|
||||
siteKey: siteKey,
|
||||
);
|
||||
|
||||
const CaptchaWebConfig.cap({
|
||||
required String instanceUrl,
|
||||
@@ -34,22 +94,36 @@ class CaptchaWebConfig {
|
||||
String? assetServer,
|
||||
String displayName = 'Cap',
|
||||
}) : this._(
|
||||
type: 'cap',
|
||||
displayName: displayName,
|
||||
instanceUrl: instanceUrl,
|
||||
siteKey: siteKey,
|
||||
assetServer: assetServer,
|
||||
);
|
||||
type: 'cap',
|
||||
displayName: displayName,
|
||||
instanceUrl: instanceUrl,
|
||||
siteKey: siteKey,
|
||||
assetServer: assetServer,
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 桌面端判断 ───────────────────────────────────────
|
||||
bool get _isDesktop => !kIsWeb && (Platform.isWindows || Platform.isLinux);
|
||||
|
||||
// ─── 桌面 User-Agent ──────────────────────────────────
|
||||
const _desktopUserAgent =
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 '
|
||||
'(KHTML, like Gecko) Chrome/148.0.0.0 Safari/537.36';
|
||||
|
||||
// ═════════════════════════════════════════════════════
|
||||
// CaptchaChallengePage
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
class CaptchaChallengePage extends StatefulWidget {
|
||||
final CaptchaWebConfig config;
|
||||
final String baseUrl;
|
||||
final CaptchaProxyConfig? proxyConfig;
|
||||
|
||||
const CaptchaChallengePage({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.baseUrl,
|
||||
this.proxyConfig,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -57,61 +131,98 @@ class CaptchaChallengePage extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
late final WebViewController _controller;
|
||||
// ── 移动端 ──
|
||||
mobile.WebViewController? _mobileController;
|
||||
|
||||
// ── 桌面端 ──
|
||||
InAppWebViewController? _desktopController;
|
||||
Key _desktopKey = UniqueKey();
|
||||
|
||||
// ── 共享状态 ──
|
||||
bool _isLoading = true;
|
||||
int _progress = 0;
|
||||
String? _errorMessage;
|
||||
String? _statusText;
|
||||
bool _disposed = false;
|
||||
|
||||
// ── 是否设置了代理环境变量(用于清理时判断)──
|
||||
bool _proxyEnvSet = false;
|
||||
|
||||
// ── HTML ──
|
||||
late String _currentHtml;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentHtml = _buildHtml(widget.config);
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.transparent)
|
||||
..addJavaScriptChannel(
|
||||
'CaptchaBridge',
|
||||
onMessageReceived: (message) {
|
||||
_handleBridgeMessage(message.message);
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (progress) {
|
||||
if (mounted) setState(() => _progress = progress);
|
||||
},
|
||||
onPageFinished: (_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = '${error.errorCode}: ${error.description}'
|
||||
.trim();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
// Windows: 在 WebView2 创建前设置代理环境变量
|
||||
if (_isDesktop && widget.proxyConfig != null && Platform.isWindows) {
|
||||
_applyWebView2Proxy(widget.proxyConfig);
|
||||
_proxyEnvSet = true;
|
||||
}
|
||||
|
||||
_loadCaptcha();
|
||||
if (!_isDesktop) {
|
||||
_mobileController = mobile.WebViewController()
|
||||
..setJavaScriptMode(mobile.JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.transparent)
|
||||
..addJavaScriptChannel(
|
||||
'CaptchaBridge',
|
||||
onMessageReceived: (message) {
|
||||
_handleBridgeMessage(message.message);
|
||||
},
|
||||
)
|
||||
..setNavigationDelegate(
|
||||
mobile.NavigationDelegate(
|
||||
onProgress: (progress) {
|
||||
if (mounted) setState(() => _progress = progress);
|
||||
},
|
||||
onPageFinished: (_) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
},
|
||||
onWebResourceError: (error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage =
|
||||
'${error.errorCode}: ${error.description}'.trim();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
..loadHtmlString(_currentHtml, baseUrl: widget.baseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 加载 / 刷新 ────────────────────────────────────
|
||||
|
||||
Future<void> _loadCaptcha() async {
|
||||
if (_disposed) return;
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_statusText = null;
|
||||
_progress = 0;
|
||||
_currentHtml = _buildHtml(widget.config);
|
||||
});
|
||||
|
||||
final html = _buildHtml(widget.config);
|
||||
await _controller.loadHtmlString(html, baseUrl: widget.baseUrl);
|
||||
if (!_isDesktop && _mobileController != null) {
|
||||
await _mobileController!.loadHtmlString(
|
||||
_currentHtml,
|
||||
baseUrl: widget.baseUrl,
|
||||
);
|
||||
} else if (_isDesktop) {
|
||||
setState(() => _desktopKey = UniqueKey());
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Bridge 消息处理 ─────────────────────────────────
|
||||
|
||||
void _handleBridgeMessage(String rawMessage) {
|
||||
AppLogger.d(
|
||||
'Bridge 收到消息: ${rawMessage.length > 100 ? "${rawMessage.substring(0, 100)}..." : rawMessage}',
|
||||
);
|
||||
try {
|
||||
final decoded = jsonDecode(rawMessage);
|
||||
if (decoded is! Map) return;
|
||||
@@ -120,8 +231,18 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
|
||||
if (type == 'success') {
|
||||
final token = decoded['token']?.toString() ?? '';
|
||||
if (token.isNotEmpty && mounted) {
|
||||
final jsTs = decoded['_jsTs'];
|
||||
if (jsTs is num) {
|
||||
final delayMs = DateTime.now().millisecondsSinceEpoch - jsTs.toInt();
|
||||
AppLogger.d('Bridge 收到 success, JS→Dart 传输延迟=${delayMs}ms, token长度=${token.length}');
|
||||
} else {
|
||||
AppLogger.d('Bridge 收到 success, token长度=${token.length}');
|
||||
}
|
||||
if (token.isNotEmpty && mounted && !_disposed) {
|
||||
_disposed = true;
|
||||
AppLogger.d('准备 pop 返回登录页');
|
||||
Navigator.of(context).pop(token);
|
||||
AppLogger.d('pop 完成');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -130,7 +251,8 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
final progress = decoded['progress']?.toString();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusText = progress == null ? '正在验证...' : '正在验证... $progress';
|
||||
_statusText =
|
||||
progress == null ? '正在验证...' : '正在验证... $progress';
|
||||
});
|
||||
}
|
||||
return;
|
||||
@@ -145,6 +267,11 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'debug') {
|
||||
AppLogger.d('JS debug: ${decoded['message']}');
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'expired') {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -153,25 +280,131 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略非 JSON 消息。
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
// ─── WebView 销毁 ───────────────────────────────────
|
||||
|
||||
void _cleanupWebView() {
|
||||
if (_isDesktop) {
|
||||
final ctrl = _desktopController;
|
||||
_desktopController = null;
|
||||
AppLogger.d('开始清理 WebView controller');
|
||||
ctrl?.dispose();
|
||||
AppLogger.d('WebView controller 已 dispose');
|
||||
if (_proxyEnvSet) {
|
||||
_clearWebView2Proxy();
|
||||
_proxyEnvSet = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
AppLogger.d('CaptchaChallengePage dispose 开始');
|
||||
_disposed = true;
|
||||
_cleanupWebView();
|
||||
super.dispose();
|
||||
AppLogger.d('CaptchaChallengePage dispose 完成');
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════
|
||||
// HTML 生成
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
String _buildHtml(CaptchaWebConfig config) {
|
||||
switch (config.type) {
|
||||
case 'turnstile':
|
||||
return _buildTurnstileHtml(config.siteKey!);
|
||||
return _baseHtml(
|
||||
title: 'Cloudflare Turnstile',
|
||||
body: '<div id="widget"></div>',
|
||||
script: '''
|
||||
function onTurnstileLoad() {
|
||||
try {
|
||||
turnstile.render('#widget', {
|
||||
sitekey: '${_js(config.siteKey!)}',
|
||||
callback: function(token) { solved(token); },
|
||||
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
|
||||
'expired-callback': function() { expired(); },
|
||||
'after-interactive-callback': function() {
|
||||
sendBridge({ type: 'debug', message: 'after-interactive fired' });
|
||||
markStatus('正在与 Cloudflare 服务器验证,请稍候...', false);
|
||||
sendBridge({ type: 'progress', progress: '服务器验证中' });
|
||||
}
|
||||
});
|
||||
markStatus('请完成人机验证', false);
|
||||
} catch (e) {
|
||||
failed(e && e.message ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
|
||||
<script>
|
||||
''',
|
||||
);
|
||||
case 'recaptcha':
|
||||
return _buildRecaptchaHtml(config.siteKey!);
|
||||
return _baseHtml(
|
||||
title: 'reCAPTCHA V2',
|
||||
body: '<div id="widget"></div>',
|
||||
script: '''
|
||||
function onRecaptchaLoad() {
|
||||
try {
|
||||
grecaptcha.render('widget', {
|
||||
sitekey: '${_js(config.siteKey!)}',
|
||||
callback: function(token) { solved(token); },
|
||||
'expired-callback': function() { expired(); },
|
||||
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
|
||||
});
|
||||
markStatus('请完成人机验证', false);
|
||||
} catch (e) {
|
||||
failed(e && e.message ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
|
||||
<script>
|
||||
''',
|
||||
);
|
||||
case 'cap':
|
||||
return _buildCapHtml(
|
||||
instanceUrl: config.instanceUrl!,
|
||||
siteKey: config.siteKey!,
|
||||
assetServer: config.assetServer,
|
||||
final endpoint = _capEndpoint(config.instanceUrl!, config.siteKey!);
|
||||
final scriptUrl = _capWidgetScript(config.assetServer);
|
||||
final safeEndpoint = const HtmlEscape().convert(endpoint);
|
||||
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
|
||||
|
||||
return _baseHtml(
|
||||
title: 'Cap',
|
||||
body:
|
||||
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
|
||||
script: '''
|
||||
window.CAP_DISABLE_HAPTICS = true;
|
||||
const cap = document.getElementById('cap');
|
||||
if (cap) {
|
||||
cap.addEventListener('solve', function(e) {
|
||||
solved(e.detail && e.detail.token ? e.detail.token : '');
|
||||
});
|
||||
cap.addEventListener('progress', function(e) {
|
||||
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
|
||||
markStatus('正在验证... ' + progress, false);
|
||||
sendBridge({ type: 'progress', progress: progress });
|
||||
});
|
||||
cap.addEventListener('error', function(e) {
|
||||
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
|
||||
failed(message);
|
||||
});
|
||||
markStatus('请完成人机验证', false);
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="$safeScriptUrl"></script>
|
||||
<script>
|
||||
''',
|
||||
);
|
||||
default:
|
||||
return _buildErrorHtml('不支持的验证码类型: ${config.type}');
|
||||
return _baseHtml(
|
||||
title: '验证码错误',
|
||||
body:
|
||||
'<div id="widget" class="error">${const HtmlEscape().convert('不支持的验证码类型: ${config.type}')}</div>',
|
||||
script: "failed('不支持的验证码类型');",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -260,8 +493,14 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
</div>
|
||||
<script>
|
||||
function sendBridge(payload) {
|
||||
payload._jsTs = Date.now();
|
||||
var json = JSON.stringify(payload);
|
||||
try {
|
||||
CaptchaBridge.postMessage(JSON.stringify(payload));
|
||||
if (typeof CaptchaBridge !== 'undefined' && CaptchaBridge.postMessage) {
|
||||
CaptchaBridge.postMessage(json);
|
||||
} else if (window.flutter_inappwebview && window.flutter_inappwebview.callHandler) {
|
||||
window.flutter_inappwebview.callHandler('CaptchaBridge', json);
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
function markStatus(text, isError) {
|
||||
@@ -289,139 +528,9 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
''';
|
||||
}
|
||||
|
||||
String _buildTurnstileHtml(String siteKey) {
|
||||
return _baseHtml(
|
||||
title: 'Cloudflare Turnstile',
|
||||
body: '<div id="widget"></div>',
|
||||
script:
|
||||
'''
|
||||
function onTurnstileLoad() {
|
||||
try {
|
||||
turnstile.render('#widget', {
|
||||
sitekey: '${_js(siteKey)}',
|
||||
callback: function(token) { solved(token); },
|
||||
'error-callback': function() { failed('Turnstile 验证失败,请重试'); },
|
||||
'expired-callback': function() { expired(); }
|
||||
});
|
||||
markStatus('请完成人机验证', false);
|
||||
} catch (e) {
|
||||
failed(e && e.message ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?onload=onTurnstileLoad&render=explicit" async defer></script>
|
||||
<script>
|
||||
''',
|
||||
);
|
||||
}
|
||||
|
||||
String _buildRecaptchaHtml(String siteKey) {
|
||||
return _baseHtml(
|
||||
title: 'reCAPTCHA V2',
|
||||
body: '<div id="widget"></div>',
|
||||
script:
|
||||
'''
|
||||
function onRecaptchaLoad() {
|
||||
try {
|
||||
grecaptcha.render('widget', {
|
||||
sitekey: '${_js(siteKey)}',
|
||||
callback: function(token) { solved(token); },
|
||||
'expired-callback': function() { expired(); },
|
||||
'error-callback': function() { failed('reCAPTCHA 加载或验证失败,请重试'); }
|
||||
});
|
||||
markStatus('请完成人机验证', false);
|
||||
} catch (e) {
|
||||
failed(e && e.message ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script src="https://www.google.com/recaptcha/api.js?onload=onRecaptchaLoad&render=explicit" async defer></script>
|
||||
<script>
|
||||
''',
|
||||
);
|
||||
}
|
||||
|
||||
String _buildCapHtml({
|
||||
required String instanceUrl,
|
||||
required String siteKey,
|
||||
String? assetServer,
|
||||
}) {
|
||||
final endpoint = _capEndpoint(instanceUrl, siteKey);
|
||||
final scriptUrl = _capWidgetScript(assetServer);
|
||||
final safeEndpoint = const HtmlEscape().convert(endpoint);
|
||||
final safeScriptUrl = const HtmlEscape().convert(scriptUrl);
|
||||
|
||||
return _baseHtml(
|
||||
title: 'Cap',
|
||||
body:
|
||||
'<div id="widget"><cap-widget id="cap" required data-cap-api-endpoint="$safeEndpoint" data-cap-disable-haptics></cap-widget></div>',
|
||||
script:
|
||||
'''
|
||||
window.CAP_DISABLE_HAPTICS = true;
|
||||
const cap = document.getElementById('cap');
|
||||
if (cap) {
|
||||
cap.addEventListener('solve', function(e) {
|
||||
solved(e.detail && e.detail.token ? e.detail.token : '');
|
||||
});
|
||||
cap.addEventListener('progress', function(e) {
|
||||
const progress = e.detail && e.detail.progress != null ? e.detail.progress : '';
|
||||
markStatus('正在验证... ' + progress, false);
|
||||
sendBridge({ type: 'progress', progress: progress });
|
||||
});
|
||||
cap.addEventListener('error', function(e) {
|
||||
const message = e.detail && e.detail.message ? e.detail.message : 'Cap 验证失败';
|
||||
failed(message);
|
||||
});
|
||||
markStatus('请完成人机验证', false);
|
||||
}
|
||||
</script>
|
||||
<script type="module" src="$safeScriptUrl"></script>
|
||||
<script>
|
||||
''',
|
||||
);
|
||||
}
|
||||
|
||||
String _buildErrorHtml(String message) {
|
||||
return _baseHtml(
|
||||
title: '验证码错误',
|
||||
body:
|
||||
'<div id="widget" class="error">${const HtmlEscape().convert(message)}</div>',
|
||||
script: 'failed(${jsonEncode(message)});',
|
||||
);
|
||||
}
|
||||
|
||||
String _capEndpoint(String instanceUrl, String siteKey) {
|
||||
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return '$trimmedInstance/$trimmedSiteKey/';
|
||||
}
|
||||
|
||||
String _capWidgetScript(String? assetServer) {
|
||||
final asset = assetServer?.trim();
|
||||
if (asset != null && asset.isNotEmpty) {
|
||||
if (asset.startsWith('http://') || asset.startsWith('https://')) {
|
||||
return asset;
|
||||
}
|
||||
|
||||
if (asset.toLowerCase() == 'jsdelivr') {
|
||||
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||
}
|
||||
|
||||
if (asset.toLowerCase() == 'unpkg') {
|
||||
return 'https://unpkg.com/cap-widget';
|
||||
}
|
||||
}
|
||||
|
||||
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||
}
|
||||
|
||||
String _js(String input) {
|
||||
return input
|
||||
.replaceAll(r'\\', r'\\\\')
|
||||
.replaceAll("'", r"\\'")
|
||||
.replaceAll('\\n', r'\\n')
|
||||
.replaceAll('\\r', r'\\r');
|
||||
}
|
||||
// ═════════════════════════════════════════════════════
|
||||
// Widget 构建
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -446,51 +555,173 @@ class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
if (_isLoading) const Center(child: CircularProgressIndicator()),
|
||||
_isDesktop ? _buildDesktopWebView() : _buildMobileWebView(),
|
||||
if (_isLoading)
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
if (_errorMessage != null)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
_buildOverlayBanner(
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||
),
|
||||
),
|
||||
color: Theme.of(context).colorScheme.errorContainer,
|
||||
)
|
||||
else if (_statusText != null)
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest
|
||||
.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(_statusText!, textAlign: TextAlign.center),
|
||||
),
|
||||
_buildOverlayBanner(
|
||||
child: Text(
|
||||
_statusText!,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ── 移动端 WebView ──────────────────────────────────
|
||||
|
||||
Widget _buildMobileWebView() {
|
||||
return mobile.WebViewWidget(controller: _mobileController!);
|
||||
}
|
||||
|
||||
// ── 桌面端 WebView ──────────────────────────────────
|
||||
|
||||
Widget _buildDesktopWebView() {
|
||||
AppLogger.i('WebView 验证码 BaseUrl: ${WebUri(widget.baseUrl)}');
|
||||
|
||||
return InAppWebView(
|
||||
key: _desktopKey,
|
||||
initialSettings: InAppWebViewSettings(
|
||||
javaScriptEnabled: true,
|
||||
domStorageEnabled: true,
|
||||
safeBrowsingEnabled: true,
|
||||
isInspectable: true,
|
||||
cacheMode: CacheMode.LOAD_NO_CACHE,
|
||||
supportMultipleWindows: true,
|
||||
allowUniversalAccessFromFileURLs: true,
|
||||
allowFileAccessFromFileURLs: true,
|
||||
userAgent: _desktopUserAgent,
|
||||
transparentBackground: true,
|
||||
supportZoom: false,
|
||||
useHybridComposition: true,
|
||||
),
|
||||
onWebViewCreated: (controller) {
|
||||
_desktopController = controller;
|
||||
controller.addJavaScriptHandler(
|
||||
handlerName: 'CaptchaBridge',
|
||||
callback: (args) {
|
||||
if (args.isNotEmpty) {
|
||||
_handleBridgeMessage(args[0].toString());
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
controller.loadUrl(
|
||||
urlRequest: URLRequest(
|
||||
url: WebUri("${widget.baseUrl}/virtual_captcha.html"),
|
||||
),
|
||||
);
|
||||
},
|
||||
shouldInterceptRequest: (controller, request) async {
|
||||
if (request.url.toString().contains('virtual_captcha.html')) {
|
||||
AppLogger.i('黑魔法 -> 拦截成功,正在注入动态 HTML');
|
||||
return WebResourceResponse(
|
||||
contentType: 'text/html',
|
||||
contentEncoding: 'utf-8',
|
||||
data: Uint8List.fromList(utf8.encode(_currentHtml)),
|
||||
statusCode: 200,
|
||||
reasonPhrase: 'OK',
|
||||
);
|
||||
}
|
||||
|
||||
if (request.url.toString().contains('/h/b/rc') && request.method == 'POST') {
|
||||
AppLogger.w('发现黑魔法后遗症校验请求 (POST),执行强制 404');
|
||||
await Future.delayed(Duration(seconds: 2));
|
||||
return WebResourceResponse(
|
||||
contentType: 'text/plain',
|
||||
statusCode: 404,
|
||||
reasonPhrase: 'Not Found',
|
||||
data: Uint8List(0),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onLoadStart: (controller, url) {},
|
||||
onLoadStop: (controller, url) {
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
},
|
||||
onProgressChanged: (controller, progress) {
|
||||
if (mounted) setState(() => _progress = progress);
|
||||
},
|
||||
onReceivedError: (controller, request, error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
_errorMessage = '${error.type}: ${error.description}'.trim();
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ── 通用底部提示条 ──────────────────────────────────
|
||||
|
||||
Widget _buildOverlayBanner({required Widget child, Color? color}) {
|
||||
return Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: SafeArea(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.all(12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color ??
|
||||
Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.92),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════════════════════════════════════
|
||||
// 辅助
|
||||
// ═════════════════════════════════════════════════════
|
||||
|
||||
String _capEndpoint(String instanceUrl, String siteKey) {
|
||||
final trimmedInstance = instanceUrl.trim().replaceAll(RegExp(r'/+$'), '');
|
||||
final trimmedSiteKey = siteKey.trim().replaceAll(RegExp(r'^/+|/+$'), '');
|
||||
return '$trimmedInstance/$trimmedSiteKey/';
|
||||
}
|
||||
|
||||
String _capWidgetScript(String? assetServer) {
|
||||
final asset = assetServer?.trim();
|
||||
if (asset != null && asset.isNotEmpty) {
|
||||
if (asset.startsWith('http://') || asset.startsWith('https://')) {
|
||||
return asset;
|
||||
}
|
||||
if (asset.toLowerCase() == 'jsdelivr') {
|
||||
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||
}
|
||||
if (asset.toLowerCase() == 'unpkg') {
|
||||
return 'https://unpkg.com/cap-widget';
|
||||
}
|
||||
}
|
||||
return 'https://cdn.jsdelivr.net/npm/cap-widget';
|
||||
}
|
||||
|
||||
String _js(String input) {
|
||||
return input
|
||||
.replaceAll(r'\', r'\\')
|
||||
.replaceAll("'", r"\'")
|
||||
.replaceAll('\n', r'\n')
|
||||
.replaceAll('\r', r'\r');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../../core/utils/date_utils.dart' as date_utils;
|
||||
import '../../../core/utils/file_type_utils.dart';
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../router/app_router.dart';
|
||||
import '../../../services/file_service.dart';
|
||||
import '../../providers/file_manager_provider.dart';
|
||||
import '../../widgets/file_info_dialog.dart';
|
||||
import '../../widgets/file_operation_dialogs.dart';
|
||||
import '../../widgets/selection_toolbar.dart';
|
||||
import '../../widgets/thumbnail_image.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
|
||||
@@ -46,23 +52,34 @@ class CategoryFilesPageArgs {
|
||||
class CategoryFilesPage extends StatefulWidget {
|
||||
final CategoryFilesPageArgs args;
|
||||
|
||||
const CategoryFilesPage({super.key, required this.args});
|
||||
const CategoryFilesPage({
|
||||
super.key,
|
||||
required this.args,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CategoryFilesPage> createState() => _CategoryFilesPageState();
|
||||
}
|
||||
|
||||
class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
class _CategoryFilesPageState extends State<CategoryFilesPage>
|
||||
with TickerProviderStateMixin {
|
||||
final _fileService = FileService();
|
||||
final _scrollController = ScrollController();
|
||||
|
||||
final List<FileModel> _files = [];
|
||||
final Set<String> _selectedFilePaths = <String>{};
|
||||
String? _nextPageToken;
|
||||
String? _contextHint;
|
||||
bool _isLoading = true;
|
||||
bool _isLoadingMore = false;
|
||||
String? _errorMessage;
|
||||
|
||||
bool get _hasSelection => _selectedFilePaths.isNotEmpty;
|
||||
|
||||
List<FileModel> get _selectedFiles => _files
|
||||
.where((file) => _selectedFilePaths.contains(file.path))
|
||||
.toList(growable: false);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -74,6 +91,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
void didUpdateWidget(covariant CategoryFilesPage oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.args.category != widget.args.category) {
|
||||
_clearSelection();
|
||||
_loadFiles(refresh: true);
|
||||
}
|
||||
}
|
||||
@@ -102,6 +120,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
_nextPageToken = null;
|
||||
_contextHint = null;
|
||||
_files.clear();
|
||||
_selectedFilePaths.clear();
|
||||
});
|
||||
} else {
|
||||
setState(() {
|
||||
@@ -118,8 +137,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
);
|
||||
|
||||
final filesData = response['files'] as List<dynamic>? ?? const [];
|
||||
final pagination =
|
||||
response['pagination'] as Map<String, dynamic>? ?? const {};
|
||||
final pagination = response['pagination'] as Map<String, dynamic>? ?? const {};
|
||||
final newFiles = filesData
|
||||
.map((item) => FileModel.fromJson(item as Map<String, dynamic>))
|
||||
.where((file) => file.isFile)
|
||||
@@ -134,9 +152,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
..addAll(newFiles);
|
||||
} else {
|
||||
final existingIds = _files.map((e) => e.id).toSet();
|
||||
_files.addAll(
|
||||
newFiles.where((file) => !existingIds.contains(file.id)),
|
||||
);
|
||||
_files.addAll(newFiles.where((file) => !existingIds.contains(file.id)));
|
||||
}
|
||||
|
||||
_nextPageToken = pagination['next_token'] as String?;
|
||||
@@ -156,22 +172,149 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
|
||||
Future<void> _refresh() => _loadFiles(refresh: true);
|
||||
|
||||
void _toggleSelection(FileModel file) {
|
||||
HapticFeedback.selectionClick();
|
||||
setState(() {
|
||||
if (_selectedFilePaths.contains(file.path)) {
|
||||
_selectedFilePaths.remove(file.path);
|
||||
} else {
|
||||
_selectedFilePaths.add(file.path);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
if (_selectedFilePaths.isEmpty) return;
|
||||
setState(_selectedFilePaths.clear);
|
||||
}
|
||||
|
||||
void _selectAllVisible() {
|
||||
setState(() {
|
||||
_selectedFilePaths
|
||||
..clear()
|
||||
..addAll(_files.map((file) => file.path));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PopScope(
|
||||
canPop: !_hasSelection,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop && _hasSelection) {
|
||||
_clearSelection();
|
||||
}
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: _buildAppBar(context),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: _buildBody(context),
|
||||
),
|
||||
bottomNavigationBar: _buildSelectionBottomBar(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSizeWidget _buildAppBar(BuildContext context) {
|
||||
final args = widget.args;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(args.title),
|
||||
if (_hasSelection) {
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(LucideIcons.x),
|
||||
tooltip: '取消选择',
|
||||
onPressed: _clearSelection,
|
||||
),
|
||||
centerTitle: true,
|
||||
title: Text('已选中 ${_selectedFilePaths.length} 个文件'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: '刷新',
|
||||
onPressed: _refresh,
|
||||
TextButton(
|
||||
onPressed: _selectAllVisible,
|
||||
child: const Text('全选'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
title: Text(args.title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(LucideIcons.refreshCw),
|
||||
tooltip: '刷新',
|
||||
onPressed: _refresh,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectionBottomBar(BuildContext context) {
|
||||
final selected = _selectedFiles;
|
||||
final singleSelected = selected.length == 1 ? selected.first : null;
|
||||
|
||||
return AnimatedSize(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 220),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
layoutBuilder: (currentChild, previousChildren) {
|
||||
return Stack(
|
||||
alignment: Alignment.bottomCenter,
|
||||
children: <Widget>[
|
||||
...previousChildren,
|
||||
?currentChild,
|
||||
],
|
||||
);
|
||||
},
|
||||
transitionBuilder: (child, animation) {
|
||||
final curved = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
final slide = Tween<Offset>(
|
||||
begin: const Offset(0, 0.10),
|
||||
end: Offset.zero,
|
||||
).animate(curved);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: curved,
|
||||
child: SlideTransition(position: slide, child: child),
|
||||
);
|
||||
},
|
||||
child: _hasSelection
|
||||
? SelectionToolbar(
|
||||
key: const ValueKey('category-selection-toolbar'),
|
||||
selectionCount: _selectedFilePaths.length,
|
||||
totalCount: _files.length,
|
||||
onCancel: _clearSelection,
|
||||
onSelectAll: _selectAllVisible,
|
||||
onMore: singleSelected == null
|
||||
? null
|
||||
: () => _showSelectionMore(context, singleSelected),
|
||||
onMove: () => FileOperationDialogs.showBatchMoveDialog(
|
||||
context,
|
||||
context.read<FileManagerProvider>(),
|
||||
_selectedFilePaths.toList(),
|
||||
false,
|
||||
),
|
||||
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
|
||||
context,
|
||||
context.read<FileManagerProvider>(),
|
||||
_selectedFilePaths.toList(),
|
||||
true,
|
||||
),
|
||||
onDelete: () => _deleteSelectedFiles(context, selected),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('category-selection-toolbar-empty'),
|
||||
),
|
||||
),
|
||||
body: RefreshIndicator(onRefresh: _refresh, child: _buildBody(context)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -216,7 +359,11 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: [
|
||||
SizedBox(height: MediaQuery.sizeOf(context).height * 0.25),
|
||||
Icon(widget.args.icon, size: 52, color: widget.args.color),
|
||||
Icon(
|
||||
widget.args.icon,
|
||||
size: 52,
|
||||
color: widget.args.color,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Text(
|
||||
@@ -236,7 +383,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
final horizontalPadding = width >= 720 ? 16.0 : 10.0;
|
||||
final columnWidth =
|
||||
(width - horizontalPadding * 2 - spacing * (columnCount - 1)) /
|
||||
columnCount;
|
||||
columnCount;
|
||||
|
||||
final columns = List.generate(columnCount, (_) => <FileModel>[]);
|
||||
final heights = List.generate(columnCount, (_) => 0.0);
|
||||
@@ -244,8 +391,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
for (final file in _files) {
|
||||
final targetIndex = _indexOfMin(heights);
|
||||
columns[targetIndex].add(file);
|
||||
heights[targetIndex] +=
|
||||
_estimatedTileHeight(file, columnWidth) + spacing;
|
||||
heights[targetIndex] += _estimatedTileHeight(file, columnWidth) + spacing;
|
||||
}
|
||||
|
||||
return ListView(
|
||||
@@ -255,7 +401,7 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
horizontalPadding,
|
||||
10,
|
||||
horizontalPadding,
|
||||
24,
|
||||
_hasSelection ? 92 : 24,
|
||||
),
|
||||
children: [
|
||||
_buildSummaryHeader(context),
|
||||
@@ -271,11 +417,22 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: spacing),
|
||||
child: _CategoryFileTile(
|
||||
key: ValueKey('category-file-${file.id.isNotEmpty ? file.id : file.path}'),
|
||||
file: file,
|
||||
contextHint: _contextHint,
|
||||
category: widget.args.category,
|
||||
accentColor: widget.args.color,
|
||||
onTap: () => _openFile(context, file),
|
||||
isSelected: _selectedFilePaths.contains(file.path),
|
||||
selectionMode: _hasSelection,
|
||||
onTap: () {
|
||||
if (_hasSelection) {
|
||||
_toggleSelection(file);
|
||||
} else {
|
||||
_openFile(context, file);
|
||||
}
|
||||
},
|
||||
onLongPress: () => _toggleSelection(file),
|
||||
onSelect: () => _toggleSelection(file),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -379,19 +536,147 @@ class _CategoryFilesPageState extends State<CategoryFilesPage> {
|
||||
} else if (FileTypeUtils.isAudio(file.name)) {
|
||||
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
|
||||
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteNames.markdownPreview, arguments: file);
|
||||
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
|
||||
} else if (FileTypeUtils.isTextCode(file.name)) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteNames.documentPreview, arguments: file);
|
||||
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
|
||||
} else {
|
||||
ToastHelper.info(
|
||||
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
|
||||
);
|
||||
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteSelectedFiles(
|
||||
BuildContext context,
|
||||
List<FileModel> selectedFiles,
|
||||
) async {
|
||||
if (selectedFiles.isEmpty) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('删除确认'),
|
||||
content: Text('确定删除这 ${selectedFiles.length} 个文件吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
child: const Text('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (confirmed != true) return;
|
||||
|
||||
try {
|
||||
await _fileService.deleteFiles(
|
||||
uris: selectedFiles.map((file) => file.path).toList(),
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
final selectedPaths = selectedFiles.map((file) => file.path).toSet();
|
||||
_files.removeWhere((file) => selectedPaths.contains(file.path));
|
||||
_selectedFilePaths.clear();
|
||||
});
|
||||
ToastHelper.success('删除成功');
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ToastHelper.failure('删除失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _renameFile(BuildContext context, FileModel file) async {
|
||||
final controller = TextEditingController(text: file.name);
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => AlertDialog(
|
||||
title: const Text('重命名'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '新名称',
|
||||
prefixIcon: Icon(LucideIcons.edit3, size: 20),
|
||||
),
|
||||
autofocus: true,
|
||||
onSubmitted: (_) => Navigator.of(dialogContext).pop(true),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(dialogContext).pop(true),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
final newName = controller.text.trim();
|
||||
if (confirmed != true || newName.isEmpty || newName == file.name) return;
|
||||
|
||||
try {
|
||||
final response = await _fileService.renameFile(
|
||||
uri: file.path,
|
||||
newName: newName,
|
||||
);
|
||||
if (!mounted) return;
|
||||
if (response.isEmpty) {
|
||||
await _refresh();
|
||||
} else {
|
||||
final updatedFile = FileModel.fromJson(response);
|
||||
setState(() {
|
||||
final index = _files.indexWhere((item) => item.path == file.path);
|
||||
if (index != -1) _files[index] = updatedFile;
|
||||
_selectedFilePaths
|
||||
..remove(file.path)
|
||||
..add(updatedFile.path);
|
||||
});
|
||||
}
|
||||
ToastHelper.success('重命名成功');
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ToastHelper.failure('重命名失败: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _showSelectionMore(BuildContext context, FileModel file) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text('重命名'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_renameFile(context, file);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('查看详情'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
FileInfoPanel.showAsBottomSheet(context, file);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CategoryFileTile extends StatelessWidget {
|
||||
@@ -399,14 +684,23 @@ class _CategoryFileTile extends StatelessWidget {
|
||||
final String? contextHint;
|
||||
final String category;
|
||||
final Color accentColor;
|
||||
final bool isSelected;
|
||||
final bool selectionMode;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
final VoidCallback onSelect;
|
||||
|
||||
const _CategoryFileTile({
|
||||
super.key,
|
||||
required this.file,
|
||||
required this.contextHint,
|
||||
required this.category,
|
||||
required this.accentColor,
|
||||
required this.isSelected,
|
||||
required this.selectionMode,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -419,73 +713,128 @@ class _CategoryFileTile extends StatelessWidget {
|
||||
final ext = FileTypeUtils.getExtension(file.name);
|
||||
final isPsd = ext == 'psd' || ext == 'psb';
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: theme.dividerColor.withValues(alpha: 0.12),
|
||||
final borderColor = isSelected
|
||||
? theme.colorScheme.primary
|
||||
: theme.dividerColor.withValues(alpha: 0.12);
|
||||
|
||||
final showSelectionCircle = selectionMode || isSelected;
|
||||
|
||||
return RepaintBoundary(
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOutCubic,
|
||||
scale: isSelected ? 0.985 : 1.0,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: isMedia || isPsd ? 1 : 1.45,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
child: ThumbnailImage(
|
||||
file: file,
|
||||
contextHint: contextHint,
|
||||
borderRadius: 0,
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 7,
|
||||
left: 7,
|
||||
child: _TypeBadge(
|
||||
icon: _badgeIcon(),
|
||||
label: _badgeLabel(ext),
|
||||
color: accentColor,
|
||||
compact: isMedia,
|
||||
),
|
||||
),
|
||||
if (category == 'video')
|
||||
const Center(
|
||||
child: _PlayOverlay(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
file.name,
|
||||
maxLines: isAudio || isDocument ? 2 : 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: borderColor,
|
||||
width: isSelected ? 2.2 : 1,
|
||||
),
|
||||
boxShadow: isSelected
|
||||
? [
|
||||
BoxShadow(
|
||||
color: theme.colorScheme.primary
|
||||
.withValues(alpha: 0.12),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 0.5,
|
||||
),
|
||||
]
|
||||
: const [],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 7,
|
||||
right: 7,
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 130),
|
||||
opacity: showSelectionCircle ? 1 : 0,
|
||||
child: IgnorePointer(
|
||||
ignoring: !showSelectionCircle,
|
||||
child: _SelectionCircle(
|
||||
selected: isSelected,
|
||||
onTap: onSelect,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
AspectRatio(
|
||||
aspectRatio: isMedia || isPsd ? 1 : 1.45,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ThumbnailImage(
|
||||
file: file,
|
||||
contextHint: contextHint,
|
||||
borderRadius: 0,
|
||||
),
|
||||
Positioned(
|
||||
top: 7,
|
||||
left: 7,
|
||||
child: _TypeBadge(
|
||||
icon: _badgeIcon(),
|
||||
label: _badgeLabel(ext),
|
||||
color: accentColor,
|
||||
compact: isMedia,
|
||||
),
|
||||
),
|
||||
if (category == 'video')
|
||||
const Center(child: _PlayOverlay()),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
file.name,
|
||||
maxLines: isAudio || isDocument ? 2 : 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${FileTypeUtils.getFileTypeDescription(file.name)} · ${date_utils.DateUtils.formatFileSize(file.size)}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.hintColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -524,6 +873,60 @@ class _CategoryFileTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionCircle extends StatelessWidget {
|
||||
final bool selected;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _SelectionCircle({
|
||||
required this.selected,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
customBorder: const CircleBorder(),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
width: 28,
|
||||
height: 28,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected
|
||||
? colorScheme.primary
|
||||
: colorScheme.surface.withValues(alpha: 0.86),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? colorScheme.primary
|
||||
: colorScheme.outline.withValues(alpha: 0.42),
|
||||
width: 1.4,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.10),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: selected
|
||||
? const Icon(
|
||||
LucideIcons.check,
|
||||
color: Colors.white,
|
||||
size: 16,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TypeBadge extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
@@ -545,7 +948,10 @@ class _TypeBadge extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: compact ? 6 : 7, vertical: 4),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: compact ? 6 : 7,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -580,7 +986,11 @@ class _PlayOverlay extends StatelessWidget {
|
||||
),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Icon(LucideIcons.play, color: Colors.white, size: 22),
|
||||
child: Icon(
|
||||
LucideIcons.play,
|
||||
color: Colors.white,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,10 +56,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
if (mounted) {
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
if (screenWidth >= 1000) {
|
||||
fileManager.setViewType(FileViewType.grid);
|
||||
@@ -70,10 +67,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
fileManager.loadFiles();
|
||||
_isFirstLoad = false;
|
||||
}
|
||||
final downloadManager = Provider.of<DownloadManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
|
||||
downloadManager.initialize();
|
||||
}
|
||||
});
|
||||
@@ -81,13 +75,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
// 上传完成 → 自动刷新当前目录文件列表
|
||||
UploadService.instance.onUploadCompleted = (targetPath, fileName) {
|
||||
if (!mounted) return;
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final normalizedCurrent = FileUtils.toCloudreveUri(
|
||||
fileManager.currentPath,
|
||||
);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
final normalizedCurrent = FileUtils.toCloudreveUri(fileManager.currentPath);
|
||||
if (targetPath == normalizedCurrent) {
|
||||
final fileUri = targetPath.endsWith('/')
|
||||
? '$targetPath$fileName'
|
||||
@@ -112,6 +101,38 @@ class _FilesPageState extends State<FilesPage> {
|
||||
});
|
||||
}
|
||||
|
||||
void _showSelectionMore(
|
||||
FileModel file,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.edit),
|
||||
title: const Text('重命名'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
FileOperationDialogs.showRenameDialog(context, fileManager, file);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.info_outline),
|
||||
title: const Text('查看详情'),
|
||||
onTap: () {
|
||||
Navigator.of(sheetContext).pop();
|
||||
_showFileInfo(file);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- FAB 显隐控制 ----
|
||||
|
||||
void _hideFab() {
|
||||
@@ -188,14 +209,22 @@ class _FilesPageState extends State<FilesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 循环解码路径段,处理多重 URL 编码(如 %25E4%25B8%25AD → 中文)
|
||||
String _decodePathSegment(String segment) {
|
||||
return FileUtils.safeDecodePathSegment(segment);
|
||||
var decoded = segment;
|
||||
for (var i = 0; i < 5; i++) {
|
||||
try {
|
||||
final next = Uri.decodeComponent(decoded);
|
||||
if (next == decoded) break;
|
||||
decoded = next;
|
||||
} catch (_) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
Widget _buildDesktopBreadcrumb(
|
||||
BuildContext context,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
Widget _buildDesktopBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final pathParts = fileManager.currentPath.split('/');
|
||||
@@ -205,9 +234,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => fileManager.currentPath != '/'
|
||||
? fileManager.enterFolder('/')
|
||||
: null,
|
||||
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
@@ -217,18 +244,12 @@ class _FilesPageState extends State<FilesPage> {
|
||||
for (int i = 0; i < pathParts.length; i++) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: theme.hintColor.withValues(alpha: 0.5),
|
||||
),
|
||||
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
|
||||
if (targetPath != fileManager.currentPath) {
|
||||
fileManager.enterFolder(targetPath);
|
||||
}
|
||||
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
|
||||
},
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
child: Padding(
|
||||
@@ -236,12 +257,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
child: Text(
|
||||
_decodePathSegment(pathParts[i]),
|
||||
style: TextStyle(
|
||||
color: i == pathParts.length - 1
|
||||
? colorScheme.onSurface
|
||||
: colorScheme.primary,
|
||||
fontWeight: i == pathParts.length - 1
|
||||
? FontWeight.w600
|
||||
: FontWeight.normal,
|
||||
color: i == pathParts.length - 1 ? colorScheme.onSurface : colorScheme.primary,
|
||||
fontWeight: i == pathParts.length - 1 ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -251,10 +268,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMobileBreadcrumb(
|
||||
BuildContext context,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
Widget _buildMobileBreadcrumb(BuildContext context, FileManagerProvider fileManager) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final pathParts = fileManager.currentPath.split('/');
|
||||
@@ -263,6 +277,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
return SizedBox(
|
||||
height: 40,
|
||||
child: ListView(
|
||||
key: const PageStorageKey('mobile_breadcrumb'),
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: [
|
||||
_buildBreadcrumbChip(
|
||||
@@ -270,18 +285,12 @@ class _FilesPageState extends State<FilesPage> {
|
||||
label: '文件',
|
||||
icon: LucideIcons.home,
|
||||
color: colorScheme.primary,
|
||||
onTap: () => fileManager.currentPath != '/'
|
||||
? fileManager.enterFolder('/')
|
||||
: null,
|
||||
onTap: () => fileManager.currentPath != '/' ? fileManager.enterFolder('/') : null,
|
||||
),
|
||||
for (int i = 0; i < pathParts.length; i++) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: theme.hintColor.withValues(alpha: 0.5),
|
||||
),
|
||||
child: Icon(LucideIcons.chevronRight, size: 14, color: theme.hintColor.withValues(alpha: 0.5)),
|
||||
),
|
||||
_buildBreadcrumbChip(
|
||||
context,
|
||||
@@ -291,9 +300,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
isLast: i == pathParts.length - 1,
|
||||
onTap: () {
|
||||
final targetPath = '/${pathParts.sublist(0, i + 1).join('/')}';
|
||||
if (targetPath != fileManager.currentPath) {
|
||||
fileManager.enterFolder(targetPath);
|
||||
}
|
||||
if (targetPath != fileManager.currentPath) fileManager.enterFolder(targetPath);
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -317,9 +324,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
height: 28,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isLast
|
||||
? color.withValues(alpha: 0.15)
|
||||
: color.withValues(alpha: 0.06),
|
||||
color: isLast ? color.withValues(alpha: 0.15) : color.withValues(alpha: 0.06),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
@@ -353,9 +358,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
Consumer<FileManagerProvider>(
|
||||
builder: (context, fileManager, child) {
|
||||
return IconButton(
|
||||
icon: Icon(
|
||||
fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh,
|
||||
),
|
||||
icon: Icon(fileManager.isLoading ? Icons.hourglass_empty : Icons.refresh),
|
||||
onPressed: () => fileManager.refreshFiles(),
|
||||
tooltip: '刷新',
|
||||
);
|
||||
@@ -375,19 +378,14 @@ class _FilesPageState extends State<FilesPage> {
|
||||
: FileViewType.list,
|
||||
);
|
||||
},
|
||||
tooltip: fileManager.viewType == FileViewType.list
|
||||
? '网格视图'
|
||||
: '列表视图',
|
||||
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
|
||||
);
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add),
|
||||
onPressed: () {
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
FileOperationDialogs.showCreateDialog(context, fileManager);
|
||||
},
|
||||
tooltip: '新建',
|
||||
@@ -399,8 +397,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.cloud_download),
|
||||
onPressed: () =>
|
||||
Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
|
||||
onPressed: () => Provider.of<NavigationProvider>(context, listen: false).setIndex(2),
|
||||
tooltip: '下载',
|
||||
),
|
||||
];
|
||||
@@ -422,9 +419,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
: FileViewType.list,
|
||||
);
|
||||
},
|
||||
tooltip: fileManager.viewType == FileViewType.list
|
||||
? '网格视图'
|
||||
: '列表视图',
|
||||
tooltip: fileManager.viewType == FileViewType.list ? '网格视图' : '列表视图',
|
||||
);
|
||||
},
|
||||
),
|
||||
@@ -476,16 +471,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
isDark: isDark,
|
||||
colorScheme: colorScheme,
|
||||
onTap: () {
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
_onFabSubAction(
|
||||
() => FileOperationDialogs.showCreateDialog(
|
||||
context,
|
||||
fileManager,
|
||||
),
|
||||
);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
_onFabSubAction(() => FileOperationDialogs.showCreateDialog(context, fileManager));
|
||||
},
|
||||
),
|
||||
_buildFabSubItem(
|
||||
@@ -495,10 +482,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
label: '离线下载',
|
||||
isDark: isDark,
|
||||
colorScheme: colorScheme,
|
||||
onTap: () => _onFabSubAction(
|
||||
() =>
|
||||
Navigator.of(context).pushNamed(RouteNames.remoteDownload),
|
||||
),
|
||||
onTap: () => _onFabSubAction(() => Navigator.of(context).pushNamed(RouteNames.remoteDownload)),
|
||||
),
|
||||
Consumer<FileManagerProvider>(
|
||||
builder: (context, fileManager, _) {
|
||||
@@ -584,10 +568,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 7,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: isDark
|
||||
? Colors.white.withValues(alpha: 0.12)
|
||||
@@ -668,8 +649,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
final isDesktop = MediaQuery.of(context).size.width >= 1000;
|
||||
final child = _buildFileList(context);
|
||||
|
||||
if (!isDesktop ||
|
||||
!Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
|
||||
if (!isDesktop || !Platform.isWindows && !Platform.isLinux && !Platform.isMacOS) {
|
||||
return child;
|
||||
}
|
||||
|
||||
@@ -693,19 +673,13 @@ class _FilesPageState extends State<FilesPage> {
|
||||
strokeAlign: BorderSide.strokeAlignOutside,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surface.withValues(alpha: 0.85),
|
||||
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85),
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.upload,
|
||||
size: 48,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
Icon(LucideIcons.upload, size: 48, color: Theme.of(context).colorScheme.primary),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'释放文件以上传到当前目录',
|
||||
@@ -735,14 +709,8 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
if (files.isEmpty) return;
|
||||
|
||||
final uploadManager = Provider.of<UploadManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final fileManager = Provider.of<FileManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final uploadManager = Provider.of<UploadManagerProvider>(context, listen: false);
|
||||
final fileManager = Provider.of<FileManagerProvider>(context, listen: false);
|
||||
uploadManager.markShouldShowDialog();
|
||||
uploadManager.startUpload(files, fileManager.currentPath);
|
||||
ToastHelper.info('已添加 ${files.length} 个文件到上传队列');
|
||||
@@ -772,19 +740,12 @@ class _FilesPageState extends State<FilesPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildErrorView(
|
||||
BuildContext context,
|
||||
FileManagerProvider fileManager,
|
||||
) {
|
||||
Widget _buildErrorView(BuildContext context, FileManagerProvider fileManager) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.error_outline,
|
||||
size: 64,
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
Icon(Icons.error_outline, size: 64, color: Theme.of(context).colorScheme.error),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
fileManager.errorMessage!,
|
||||
@@ -832,12 +793,13 @@ class _FilesPageState extends State<FilesPage> {
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScrollNotification,
|
||||
child: ListView.builder(
|
||||
key: PageStorageKey('files_list_${fileManager.currentPath}'),
|
||||
cacheExtent: 900,
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
itemCount: fileManager.files.length,
|
||||
itemBuilder: (context, index) {
|
||||
final file = fileManager.files[index];
|
||||
final isSelected = fileManager.selectedFiles.contains(
|
||||
file.path,
|
||||
);
|
||||
final isSelected = fileManager.selectedFiles.contains(file.path);
|
||||
|
||||
return FileListItem(
|
||||
key: ValueKey('file_${file.id}'),
|
||||
@@ -859,37 +821,14 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
},
|
||||
onSelect: () => fileManager.toggleSelection(file.path),
|
||||
onDownload: !file.isFolder
|
||||
? () => _downloadFile(context, fileManager, file)
|
||||
: null,
|
||||
onOpenInBrowser: !file.isFolder
|
||||
? () => _openInBrowser(context, file)
|
||||
: null,
|
||||
onRename: () => FileOperationDialogs.showRenameDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
),
|
||||
onMove: () => FileOperationDialogs.showMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
false,
|
||||
),
|
||||
onCopy: () => FileOperationDialogs.showMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
true,
|
||||
),
|
||||
onShare: () =>
|
||||
FileOperationDialogs.showShareDialog(context, file),
|
||||
onDelete: () =>
|
||||
FileOperationDialogs.showDeleteSingleConfirmation(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
),
|
||||
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
|
||||
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
|
||||
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
|
||||
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
|
||||
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
|
||||
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
|
||||
onShare: () => FileOperationDialogs.showShareDialog(context, file),
|
||||
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
|
||||
onInfo: () => _showFileInfo(file),
|
||||
);
|
||||
},
|
||||
@@ -918,8 +857,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
crossAxisCount = 5;
|
||||
}
|
||||
|
||||
final itemWidth =
|
||||
(availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
|
||||
final itemWidth = (availableWidth - spacing * (crossAxisCount - 1)) / crossAxisCount;
|
||||
final childAspectRatio = itemWidth / 160;
|
||||
final showCheckbox = fileManager.hasSelection;
|
||||
|
||||
@@ -928,6 +866,9 @@ class _FilesPageState extends State<FilesPage> {
|
||||
child: NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScrollNotification,
|
||||
child: GridView.builder(
|
||||
key: PageStorageKey('files_grid_${fileManager.currentPath}'),
|
||||
cacheExtent: 1100,
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.all(8),
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: crossAxisCount,
|
||||
@@ -959,36 +900,14 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
},
|
||||
onSelect: () => fileManager.toggleSelection(file.path),
|
||||
onDownload: !file.isFolder
|
||||
? () => _downloadFile(context, fileManager, file)
|
||||
: null,
|
||||
onOpenInBrowser: !file.isFolder
|
||||
? () => _openInBrowser(context, file)
|
||||
: null,
|
||||
onRename: () => FileOperationDialogs.showRenameDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
),
|
||||
onMove: () => FileOperationDialogs.showMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
false,
|
||||
),
|
||||
onCopy: () => FileOperationDialogs.showMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
true,
|
||||
),
|
||||
onShare: () =>
|
||||
FileOperationDialogs.showShareDialog(context, file),
|
||||
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(
|
||||
context,
|
||||
fileManager,
|
||||
file,
|
||||
),
|
||||
onDownload: !file.isFolder ? () => _downloadFile(context, fileManager, file) : null,
|
||||
onOpenInBrowser: !file.isFolder ? () => _openInBrowser(context, file) : null,
|
||||
onOpenInCloudreveApp: !file.isFolder ? () => _openInCloudreveApp(context, file) : null,
|
||||
onRename: () => FileOperationDialogs.showRenameDialog(context, fileManager, file),
|
||||
onMove: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, false),
|
||||
onCopy: () => FileOperationDialogs.showMoveDialog(context, fileManager, file, true),
|
||||
onShare: () => FileOperationDialogs.showShareDialog(context, file),
|
||||
onDelete: () => FileOperationDialogs.showDeleteSingleConfirmation(context, fileManager, file),
|
||||
onInfo: () => _showFileInfo(file),
|
||||
);
|
||||
},
|
||||
@@ -1006,33 +925,34 @@ class _FilesPageState extends State<FilesPage> {
|
||||
if (fileManager.hasSelection) {
|
||||
return SelectionToolbar(
|
||||
selectionCount: fileManager.selectedFiles.length,
|
||||
totalCount: fileManager.files.length,
|
||||
onCancel: () => fileManager.clearSelection(),
|
||||
onRename: fileManager.selectedFiles.length == 1
|
||||
? () => FileOperationDialogs.showRenameDialog(
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.files.firstWhere(
|
||||
(f) => f.path == fileManager.selectedFiles.first,
|
||||
),
|
||||
)
|
||||
onSelectAll: () => fileManager.selectAll(),
|
||||
onMore: fileManager.selectedFiles.length == 1
|
||||
? () => _showSelectionMore(
|
||||
fileManager.files.firstWhere(
|
||||
(f) => f.path == fileManager.selectedFiles.first,
|
||||
),
|
||||
fileManager,
|
||||
)
|
||||
: null,
|
||||
onMove: () => FileOperationDialogs.showBatchMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
false,
|
||||
),
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
false,
|
||||
),
|
||||
onCopy: () => FileOperationDialogs.showBatchMoveDialog(
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
true,
|
||||
),
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
true,
|
||||
),
|
||||
onDelete: () => FileOperationDialogs.showDeleteConfirmation(
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
),
|
||||
context,
|
||||
fileManager,
|
||||
fileManager.selectedFiles,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1056,17 +976,11 @@ class _FilesPageState extends State<FilesPage> {
|
||||
} else if (FileTypeUtils.isAudio(file.name)) {
|
||||
Navigator.of(context).pushNamed(RouteNames.audioPreview, arguments: file);
|
||||
} else if (FileTypeUtils.isMarkdown(file.name)) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteNames.markdownPreview, arguments: file);
|
||||
Navigator.of(context).pushNamed(RouteNames.markdownPreview, arguments: file);
|
||||
} else if (FileTypeUtils.isTextCode(file.name)) {
|
||||
Navigator.of(
|
||||
context,
|
||||
).pushNamed(RouteNames.documentPreview, arguments: file);
|
||||
Navigator.of(context).pushNamed(RouteNames.documentPreview, arguments: file);
|
||||
} else {
|
||||
ToastHelper.info(
|
||||
'暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}',
|
||||
);
|
||||
ToastHelper.info('暂不支持预览 ${FileTypeUtils.getFileTypeDescription(file.name)}');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1075,10 +989,7 @@ class _FilesPageState extends State<FilesPage> {
|
||||
FileManagerProvider fileManager,
|
||||
FileModel file,
|
||||
) async {
|
||||
final downloadManager = Provider.of<DownloadManagerProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
final downloadManager = Provider.of<DownloadManagerProvider>(context, listen: false);
|
||||
final task = await downloadManager.addDownloadTask(
|
||||
fileName: file.name,
|
||||
fileUri: file.relativePath,
|
||||
@@ -1124,4 +1035,13 @@ class _FilesPageState extends State<FilesPage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _openInCloudreveApp(BuildContext context, FileModel file) {
|
||||
Navigator.of(context).pushNamed(
|
||||
RouteNames.cloudreveFileApp,
|
||||
arguments: {
|
||||
'file': file,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../../../data/models/file_model.dart';
|
||||
import '../../../services/api_service.dart';
|
||||
|
||||
/// 完全交给 Cloudreve 官方 Web 前端处理文件打开。
|
||||
///
|
||||
/// 这里不再自己读取 file_viewers,也不再自己创建 viewerSession。
|
||||
/// Cloudreve 前端本身会根据 `/home?path=...&open=...` 打开对应文件,
|
||||
/// 并使用它自己的文件应用、WOPI、Markdown、表格、压缩包、EPUB 等逻辑。
|
||||
class CloudreveFileAppPage extends StatefulWidget {
|
||||
final FileModel file;
|
||||
final String? preferredAction;
|
||||
|
||||
const CloudreveFileAppPage({
|
||||
super.key,
|
||||
required this.file,
|
||||
this.preferredAction,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CloudreveFileAppPage> createState() => _CloudreveFileAppPageState();
|
||||
}
|
||||
|
||||
class _CloudreveFileAppPageState extends State<CloudreveFileAppPage> {
|
||||
late final WebViewController _controller;
|
||||
|
||||
bool _isPreparing = true;
|
||||
bool _sessionInjected = false;
|
||||
int _progress = 0;
|
||||
String? _error;
|
||||
late Uri _targetUri;
|
||||
late Uri _originUri;
|
||||
String? _sessionStateJson;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setBackgroundColor(Colors.transparent)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onProgress: (progress) {
|
||||
if (!mounted) return;
|
||||
setState(() => _progress = progress);
|
||||
},
|
||||
onPageFinished: (_) => _injectSessionAndOpenIfNeeded(),
|
||||
onWebResourceError: (error) {
|
||||
if (!mounted) return;
|
||||
if (error.isForMainFrame == true) {
|
||||
setState(() {
|
||||
_error = '${error.errorCode}: ${error.description}';
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
_prepareAndOpen();
|
||||
}
|
||||
|
||||
Future<void> _prepareAndOpen() async {
|
||||
setState(() {
|
||||
_isPreparing = true;
|
||||
_error = null;
|
||||
_progress = 0;
|
||||
_sessionInjected = false;
|
||||
});
|
||||
|
||||
try {
|
||||
_originUri = _buildOriginUri();
|
||||
_targetUri = _buildCloudreveHomeUri();
|
||||
|
||||
_sessionStateJson = await _buildCloudreveFrontendSessionJson();
|
||||
|
||||
// 先打开同源页面,再注入 localStorage。
|
||||
// 这样 Cloudreve 官方前端可以直接复用 App 的 access_token。
|
||||
await _controller.loadRequest(_originUri);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _isPreparing = false);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = e.toString();
|
||||
_isPreparing = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Uri _buildOriginUri() {
|
||||
final base = Uri.parse(ApiService.instance.dio.options.baseUrl);
|
||||
return Uri(
|
||||
scheme: base.scheme,
|
||||
host: base.host,
|
||||
port: base.hasPort ? base.port : null,
|
||||
path: '/',
|
||||
);
|
||||
}
|
||||
|
||||
Uri _buildCloudreveHomeUri() {
|
||||
final parent = _parentUri(widget.file.relativePath);
|
||||
final openTarget = widget.file.id.isNotEmpty ? widget.file.id : widget.file.relativePath;
|
||||
|
||||
return Uri(
|
||||
scheme: _originUri.scheme,
|
||||
host: _originUri.host,
|
||||
port: _originUri.hasPort ? _originUri.port : null,
|
||||
path: '/home',
|
||||
queryParameters: {
|
||||
'path': parent,
|
||||
'open': openTarget,
|
||||
'size': widget.file.size.toString(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _parentUri(String uri) {
|
||||
final normalized = uri.endsWith('/') ? uri.substring(0, uri.length - 1) : uri;
|
||||
final index = normalized.lastIndexOf('/');
|
||||
if (index <= 'cloudreve://my'.length) {
|
||||
return 'cloudreve://my';
|
||||
}
|
||||
return normalized.substring(0, index);
|
||||
}
|
||||
|
||||
Future<String?> _buildCloudreveFrontendSessionJson() async {
|
||||
final tokenGetter = ApiService.instance.getTokenCallback;
|
||||
final token = tokenGetter == null ? null : await tokenGetter();
|
||||
if (token == null || token.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Map<String, dynamic>? user;
|
||||
try {
|
||||
final response = await ApiService.instance.get<Map<String, dynamic>>('/user/me');
|
||||
user = Map<String, dynamic>.from(response);
|
||||
} catch (_) {
|
||||
user = null;
|
||||
}
|
||||
|
||||
final userId = user?['id']?.toString() ?? user?['uid']?.toString() ?? 'app';
|
||||
|
||||
final now = DateTime.now().toUtc();
|
||||
final accessExpires = now.add(const Duration(hours: 2)).toIso8601String();
|
||||
final refreshExpires = now.add(const Duration(hours: 2)).toIso8601String();
|
||||
|
||||
final sessionState = {
|
||||
'current': userId,
|
||||
'sessions': {
|
||||
userId: {
|
||||
'user': user ?? {'id': userId},
|
||||
'token': {
|
||||
'access_token': token,
|
||||
// App 侧暂时没有把 refresh token 暴露给这里。
|
||||
// 给 Cloudreve 前端一个短期可用 session;过期后 WebView 内刷新会要求重新登录。
|
||||
'refresh_token': '',
|
||||
'access_expires': accessExpires,
|
||||
'refresh_expires': refreshExpires,
|
||||
},
|
||||
'settings': {},
|
||||
},
|
||||
},
|
||||
'anonymousSettings': {},
|
||||
};
|
||||
|
||||
return jsonEncode(sessionState);
|
||||
}
|
||||
|
||||
Future<void> _injectSessionAndOpenIfNeeded() async {
|
||||
if (_sessionInjected) return;
|
||||
_sessionInjected = true;
|
||||
|
||||
final sessionJson = _sessionStateJson;
|
||||
final target = _targetUri.toString();
|
||||
|
||||
try {
|
||||
if (sessionJson != null) {
|
||||
final script = '''
|
||||
try {
|
||||
localStorage.setItem('cloudreve_session', ${jsonEncode(sessionJson)});
|
||||
} catch (e) {}
|
||||
window.location.replace(${jsonEncode(target)});
|
||||
''';
|
||||
await _controller.runJavaScript(script);
|
||||
} else {
|
||||
await _controller.loadRequest(_targetUri);
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _error = e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openTargetAgain() async {
|
||||
_sessionInjected = true;
|
||||
await _controller.loadRequest(_targetUri);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
widget.file.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: '重新打开',
|
||||
icon: const Icon(Icons.open_in_browser),
|
||||
onPressed: _openTargetAgain,
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '刷新',
|
||||
icon: const Icon(Icons.refresh),
|
||||
onPressed: () => _controller.reload(),
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(3),
|
||||
child: _progress > 0 && _progress < 100
|
||||
? LinearProgressIndicator(value: _progress / 100)
|
||||
: const SizedBox(height: 3),
|
||||
),
|
||||
),
|
||||
body: _buildBody(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_isPreparing) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.error_outline, size: 56, color: Colors.red),
|
||||
const SizedBox(height: 16),
|
||||
Text(_error!, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 16),
|
||||
FilledButton.icon(
|
||||
onPressed: _prepareAndOpen,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重试'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return WebViewWidget(controller: _controller);
|
||||
}
|
||||
}
|
||||
@@ -23,45 +23,26 @@ class QuickFunctionsSection extends StatelessWidget {
|
||||
const QuickFunctionsSection({super.key});
|
||||
|
||||
static final _functions = [
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.share2,
|
||||
label: '我的分享',
|
||||
route: RouteNames.share,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.cloud,
|
||||
label: 'WebDAV',
|
||||
route: RouteNames.webdav,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.download,
|
||||
label: '离线下载',
|
||||
route: RouteNames.remoteDownload,
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.trash2,
|
||||
label: '回收站',
|
||||
route: RouteNames.recycleBin,
|
||||
),
|
||||
_QuickFunction(icon: LucideIcons.share2, label: '我的分享', route: RouteNames.share),
|
||||
_QuickFunction(icon: LucideIcons.cloud, label: 'WebDAV', route: RouteNames.webdav),
|
||||
_QuickFunction(icon: LucideIcons.download, label: '离线下载', route: RouteNames.remoteDownload),
|
||||
_QuickFunction(icon: LucideIcons.trash2, label: '回收站', route: RouteNames.recycleBin),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.refreshCw,
|
||||
label: '文件同步',
|
||||
onTap: (ctx) {
|
||||
final isMobilePlatform =
|
||||
defaultTargetPlatform == TargetPlatform.android ||
|
||||
defaultTargetPlatform == TargetPlatform.iOS;
|
||||
if (isMobilePlatform) {
|
||||
final nav = ctx.read<NavigationProvider>();
|
||||
// 桌面端有同步 Tab(index 4),直接切换;移动端跳转同步设置页
|
||||
final isDesktop = defaultTargetPlatform != TargetPlatform.android &&
|
||||
defaultTargetPlatform != TargetPlatform.iOS;
|
||||
if (isDesktop) {
|
||||
nav.setIndex(4);
|
||||
} else {
|
||||
Navigator.of(ctx).pushNamed(RouteNames.syncSettings);
|
||||
return;
|
||||
}
|
||||
ctx.read<NavigationProvider>().setIndex(3);
|
||||
},
|
||||
),
|
||||
_QuickFunction(
|
||||
icon: LucideIcons.settings,
|
||||
label: '设置',
|
||||
route: RouteNames.settings,
|
||||
),
|
||||
_QuickFunction(icon: LucideIcons.settings, label: '设置', route: RouteNames.settings),
|
||||
];
|
||||
|
||||
static const double _spacing = 12;
|
||||
@@ -82,12 +63,9 @@ class QuickFunctionsSection extends StatelessWidget {
|
||||
children: [
|
||||
Icon(LucideIcons.zap, size: 18, color: theme.colorScheme.primary),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'快捷功能',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Text('快捷功能',
|
||||
style: theme.textTheme.titleMedium
|
||||
?.copyWith(fontWeight: FontWeight.w600)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -101,8 +79,7 @@ class QuickFunctionsSection extends StatelessWidget {
|
||||
if (itemWidth < _minItemWidth) break;
|
||||
perRow = next;
|
||||
}
|
||||
final itemWidth =
|
||||
(availableWidth - _spacing * (perRow - 1)) / perRow;
|
||||
final itemWidth = (availableWidth - _spacing * (perRow - 1)) / perRow;
|
||||
|
||||
return Wrap(
|
||||
spacing: _spacing,
|
||||
@@ -155,7 +132,9 @@ class _QuickFunctionCardState extends State<_QuickFunctionCard> {
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Card(
|
||||
color: _hovered ? colorScheme.surfaceContainerHighest : null,
|
||||
color: _hovered
|
||||
? colorScheme.surfaceContainerHighest
|
||||
: null,
|
||||
child: InkWell(
|
||||
onTap: widget.onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
|
||||
@@ -930,7 +930,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
(Level.error, 'Error — 仅错误'),
|
||||
(Level.warning, 'Warning — 错误 + 警告'),
|
||||
(Level.info, 'Info — 常规信息'),
|
||||
(Level.debug, 'Debug — 调试信息(含FFI交互)'),
|
||||
(Level.debug, 'Debug — 调试信息(含FFI)'),
|
||||
(Level.trace, 'Trace — 全量追踪'),
|
||||
];
|
||||
|
||||
@@ -950,7 +950,7 @@ class _AppSettingsPageState extends State<AppSettingsPage> {
|
||||
color: isSelected ? Theme.of(ctx).colorScheme.primary : Theme.of(ctx).hintColor,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(e.$2),
|
||||
Flexible(child: Text(e.$2)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,11 +4,17 @@ import 'package:cloudreve4_flutter/presentation/providers/file_manager_provider.
|
||||
import 'package:cloudreve4_flutter/presentation/providers/navigation_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/sync_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/providers/upload_manager_provider.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/announcement_dialog.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/gesture_handler_mixin.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/glassmorphism_container.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/user_avatar.dart';
|
||||
import 'package:cloudreve4_flutter/services/announcement_service.dart';
|
||||
import 'package:cloudreve4_flutter/services/dialog_queue_service.dart';
|
||||
import 'package:cloudreve4_flutter/services/share_link_service.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/pages/share/share_link_page.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../router/app_router.dart';
|
||||
@@ -47,9 +53,10 @@ class AppShell extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _AppShellState extends State<AppShell>
|
||||
with GestureHandlerMixin, TickerProviderStateMixin {
|
||||
with GestureHandlerMixin, TickerProviderStateMixin, WidgetsBindingObserver {
|
||||
final Set<int> _visitedPageIndexes = <int>{0};
|
||||
late AnimationController _syncSpinController;
|
||||
String? _lastClipboardShareId;
|
||||
|
||||
bool get _showSyncTab =>
|
||||
defaultTargetPlatform != TargetPlatform.android &&
|
||||
@@ -84,14 +91,104 @@ class _AppShellState extends State<AppShell>
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_showPostLoginAnnouncement();
|
||||
_checkClipboardShareLink();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_syncSpinController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_checkClipboardShareLink();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _showPostLoginAnnouncement() async {
|
||||
final authProvider = context.read<AuthProvider>();
|
||||
if (!authProvider.isAuthenticated) return;
|
||||
|
||||
try {
|
||||
final service = AnnouncementService.instance;
|
||||
final announcement = await service.getChangedSiteNotice();
|
||||
if (!mounted || announcement == null) return;
|
||||
|
||||
await DialogQueueService.instance.enqueue<void>(() async {
|
||||
if (!mounted) return;
|
||||
|
||||
await AnnouncementDialog.show(
|
||||
context,
|
||||
title: announcement.title,
|
||||
html: announcement.html,
|
||||
baseUrl: announcement.baseUrl,
|
||||
);
|
||||
|
||||
await service.markDismissed(announcement);
|
||||
});
|
||||
} catch (_) {
|
||||
// Announcement checks should never block the shell.
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _checkClipboardShareLink() async {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 650));
|
||||
if (!mounted) return;
|
||||
|
||||
try {
|
||||
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||
final candidate = ShareLinkService.instance.parseShareLink(data?.text);
|
||||
|
||||
if (candidate == null) return;
|
||||
if (_lastClipboardShareId == candidate.id) return;
|
||||
|
||||
_lastClipboardShareId = candidate.id;
|
||||
|
||||
await DialogQueueService.instance.enqueue<void>(() async {
|
||||
if (!mounted) return;
|
||||
|
||||
final open = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('检测到分享链接'),
|
||||
content: Text(
|
||||
'是否打开这个文件分享?\n\n${candidate.url}',
|
||||
maxLines: 5,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: const Text('忽略'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
child: const Text('打开'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (open == true && mounted) {
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => ShareLinkPage(candidate: candidate),
|
||||
),
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (_) {
|
||||
// Clipboard access failures should not affect the shell.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
|
||||
Reference in New Issue
Block a user