ops-console v1.0.2 (versionCode 8) 源码入库
This commit is contained in:
509
lib/screens/login/login_page.dart
Normal file
509
lib/screens/login/login_page.dart
Normal file
@@ -0,0 +1,509 @@
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user