Android Feature Optimization and Updates
Build Android Release / Build Android (push) Failing after 7s
Build Android Release / Build Android (push) Failing after 7s
This commit is contained in:
@@ -0,0 +1,496 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
class CaptchaWebConfig {
|
||||
final String type;
|
||||
final String displayName;
|
||||
final String? siteKey;
|
||||
final String? instanceUrl;
|
||||
final String? assetServer;
|
||||
|
||||
const CaptchaWebConfig._({
|
||||
required this.type,
|
||||
required this.displayName,
|
||||
this.siteKey,
|
||||
this.instanceUrl,
|
||||
this.assetServer,
|
||||
});
|
||||
|
||||
const CaptchaWebConfig.recaptchaV2({
|
||||
required String siteKey,
|
||||
String displayName = 'reCAPTCHA V2',
|
||||
}) : this._(type: 'recaptcha', displayName: displayName, siteKey: siteKey);
|
||||
|
||||
const CaptchaWebConfig.turnstile({
|
||||
required String siteKey,
|
||||
String displayName = 'Cloudflare Turnstile',
|
||||
}) : this._(type: 'turnstile', displayName: displayName, siteKey: siteKey);
|
||||
|
||||
const CaptchaWebConfig.cap({
|
||||
required String instanceUrl,
|
||||
required String siteKey,
|
||||
String? assetServer,
|
||||
String displayName = 'Cap',
|
||||
}) : this._(
|
||||
type: 'cap',
|
||||
displayName: displayName,
|
||||
instanceUrl: instanceUrl,
|
||||
siteKey: siteKey,
|
||||
assetServer: assetServer,
|
||||
);
|
||||
}
|
||||
|
||||
class CaptchaChallengePage extends StatefulWidget {
|
||||
final CaptchaWebConfig config;
|
||||
final String baseUrl;
|
||||
|
||||
const CaptchaChallengePage({
|
||||
super.key,
|
||||
required this.config,
|
||||
required this.baseUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
State<CaptchaChallengePage> createState() => _CaptchaChallengePageState();
|
||||
}
|
||||
|
||||
class _CaptchaChallengePageState extends State<CaptchaChallengePage> {
|
||||
late final WebViewController _controller;
|
||||
bool _isLoading = true;
|
||||
int _progress = 0;
|
||||
String? _errorMessage;
|
||||
String? _statusText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_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();
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
_loadCaptcha();
|
||||
}
|
||||
|
||||
Future<void> _loadCaptcha() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_statusText = null;
|
||||
_progress = 0;
|
||||
});
|
||||
|
||||
final html = _buildHtml(widget.config);
|
||||
await _controller.loadHtmlString(html, baseUrl: widget.baseUrl);
|
||||
}
|
||||
|
||||
void _handleBridgeMessage(String rawMessage) {
|
||||
try {
|
||||
final decoded = jsonDecode(rawMessage);
|
||||
if (decoded is! Map) return;
|
||||
|
||||
final type = decoded['type']?.toString();
|
||||
|
||||
if (type == 'success') {
|
||||
final token = decoded['token']?.toString() ?? '';
|
||||
if (token.isNotEmpty && mounted) {
|
||||
Navigator.of(context).pop(token);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'progress') {
|
||||
final progress = decoded['progress']?.toString();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusText = progress == null ? '正在验证...' : '正在验证... $progress';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'error') {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = decoded['message']?.toString() ?? '验证码加载失败';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (type == 'expired') {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_statusText = '验证码已过期,请重新验证';
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
} catch (_) {
|
||||
// 忽略非 JSON 消息。
|
||||
}
|
||||
}
|
||||
|
||||
String _buildHtml(CaptchaWebConfig config) {
|
||||
switch (config.type) {
|
||||
case 'turnstile':
|
||||
return _buildTurnstileHtml(config.siteKey!);
|
||||
case 'recaptcha':
|
||||
return _buildRecaptchaHtml(config.siteKey!);
|
||||
case 'cap':
|
||||
return _buildCapHtml(
|
||||
instanceUrl: config.instanceUrl!,
|
||||
siteKey: config.siteKey!,
|
||||
assetServer: config.assetServer,
|
||||
);
|
||||
default:
|
||||
return _buildErrorHtml('不支持的验证码类型: ${config.type}');
|
||||
}
|
||||
}
|
||||
|
||||
String _baseHtml({
|
||||
required String title,
|
||||
required String body,
|
||||
required String script,
|
||||
}) {
|
||||
final safeTitle = const HtmlEscape().convert(title);
|
||||
return '''
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<title>$safeTitle</title>
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: #ffffff;
|
||||
color: #0f172a;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans SC", sans-serif;
|
||||
}
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.card {
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
h1 {
|
||||
font-size: 18px;
|
||||
margin: 0 0 8px;
|
||||
text-align: center;
|
||||
color: #111827;
|
||||
}
|
||||
p {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
text-align: center;
|
||||
margin: 0 0 20px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
#widget {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
min-height: 78px;
|
||||
align-items: center;
|
||||
}
|
||||
.status {
|
||||
margin-top: 14px;
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
word-break: break-word;
|
||||
}
|
||||
.error {
|
||||
color: #dc2626;
|
||||
}
|
||||
cap-widget {
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="page">
|
||||
<div class="card">
|
||||
<h1>$safeTitle</h1>
|
||||
<p>完成验证后会自动返回登录页。</p>
|
||||
$body
|
||||
<div id="status" class="status">正在加载验证码...</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
function sendBridge(payload) {
|
||||
try {
|
||||
CaptchaBridge.postMessage(JSON.stringify(payload));
|
||||
} catch (e) {}
|
||||
}
|
||||
function markStatus(text, isError) {
|
||||
var el = document.getElementById('status');
|
||||
if (!el) return;
|
||||
el.textContent = text || '';
|
||||
el.className = isError ? 'status error' : 'status';
|
||||
}
|
||||
function solved(token) {
|
||||
markStatus('验证完成,正在返回...', false);
|
||||
sendBridge({ type: 'success', token: token });
|
||||
}
|
||||
function failed(message) {
|
||||
markStatus(message || '验证码加载失败', true);
|
||||
sendBridge({ type: 'error', message: message || '验证码加载失败' });
|
||||
}
|
||||
function expired() {
|
||||
markStatus('验证码已过期,请重新验证', true);
|
||||
sendBridge({ type: 'expired' });
|
||||
}
|
||||
$script
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
''';
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = widget.config.displayName;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: '刷新验证码',
|
||||
onPressed: _loadCaptcha,
|
||||
),
|
||||
],
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(3),
|
||||
child: _progress > 0 && _progress < 100
|
||||
? LinearProgressIndicator(value: _progress / 100)
|
||||
: const SizedBox(height: 3),
|
||||
),
|
||||
),
|
||||
body: Stack(
|
||||
children: [
|
||||
WebViewWidget(controller: _controller),
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,20 @@
|
||||
import 'package:cloudreve4_flutter/data/models/login_config_model.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
|
||||
import 'package:cloudreve4_flutter/services/captcha_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../core/validators/string_validator.dart';
|
||||
import '../../../services/auth_service.dart';
|
||||
import '../../../services/server_service.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
|
||||
class ForgotPasswordPage extends StatefulWidget {
|
||||
const ForgotPasswordPage({super.key});
|
||||
final LoginConfigModel loginConfig;
|
||||
|
||||
const ForgotPasswordPage({
|
||||
super.key,
|
||||
this.loginConfig = const LoginConfigModel(),
|
||||
});
|
||||
|
||||
@override
|
||||
State<ForgotPasswordPage> createState() => _ForgotPasswordPageState();
|
||||
@@ -18,6 +26,21 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.loginConfig.forgetCaptcha) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final server = ServerService.instance.currentServer;
|
||||
if (server != null) {
|
||||
CaptchaService.instance.loadCaptcha(server.baseUrl).then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
@@ -27,14 +50,26 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
||||
Future<void> _sendResetEmail() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final captcha = CaptchaService.instance;
|
||||
if (widget.loginConfig.forgetCaptcha && !captcha.isWebCaptchaVerified) {
|
||||
ToastHelper.failure('请先完成人机验证');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final captchaParams = widget.loginConfig.forgetCaptcha
|
||||
? captcha.getCaptchaParams()
|
||||
: <String, String>{};
|
||||
|
||||
await AuthService.instance.sendResetPasswordEmail(
|
||||
email: _emailController.text.trim(),
|
||||
captcha: captchaParams['captcha'],
|
||||
ticket: captchaParams['ticket'],
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
@@ -43,6 +78,10 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
if (widget.loginConfig.forgetCaptcha) {
|
||||
await captcha.refreshCaptcha();
|
||||
setState(() {});
|
||||
}
|
||||
setState(() => _errorMessage = e.toString());
|
||||
}
|
||||
} finally {
|
||||
@@ -53,6 +92,7 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final captcha = CaptchaService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('忘记密码')),
|
||||
@@ -91,10 +131,17 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
||||
prefixIcon: Icon(LucideIcons.mail),
|
||||
),
|
||||
),
|
||||
if (widget.loginConfig.forgetCaptcha) ...[
|
||||
const SizedBox(height: 16),
|
||||
captcha.buildCaptchaInput(context),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -121,7 +168,9 @@ class _ForgotPasswordPageState extends State<ForgotPasswordPage> {
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('发送重置邮件'),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,11 @@ import 'package:lucide_icons/lucide_icons.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../../core/exceptions/app_exception.dart';
|
||||
import '../../../core/validators/string_validator.dart';
|
||||
import '../../../data/models/login_config_model.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../../services/api_service.dart';
|
||||
import '../../../services/auth_service.dart';
|
||||
import '../../../services/captcha_service.dart';
|
||||
import '../../../services/server_service.dart';
|
||||
import '../../../router/app_router.dart';
|
||||
import 'forgot_password_page.dart';
|
||||
@@ -27,11 +31,15 @@ class _LoginPageState extends State<LoginPage> {
|
||||
bool _obscurePassword = true;
|
||||
bool _rememberMe = false;
|
||||
bool _isLoading = false;
|
||||
LoginConfigModel _loginConfig = const LoginConfigModel();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadRememberedInfo();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadLoginConfig();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -57,23 +65,59 @@ class _LoginPageState extends State<LoginPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadLoginConfig() async {
|
||||
final server = ServerService.instance.currentServer;
|
||||
if (server == null) return;
|
||||
|
||||
try {
|
||||
await ApiService.instance.setBaseUrl(server.baseUrl);
|
||||
final config = await AuthService.instance.getLoginConfig().timeout(
|
||||
const Duration(seconds: 10),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => _loginConfig = config);
|
||||
|
||||
if (config.loginCaptcha) {
|
||||
await CaptchaService.instance.loadCaptcha(server.baseUrl);
|
||||
if (mounted) setState(() {});
|
||||
} else {
|
||||
CaptchaService.instance.clearCaptcha();
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _login() async {
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
|
||||
final captcha = CaptchaService.instance;
|
||||
if (_loginConfig.loginCaptcha && !captcha.isWebCaptchaVerified) {
|
||||
ToastHelper.failure('请先完成人机验证');
|
||||
return;
|
||||
}
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final success = await authProvider.passwordLogin(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
rememberMe: _rememberMe,
|
||||
).timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw Exception('请求超时'),
|
||||
);
|
||||
final captchaParams = _loginConfig.loginCaptcha
|
||||
? captcha.getCaptchaParams()
|
||||
: <String, String>{};
|
||||
|
||||
final success = await authProvider
|
||||
.passwordLogin(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
rememberMe: _rememberMe,
|
||||
captcha: captchaParams['captcha'],
|
||||
ticket: captchaParams['ticket'],
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 15),
|
||||
onTimeout: () => throw Exception('请求超时'),
|
||||
);
|
||||
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
|
||||
@@ -83,6 +127,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (mounted) navigator.pushReplacementNamed(RouteNames.home);
|
||||
} else if (mounted) {
|
||||
if (_loginConfig.loginCaptcha) {
|
||||
await captcha.refreshCaptcha();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
final errorMessage = authProvider.errorMessage;
|
||||
if (errorMessage != null && errorMessage.isNotEmpty) {
|
||||
final errorMsg = _parseErrorMessage(errorMessage);
|
||||
@@ -99,6 +148,11 @@ class _LoginPageState extends State<LoginPage> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() => _isLoading = false);
|
||||
if (_loginConfig.loginCaptcha) {
|
||||
await captcha.refreshCaptcha();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
final errorMsg = _parseErrorMessage(e.toString());
|
||||
ToastHelper.failure(errorMsg);
|
||||
}
|
||||
@@ -153,6 +207,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final captcha = CaptchaService.instance;
|
||||
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
@@ -168,10 +223,7 @@ class _LoginPageState extends State<LoginPage> {
|
||||
Center(child: _buildLogo()),
|
||||
const SizedBox(height: 32),
|
||||
Center(
|
||||
child: Text(
|
||||
'公云存储',
|
||||
style: theme.textTheme.headlineMedium,
|
||||
),
|
||||
child: Text('公云存储', style: theme.textTheme.headlineMedium),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
Card(
|
||||
@@ -196,7 +248,8 @@ class _LoginPageState extends State<LoginPage> {
|
||||
hintText: '请输入邮箱地址',
|
||||
prefixIcon: Icon(LucideIcons.mail),
|
||||
),
|
||||
onFieldSubmitted: (_) => _focusNode.requestFocus(),
|
||||
onFieldSubmitted: (_) =>
|
||||
_focusNode.requestFocus(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
@@ -219,18 +272,27 @@ class _LoginPageState extends State<LoginPage> {
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() => _obscurePassword = !_obscurePassword);
|
||||
setState(
|
||||
() =>
|
||||
_obscurePassword = !_obscurePassword,
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
onFieldSubmitted: (_) => _login(),
|
||||
),
|
||||
|
||||
if (_loginConfig.loginCaptcha) ...[
|
||||
const SizedBox(height: 16),
|
||||
captcha.buildCaptchaInput(context),
|
||||
],
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 记住我
|
||||
InkWell(
|
||||
onTap: () => setState(() => _rememberMe = !_rememberMe),
|
||||
onTap: () =>
|
||||
setState(() => _rememberMe = !_rememberMe),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -240,7 +302,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
height: 24,
|
||||
child: Checkbox(
|
||||
value: _rememberMe,
|
||||
onChanged: (v) => setState(() => _rememberMe = v ?? false),
|
||||
onChanged: (v) => setState(
|
||||
() => _rememberMe = v ?? false,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
@@ -259,7 +323,10 @@ class _LoginPageState extends State<LoginPage> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const ForgotPasswordPage(),
|
||||
builder: (context) =>
|
||||
ForgotPasswordPage(
|
||||
loginConfig: _loginConfig,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -269,7 +336,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const RegisterPage(),
|
||||
builder: (context) => RegisterPage(
|
||||
loginConfig: _loginConfig,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -293,7 +362,9 @@ class _LoginPageState extends State<LoginPage> {
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('登录'),
|
||||
),
|
||||
@@ -356,17 +427,17 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
|
||||
duration: const Duration(milliseconds: 400),
|
||||
vsync: this,
|
||||
);
|
||||
_shakeAnimation = TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 10, end: -10), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -10, end: 8), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -8, end: 4), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 4, end: 0), weight: 1),
|
||||
]).animate(CurvedAnimation(
|
||||
parent: _shakeController,
|
||||
curve: Curves.easeInOut,
|
||||
));
|
||||
_shakeAnimation =
|
||||
TweenSequence<double>([
|
||||
TweenSequenceItem(tween: Tween(begin: 0, end: 10), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 10, end: -10), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -10, end: 8), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 8, end: -8), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: -8, end: 4), weight: 1),
|
||||
TweenSequenceItem(tween: Tween(begin: 4, end: 0), weight: 1),
|
||||
]).animate(
|
||||
CurvedAnimation(parent: _shakeController, curve: Curves.easeInOut),
|
||||
);
|
||||
_controller.addListener(_onTextChanged);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_focusNode.requestFocus();
|
||||
@@ -396,16 +467,18 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
|
||||
|
||||
final authProvider = Provider.of<AuthProvider>(context, listen: false);
|
||||
try {
|
||||
final success = await authProvider.twoFactorLogin(
|
||||
otp: code,
|
||||
sessionId: widget.sessionId,
|
||||
email: widget.email,
|
||||
password: widget.password,
|
||||
rememberMe: widget.rememberMe,
|
||||
).timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw Exception('请求超时'),
|
||||
);
|
||||
final success = await authProvider
|
||||
.twoFactorLogin(
|
||||
otp: code,
|
||||
sessionId: widget.sessionId,
|
||||
email: widget.email,
|
||||
password: widget.password,
|
||||
rememberMe: widget.rememberMe,
|
||||
)
|
||||
.timeout(
|
||||
const Duration(seconds: 5),
|
||||
onTimeout: () => throw Exception('请求超时'),
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
@@ -493,9 +566,7 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
],
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
),
|
||||
@@ -503,7 +574,9 @@ class _TwoFactorDialogState extends State<_TwoFactorDialog>
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _isSubmitting ? null : () => Navigator.of(context).pop(false),
|
||||
onPressed: _isSubmitting
|
||||
? null
|
||||
: () => Navigator.of(context).pop(false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import 'package:cloudreve4_flutter/data/models/login_config_model.dart';
|
||||
import 'package:cloudreve4_flutter/presentation/widgets/desktop_constrained.dart';
|
||||
import 'package:cloudreve4_flutter/services/captcha_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:lucide_icons/lucide_icons.dart';
|
||||
import '../../../core/validators/string_validator.dart';
|
||||
import '../../../services/auth_service.dart';
|
||||
import '../../../services/server_service.dart';
|
||||
import '../../widgets/toast_helper.dart';
|
||||
|
||||
class RegisterPage extends StatefulWidget {
|
||||
const RegisterPage({super.key});
|
||||
final LoginConfigModel loginConfig;
|
||||
|
||||
const RegisterPage({super.key, this.loginConfig = const LoginConfigModel()});
|
||||
|
||||
@override
|
||||
State<RegisterPage> createState() => _RegisterPageState();
|
||||
@@ -22,6 +27,21 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.loginConfig.regCaptcha) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
final server = ServerService.instance.currentServer;
|
||||
if (server != null) {
|
||||
CaptchaService.instance.loadCaptcha(server.baseUrl).then((_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_emailController.dispose();
|
||||
@@ -38,16 +58,28 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
return;
|
||||
}
|
||||
|
||||
final captcha = CaptchaService.instance;
|
||||
if (widget.loginConfig.regCaptcha && !captcha.isWebCaptchaVerified) {
|
||||
ToastHelper.failure('请先完成人机验证');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final captchaParams = widget.loginConfig.regCaptcha
|
||||
? captcha.getCaptchaParams()
|
||||
: <String, String>{};
|
||||
|
||||
final response = await AuthService.instance.signUp(
|
||||
email: _emailController.text.trim(),
|
||||
password: _passwordController.text,
|
||||
language: 'zh-CN',
|
||||
captcha: captchaParams['captcha'],
|
||||
ticket: captchaParams['ticket'],
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
@@ -58,6 +90,10 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
if (widget.loginConfig.regCaptcha) {
|
||||
await captcha.refreshCaptcha();
|
||||
setState(() {});
|
||||
}
|
||||
setState(() => _errorMessage = e.toString());
|
||||
}
|
||||
} finally {
|
||||
@@ -68,6 +104,7 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final captcha = CaptchaService.instance;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('注册')),
|
||||
@@ -109,10 +146,14 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
prefixIcon: const Icon(LucideIcons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword ? LucideIcons.eye : LucideIcons.eyeOff,
|
||||
_obscurePassword
|
||||
? LucideIcons.eye
|
||||
: LucideIcons.eyeOff,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
|
||||
onPressed: () => setState(
|
||||
() => _obscurePassword = !_obscurePassword,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -122,7 +163,9 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
obscureText: _obscureConfirmPassword,
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) return '请确认密码';
|
||||
if (value != _passwordController.text) return '两次输入的密码不一致';
|
||||
if (value != _passwordController.text) {
|
||||
return '两次输入的密码不一致';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
@@ -131,17 +174,29 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
prefixIcon: const Icon(LucideIcons.lock),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscureConfirmPassword ? LucideIcons.eye : LucideIcons.eyeOff,
|
||||
_obscureConfirmPassword
|
||||
? LucideIcons.eye
|
||||
: LucideIcons.eyeOff,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: () => setState(() => _obscureConfirmPassword = !_obscureConfirmPassword),
|
||||
onPressed: () => setState(
|
||||
() => _obscureConfirmPassword =
|
||||
!_obscureConfirmPassword,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.loginConfig.regCaptcha) ...[
|
||||
const SizedBox(height: 16),
|
||||
captcha.buildCaptchaInput(context),
|
||||
],
|
||||
if (_errorMessage != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.errorContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -168,7 +223,9 @@ class _RegisterPageState extends State<RegisterPage> {
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Text('注册'),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user