510 lines
21 KiB
Dart
510 lines
21 KiB
Dart
|
|
import 'dart:typed_data';
|
|||
|
|
|
|||
|
|
import 'package:flutter/material.dart';
|
|||
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||
|
|
|
|||
|
|
import '../../providers/auth_provider.dart';
|
|||
|
|
import '../../app/theme.dart';
|
|||
|
|
import '../../services/auth_service.dart';
|
|||
|
|
import '../../utils/constants.dart';
|
|||
|
|
import '../../utils/credential_storage.dart';
|
|||
|
|
import '../../utils/i10n.dart';
|
|||
|
|
import '../../config/app_config.dart';
|
|||
|
|
import '../../widgets/cloud_card.dart';
|
|||
|
|
|
|||
|
|
/// 登录页。
|
|||
|
|
class LoginPage extends ConsumerStatefulWidget {
|
|||
|
|
const LoginPage({super.key});
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
ConsumerState<LoginPage> createState() => _LoginPageState();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
|||
|
|
final _formKey = GlobalKey<FormState>();
|
|||
|
|
final _accountController = TextEditingController();
|
|||
|
|
final _passwordController = TextEditingController();
|
|||
|
|
final _captchaController = TextEditingController();
|
|||
|
|
bool _obscurePassword = true;
|
|||
|
|
|
|||
|
|
/// 是否记住密码。
|
|||
|
|
bool _rememberPassword = false;
|
|||
|
|
|
|||
|
|
/// 当前验证码图片数据。
|
|||
|
|
Uint8List? _captchaBytes;
|
|||
|
|
|
|||
|
|
/// 当前验证码 UUID,登录时必须原样回传。
|
|||
|
|
String _captchaUuid = '';
|
|||
|
|
|
|||
|
|
/// 是否正在加载验证码。
|
|||
|
|
bool _captchaLoading = false;
|
|||
|
|
|
|||
|
|
/// 验证码加载/刷新错误提示。
|
|||
|
|
String? _captchaError;
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void initState() {
|
|||
|
|
super.initState();
|
|||
|
|
_safeLoadSavedCredentials();
|
|||
|
|
if (AppConfig.enableCaptcha) {
|
|||
|
|
_safeRefreshCaptcha();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 加载已保存的账号密码(如果之前勾选了「记住密码」)。
|
|||
|
|
///
|
|||
|
|
/// 包一层 try-catch:release 模式下任何同步/异步异常都不应让登录页崩成灰屏。
|
|||
|
|
void _safeLoadSavedCredentials() {
|
|||
|
|
try {
|
|||
|
|
_loadSavedCredentials();
|
|||
|
|
} catch (_) {
|
|||
|
|
// 读取本地凭据失败时使用空表单,不阻塞登录。
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 验证码加载包一层 try-catch,失败时通过 _refreshCaptcha 内部的 catch 兜底。
|
|||
|
|
void _safeRefreshCaptcha() {
|
|||
|
|
try {
|
|||
|
|
_refreshCaptcha();
|
|||
|
|
} catch (_) {
|
|||
|
|
// 极端情况下同步抛错,确保页面仍可渲染。
|
|||
|
|
if (mounted) {
|
|||
|
|
setState(() {
|
|||
|
|
_captchaError = '验证码加载失败,请重试';
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/// 加载已保存的账号密码(如果之前勾选了「记住密码」)。
|
|||
|
|
void _loadSavedCredentials() {
|
|||
|
|
final cs = CredentialStorage.instance;
|
|||
|
|
if (cs.rememberPassword) {
|
|||
|
|
_rememberPassword = true;
|
|||
|
|
_accountController.text = cs.savedAccount ?? '';
|
|||
|
|
_passwordController.text = cs.savedPassword ?? '';
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
void dispose() {
|
|||
|
|
_accountController.dispose();
|
|||
|
|
_passwordController.dispose();
|
|||
|
|
_captchaController.dispose();
|
|||
|
|
super.dispose();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _refreshCaptcha() async {
|
|||
|
|
if (!mounted) return;
|
|||
|
|
setState(() {
|
|||
|
|
_captchaLoading = true;
|
|||
|
|
_captchaError = null;
|
|||
|
|
});
|
|||
|
|
try {
|
|||
|
|
final captcha = await AuthService().fetchCaptcha();
|
|||
|
|
if (!mounted) return;
|
|||
|
|
setState(() {
|
|||
|
|
_captchaUuid = captcha.uuid;
|
|||
|
|
_captchaBytes = Uint8List.fromList(captcha.imageBytes);
|
|||
|
|
_captchaLoading = false;
|
|||
|
|
});
|
|||
|
|
} on AuthException catch (e) {
|
|||
|
|
if (!mounted) return;
|
|||
|
|
setState(() {
|
|||
|
|
_captchaError = e.message;
|
|||
|
|
_captchaLoading = false;
|
|||
|
|
});
|
|||
|
|
} catch (e) {
|
|||
|
|
if (!mounted) return;
|
|||
|
|
setState(() {
|
|||
|
|
_captchaError = '验证码加载失败,点击重试';
|
|||
|
|
_captchaLoading = false;
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Future<void> _submit() async {
|
|||
|
|
if (!(_formKey.currentState?.validate() ?? false)) {
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
final success = await ref.read(authProvider.notifier).login(
|
|||
|
|
account: _accountController.text.trim(),
|
|||
|
|
password: _passwordController.text,
|
|||
|
|
captcha: _captchaController.text.trim(),
|
|||
|
|
uuid: _captchaUuid,
|
|||
|
|
rememberPassword: _rememberPassword,
|
|||
|
|
);
|
|||
|
|
if (!success && mounted && AppConfig.enableCaptcha) {
|
|||
|
|
// 登录失败后刷新验证码,并清空输入。
|
|||
|
|
_captchaController.clear();
|
|||
|
|
await _refreshCaptcha();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@override
|
|||
|
|
Widget build(BuildContext context) {
|
|||
|
|
final t = AppLocalizations.of(context);
|
|||
|
|
final authState = ref.watch(authProvider);
|
|||
|
|
// 跟随主题:原有硬编码深色是为了规避 M3 动态取色;现改为显式 ColorScheme,
|
|||
|
|
// 直接取 token,浅色模式即为暖白品牌主场(与 logo 橙白一致)。
|
|||
|
|
final scheme = Theme.of(context).colorScheme;
|
|||
|
|
final inputFillColor = scheme.surfaceContainerHighest;
|
|||
|
|
final inputBorderColor = scheme.outline;
|
|||
|
|
final inputTextColor = scheme.onSurface;
|
|||
|
|
final inputLabelColor = scheme.onSurfaceVariant;
|
|||
|
|
final cardColor = scheme.surfaceContainerLow;
|
|||
|
|
|
|||
|
|
return Scaffold(
|
|||
|
|
backgroundColor: scheme.surface,
|
|||
|
|
body: Container(
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
// 浅色模式铺 logo 橙→金渐变(品牌渗透);深色模式保持暖黑 surface。
|
|||
|
|
gradient: scheme.brightness == Brightness.light
|
|||
|
|
? AppBrand.cloudGradient
|
|||
|
|
: null,
|
|||
|
|
color: scheme.brightness == Brightness.light ? null : scheme.surface,
|
|||
|
|
),
|
|||
|
|
child: SafeArea(
|
|||
|
|
child: Center(
|
|||
|
|
child: SingleChildScrollView(
|
|||
|
|
padding: const EdgeInsets.all(24),
|
|||
|
|
child: ConstrainedBox(
|
|||
|
|
constraints: const BoxConstraints(maxWidth: 420),
|
|||
|
|
child: Card(
|
|||
|
|
color: cardColor,
|
|||
|
|
elevation: 4,
|
|||
|
|
shape: RoundedRectangleBorder(
|
|||
|
|
borderRadius: BorderRadius.circular(16),
|
|||
|
|
),
|
|||
|
|
child: Padding(
|
|||
|
|
padding: const EdgeInsets.all(24),
|
|||
|
|
child: Form(
|
|||
|
|
key: _formKey,
|
|||
|
|
child: Column(
|
|||
|
|
mainAxisSize: MainAxisSize.min,
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|||
|
|
children: [
|
|||
|
|
Image.asset(
|
|||
|
|
'assets/images/logo.png',
|
|||
|
|
width: 112,
|
|||
|
|
height: 112,
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
CloudCard(
|
|||
|
|
child: Row(
|
|||
|
|
children: [
|
|||
|
|
const Icon(Icons.cloud, size: 36),
|
|||
|
|
const SizedBox(width: 14),
|
|||
|
|
Expanded(
|
|||
|
|
child: Column(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|||
|
|
children: [
|
|||
|
|
Text(
|
|||
|
|
AppConstants.appName,
|
|||
|
|
style: const TextStyle(
|
|||
|
|
fontSize: 20,
|
|||
|
|
fontWeight: FontWeight.bold,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 4),
|
|||
|
|
Text(
|
|||
|
|
t.appSubtitle,
|
|||
|
|
style: const TextStyle(fontSize: 13),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 32),
|
|||
|
|
TextFormField(
|
|||
|
|
controller: _accountController,
|
|||
|
|
style: TextStyle(color: inputTextColor),
|
|||
|
|
decoration: InputDecoration(
|
|||
|
|
labelText: '账号',
|
|||
|
|
labelStyle: TextStyle(color: inputLabelColor),
|
|||
|
|
prefixIconColor: inputLabelColor,
|
|||
|
|
filled: true,
|
|||
|
|
fillColor: inputFillColor,
|
|||
|
|
border: OutlineInputBorder(
|
|||
|
|
borderSide: BorderSide(color: inputBorderColor),
|
|||
|
|
),
|
|||
|
|
enabledBorder: OutlineInputBorder(
|
|||
|
|
borderSide: BorderSide(color: inputBorderColor),
|
|||
|
|
),
|
|||
|
|
focusedBorder: OutlineInputBorder(
|
|||
|
|
borderSide: BorderSide(color: scheme.primary),
|
|||
|
|
),
|
|||
|
|
prefixIcon: const Icon(Icons.person_outline),
|
|||
|
|
),
|
|||
|
|
validator: (value) =>
|
|||
|
|
(value == null || value.trim().isEmpty)
|
|||
|
|
? '请输入账号'
|
|||
|
|
: null,
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
TextFormField(
|
|||
|
|
controller: _passwordController,
|
|||
|
|
obscureText: _obscurePassword,
|
|||
|
|
style: TextStyle(color: inputTextColor),
|
|||
|
|
decoration: InputDecoration(
|
|||
|
|
labelText: '密码',
|
|||
|
|
labelStyle: TextStyle(color: inputLabelColor),
|
|||
|
|
prefixIconColor: inputLabelColor,
|
|||
|
|
suffixIconColor: inputLabelColor,
|
|||
|
|
filled: true,
|
|||
|
|
fillColor: inputFillColor,
|
|||
|
|
border: OutlineInputBorder(
|
|||
|
|
borderSide: BorderSide(color: inputBorderColor),
|
|||
|
|
),
|
|||
|
|
enabledBorder: OutlineInputBorder(
|
|||
|
|
borderSide: BorderSide(color: inputBorderColor),
|
|||
|
|
),
|
|||
|
|
focusedBorder: OutlineInputBorder(
|
|||
|
|
borderSide: BorderSide(color: scheme.primary),
|
|||
|
|
),
|
|||
|
|
prefixIcon: const Icon(Icons.lock_outline),
|
|||
|
|
suffixIcon: IconButton(
|
|||
|
|
icon: Icon(
|
|||
|
|
_obscurePassword
|
|||
|
|
? Icons.visibility_off
|
|||
|
|
: Icons.visibility,
|
|||
|
|
color: inputLabelColor,
|
|||
|
|
),
|
|||
|
|
onPressed: () => setState(
|
|||
|
|
() => _obscurePassword = !_obscurePassword,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
validator: (value) =>
|
|||
|
|
(value == null || value.isEmpty) ? '请输入密码' : null,
|
|||
|
|
onFieldSubmitted: (_) => _submit(),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
if (AppConfig.enableCaptcha) ...[
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
// 验证码区域
|
|||
|
|
Row(
|
|||
|
|
crossAxisAlignment: CrossAxisAlignment.center,
|
|||
|
|
children: [
|
|||
|
|
Expanded(
|
|||
|
|
flex: 5,
|
|||
|
|
child: TextFormField(
|
|||
|
|
controller: _captchaController,
|
|||
|
|
style: TextStyle(color: inputTextColor),
|
|||
|
|
decoration: InputDecoration(
|
|||
|
|
labelText: '验证码',
|
|||
|
|
labelStyle:
|
|||
|
|
TextStyle(color: inputLabelColor),
|
|||
|
|
prefixIconColor: inputLabelColor,
|
|||
|
|
filled: true,
|
|||
|
|
fillColor: inputFillColor,
|
|||
|
|
border: OutlineInputBorder(
|
|||
|
|
borderSide:
|
|||
|
|
BorderSide(color: inputBorderColor),
|
|||
|
|
),
|
|||
|
|
enabledBorder: OutlineInputBorder(
|
|||
|
|
borderSide:
|
|||
|
|
BorderSide(color: inputBorderColor),
|
|||
|
|
),
|
|||
|
|
focusedBorder: OutlineInputBorder(
|
|||
|
|
borderSide:
|
|||
|
|
BorderSide(color: scheme.primary),
|
|||
|
|
),
|
|||
|
|
prefixIcon: const Icon(Icons.security_outlined),
|
|||
|
|
),
|
|||
|
|
textInputAction: TextInputAction.done,
|
|||
|
|
validator: (value) =>
|
|||
|
|
(value == null || value.trim().isEmpty)
|
|||
|
|
? '请输入验证码'
|
|||
|
|
: null,
|
|||
|
|
onFieldSubmitted: (_) => _submit(),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
const SizedBox(width: 12),
|
|||
|
|
Expanded(
|
|||
|
|
flex: 3,
|
|||
|
|
child: SizedBox(
|
|||
|
|
height: 52,
|
|||
|
|
child: InkWell(
|
|||
|
|
onTap: _captchaLoading
|
|||
|
|
? null
|
|||
|
|
: _refreshCaptcha,
|
|||
|
|
borderRadius: BorderRadius.circular(12),
|
|||
|
|
child: Container(
|
|||
|
|
decoration: BoxDecoration(
|
|||
|
|
color: inputFillColor,
|
|||
|
|
borderRadius: BorderRadius.circular(12),
|
|||
|
|
border: Border.all(
|
|||
|
|
color: inputBorderColor,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
alignment: Alignment.center,
|
|||
|
|
child: _buildCaptchaImage(),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
if (_captchaError != null) ...[
|
|||
|
|
const SizedBox(height: 8),
|
|||
|
|
Text(
|
|||
|
|
_captchaError!,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: scheme.error,
|
|||
|
|
fontSize: 12,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
],
|
|||
|
|
if (authState.errorMessage != null) ...[
|
|||
|
|
const SizedBox(height: 12),
|
|||
|
|
Text(
|
|||
|
|
authState.errorMessage!,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: scheme.error,
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
const SizedBox(height: 12),
|
|||
|
|
// 记住密码
|
|||
|
|
Row(
|
|||
|
|
children: [
|
|||
|
|
Checkbox(
|
|||
|
|
value: _rememberPassword,
|
|||
|
|
onChanged: (v) => setState(
|
|||
|
|
() => _rememberPassword = v ?? false,
|
|||
|
|
),
|
|||
|
|
fillColor: WidgetStateProperty.resolveWith(
|
|||
|
|
(states) => states.contains(WidgetState.selected)
|
|||
|
|
? scheme.primary
|
|||
|
|
: inputBorderColor,
|
|||
|
|
),
|
|||
|
|
checkColor: scheme.onPrimary,
|
|||
|
|
),
|
|||
|
|
GestureDetector(
|
|||
|
|
onTap: () => setState(
|
|||
|
|
() => _rememberPassword = !_rememberPassword,
|
|||
|
|
),
|
|||
|
|
child: Text(
|
|||
|
|
'记住密码',
|
|||
|
|
style: TextStyle(color: inputTextColor),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 12),
|
|||
|
|
FilledButton(
|
|||
|
|
onPressed: authState.isLoading ? null : _submit,
|
|||
|
|
style: FilledButton.styleFrom(
|
|||
|
|
backgroundColor: scheme.primary,
|
|||
|
|
foregroundColor: scheme.onPrimary,
|
|||
|
|
disabledBackgroundColor:
|
|||
|
|
scheme.onSurface.withOpacity(0.12),
|
|||
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|||
|
|
),
|
|||
|
|
child: authState.isLoading
|
|||
|
|
? SizedBox(
|
|||
|
|
width: 20,
|
|||
|
|
height: 20,
|
|||
|
|
child: CircularProgressIndicator(
|
|||
|
|
strokeWidth: 2,
|
|||
|
|
color: scheme.onPrimary,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
: const Text('登 录'),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 16),
|
|||
|
|
// 仅 mock 模式显示演示账号;真实模式下提示无意义
|
|||
|
|
if (AppConstants.useMock)
|
|||
|
|
Text(
|
|||
|
|
'演示账号:${AppConstants.mockAccount} / '
|
|||
|
|
'${AppConstants.mockPassword}',
|
|||
|
|
textAlign: TextAlign.center,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: scheme.onSurfaceVariant, fontSize: 12),
|
|||
|
|
),
|
|||
|
|
const SizedBox(height: 8),
|
|||
|
|
Text(
|
|||
|
|
'v6-fix68',
|
|||
|
|
textAlign: TextAlign.center,
|
|||
|
|
style: TextStyle(
|
|||
|
|
color: scheme.onSurfaceVariant.withOpacity(0.5),
|
|||
|
|
fontSize: 10),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
Widget _buildCaptchaImage() {
|
|||
|
|
if (_captchaLoading) {
|
|||
|
|
return const SizedBox(
|
|||
|
|
width: 18,
|
|||
|
|
height: 18,
|
|||
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
final bytes = _captchaBytes;
|
|||
|
|
if (bytes != null && bytes.isNotEmpty) {
|
|||
|
|
return ClipRRect(
|
|||
|
|
borderRadius: BorderRadius.circular(12),
|
|||
|
|
child: Image.memory(
|
|||
|
|
bytes,
|
|||
|
|
fit: BoxFit.contain,
|
|||
|
|
width: double.infinity,
|
|||
|
|
height: double.infinity,
|
|||
|
|
errorBuilder: (context, error, stackTrace) {
|
|||
|
|
return const Column(
|
|||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|||
|
|
children: [
|
|||
|
|
Icon(
|
|||
|
|
Icons.broken_image_outlined,
|
|||
|
|
size: 20,
|
|||
|
|
color: Color(0xFFB0B0B0),
|
|||
|
|
),
|
|||
|
|
SizedBox(height: 2),
|
|||
|
|
Text(
|
|||
|
|
'点击刷新',
|
|||
|
|
style: TextStyle(
|
|||
|
|
fontSize: 10,
|
|||
|
|
color: Color(0xFFB0B0B0),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
},
|
|||
|
|
),
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
return const Column(
|
|||
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|||
|
|
children: [
|
|||
|
|
Icon(
|
|||
|
|
Icons.refresh,
|
|||
|
|
size: 20,
|
|||
|
|
color: Color(0xFFB0B0B0),
|
|||
|
|
),
|
|||
|
|
SizedBox(height: 2),
|
|||
|
|
Text(
|
|||
|
|
'点击刷新',
|
|||
|
|
style: TextStyle(
|
|||
|
|
fontSize: 10,
|
|||
|
|
color: Color(0xFFB0B0B0),
|
|||
|
|
),
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|