ops-console v1.0.2 (versionCode 8) 源码入库

This commit is contained in:
txh
2026-09-03 18:01:05 +08:00
commit 685fff27c8
170 changed files with 53049 additions and 0 deletions

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