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),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|