- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析 - 主页与音乐风险页拆成组件,风险功能共用一套运行框架 - 修复设置页作为主页页签保存时弹掉根路由导致的黑屏 - 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正, 补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对 BoringSSL 的 TLS 指纹拦截(证书校验保持开启) - 补开发预览数据与测试
337 lines
11 KiB
Dart
337 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
||
|
||
import '../config/responsive.dart';
|
||
import '../config/strings.dart';
|
||
import '../models/session_model.dart';
|
||
import '../models/session_share.dart';
|
||
import '../services/file_picker_service.dart';
|
||
import '../services/qr_service.dart';
|
||
import '../services/title_api_service.dart';
|
||
import '../widgets/app_notice.dart';
|
||
import 'home_page.dart';
|
||
import 'settings_page.dart';
|
||
|
||
/// 登录页:粘贴 / 识别 QR 令牌,走 Aime 登录后进入主页。
|
||
///
|
||
/// 也接受主页导出的「连接信息」(实验性):那是一串 Base64,直接续上原有会话。
|
||
class LoginPage extends StatefulWidget {
|
||
const LoginPage({super.key});
|
||
|
||
@override
|
||
State<LoginPage> createState() => _LoginPageState();
|
||
}
|
||
|
||
class _LoginPageState extends State<LoginPage> {
|
||
final _qrController = TextEditingController();
|
||
String _rawInput = '';
|
||
SessionShare? _share;
|
||
bool _loading = false;
|
||
bool _forcePreviewApi = false;
|
||
|
||
@override
|
||
void dispose() {
|
||
_qrController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _onQRContentChanged(String value) {
|
||
final text = value.trim();
|
||
setState(() {
|
||
_rawInput = text;
|
||
_share = SessionShare.tryDecode(text);
|
||
});
|
||
}
|
||
|
||
Future<void> _onUploadQR() async {
|
||
try {
|
||
final bytes = await pickImageBytes();
|
||
if (bytes == null) return;
|
||
|
||
final result = await decodeQRFromBytes(bytes);
|
||
|
||
if (result != null && result.isNotEmpty) {
|
||
if (!mounted) return;
|
||
_qrController.text = result;
|
||
_onQRContentChanged(result);
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text(AppStrings.qrScanSuccess)),
|
||
);
|
||
}
|
||
} else {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(content: Text(AppStrings.qrScanFailed)),
|
||
);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('${AppStrings.qrScanError}: $e')),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _onLogin() async {
|
||
if (_rawInput.isEmpty) return;
|
||
setState(() => _loading = true);
|
||
|
||
try {
|
||
final result = await SessionModel.instance.loginWithQr(_rawInput);
|
||
|
||
if (!mounted) return;
|
||
|
||
if (result.success) {
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (_) => HomePage(
|
||
userId: result.userId,
|
||
token: result.token,
|
||
forcePreviewApi: _forcePreviewApi,
|
||
),
|
||
),
|
||
);
|
||
} else {
|
||
context.showSnack(
|
||
'${AppStrings.loginFailed}:'
|
||
'${result.message ?? 'errorID=${result.errorId}'}',
|
||
);
|
||
}
|
||
} on FormatException catch (e) {
|
||
// 令牌格式不对 / 没配 Keychip:登录请求根本没发出去,要说清楚是哪一种。
|
||
if (mounted) {
|
||
context.showSnack('${AppStrings.qrTokenUnrecognized}:${e.message}');
|
||
}
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text('${AppStrings.requestFailed}: ${describeApiError(e)}'),
|
||
),
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) {
|
||
setState(() => _loading = false);
|
||
}
|
||
}
|
||
}
|
||
|
||
String _restoreMessage(SessionRestoreResult result) {
|
||
if (!result.reusedCookie) return AppStrings.sessionRestoreToken;
|
||
return result.loggedIn
|
||
? AppStrings.sessionRestoreCookie
|
||
: AppStrings.sessionRestoreReadOnly;
|
||
}
|
||
|
||
/// 实验性:用主页导出的连接信息恢复会话。带可用 JSESSIONID 就直接续上那次登录,
|
||
/// 否则退回用令牌重新登录。
|
||
Future<void> _onRestoreSession() async {
|
||
final share = _share;
|
||
if (share == null) return;
|
||
|
||
setState(() => _loading = true);
|
||
try {
|
||
final result = await SessionModel.instance.restoreFromShare(share);
|
||
if (!mounted) return;
|
||
context.showSnack(_restoreMessage(result));
|
||
Navigator.of(context).push(
|
||
MaterialPageRoute(
|
||
builder: (_) => HomePage(
|
||
userId: share.userId,
|
||
token: share.token,
|
||
restored: result,
|
||
),
|
||
),
|
||
);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
context.showSnack(
|
||
'${AppStrings.sessionRestoreFailed}: ${describeApiError(e)}',
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _loading = false);
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final theme = Theme.of(context);
|
||
final isRestore = _share != null;
|
||
|
||
return Scaffold(
|
||
appBar: AppBar(
|
||
title: const Text(AppStrings.appTitle),
|
||
actions: [
|
||
IconButton(
|
||
icon: const Icon(Icons.settings),
|
||
tooltip: AppStrings.settingsTooltip,
|
||
onPressed: () {
|
||
Navigator.of(
|
||
context,
|
||
).push(MaterialPageRoute(builder: (_) => const SettingsPage()));
|
||
},
|
||
),
|
||
],
|
||
),
|
||
body: Center(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 48),
|
||
child: responsiveBody(
|
||
context,
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
Icons.qr_code_scanner,
|
||
size: 64,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
AppStrings.appTitle,
|
||
style: theme.textTheme.headlineMedium?.copyWith(
|
||
fontWeight: FontWeight.bold,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
),
|
||
const SizedBox(height: 48),
|
||
|
||
Card(
|
||
elevation: 0,
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(16),
|
||
side: BorderSide(
|
||
color: theme.colorScheme.outline.withValues(alpha: 0.3),
|
||
),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Icon(
|
||
Icons.key,
|
||
size: 18,
|
||
color: theme.colorScheme.primary,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
AppStrings.qrCodeToken,
|
||
style: theme.textTheme.labelLarge?.copyWith(
|
||
color: theme.colorScheme.primary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: _qrController,
|
||
maxLines: 3,
|
||
onChanged: _onQRContentChanged,
|
||
style: theme.textTheme.bodyMedium?.copyWith(
|
||
fontFamily: 'monospace',
|
||
letterSpacing: 0.5,
|
||
),
|
||
decoration: InputDecoration(
|
||
hintText: AppStrings.qrHint,
|
||
border: OutlineInputBorder(
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
contentPadding: const EdgeInsets.all(14),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
|
||
const SizedBox(height: 28),
|
||
|
||
if (isRestore)
|
||
const AppNotice(
|
||
AppStrings.sessionShareDetected,
|
||
icon: Icons.science_outlined,
|
||
),
|
||
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: OutlinedButton.icon(
|
||
onPressed: _loading ? null : _onUploadQR,
|
||
icon: const Icon(Icons.image, size: 20),
|
||
label: const Text(AppStrings.uploadQR),
|
||
style: OutlinedButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: FilledButton.icon(
|
||
onPressed: isRestore
|
||
? (_loading ? null : _onRestoreSession)
|
||
: (_rawInput.isNotEmpty && !_loading)
|
||
? _onLogin
|
||
: null,
|
||
icon: _loading
|
||
? const SizedBox(
|
||
width: 20,
|
||
height: 20,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: Colors.white,
|
||
),
|
||
)
|
||
: Icon(
|
||
isRestore ? Icons.link : Icons.arrow_forward,
|
||
size: 20,
|
||
),
|
||
label: Text(
|
||
isRestore
|
||
? (_loading
|
||
? AppStrings.restoringSession
|
||
: AppStrings.restoreSession)
|
||
: (_loading
|
||
? AppStrings.loggingIn
|
||
: AppStrings.login),
|
||
),
|
||
style: FilledButton.styleFrom(
|
||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
if (!isRestore)
|
||
CheckboxListTile(
|
||
value: _forcePreviewApi,
|
||
onChanged: (v) =>
|
||
setState(() => _forcePreviewApi = v ?? false),
|
||
title: const Text(AppStrings.forcePreviewApi),
|
||
controlAffinity: ListTileControlAffinity.leading,
|
||
contentPadding: EdgeInsets.zero,
|
||
dense: true,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|