feat: 实验性连接信息导出与会话恢复,重写关于页

- SessionShare:把 userId / JSESSIONID / token 打成 Base64(JSON),Cookie 只保留
  JSESSIONID 一项,粘贴时容忍换行与空白,QR 令牌与乱码解不出来
- 主页底部新增「导出连接信息」卡片,标记实验性:内容未加密,字符串在手即可操作账号
- 登录页识别到连接信息时把登录按钮换成「恢复会话」,不必再扫 QR 令牌
- 恢复优先沿用导入的 JSESSIONID(不重发 UserLoginApi,免得顶掉机台上的登录),
  Cookie 失效才退回用 token 登录;验证 Cookie 时顺手拉到的 GetUserDataApi 结果
  直接交给主页展示,省一次请求
- 仅凭 Cookie 恢复的会话没有 loginId / loginDateTime,_gameLogin 保持 null,
  票据与风险页照旧提示「尚未登录游戏服务器」,主页顶部给出说明横幅
- 关于页改用 AppCard / SectionTitle 脚手架,补基本信息与风险提示,把过时的
  「jsQR (web)」致谢换成原生 MethodChannel(QR 解码早已不走 web 库)
This commit is contained in:
Cookiues committed 2026-09-29 02:43:39 +08:00
1 parent 4dddfdc00c
commit 5d16984e4e
9 files changed
+772 -190

No files matched your search

+112 -79
View File
@@ -3,12 +3,16 @@ 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 '../widgets/app_notice.dart';
import 'home_page.dart';
import 'settings_page.dart';
/// 登录页:粘贴 / 识别 QR 令牌,走 Aime 登录后进入主页。
///
/// 也接受主页导出的「连接信息」(实验性):那是一串 Base64,直接续上原有会话。
class LoginPage extends StatefulWidget {
const LoginPage({super.key});
@@ -19,6 +23,7 @@ class LoginPage extends StatefulWidget {
class _LoginPageState extends State<LoginPage> {
final _qrController = TextEditingController();
String _qrCode = '';
SessionShare? _share;
bool _loading = false;
bool _forcePreviewApi = false;
@@ -29,8 +34,10 @@ class _LoginPageState extends State<LoginPage> {
}
void _onQRContentChanged(String value) {
final text = value.trim();
setState(() {
_qrCode = SessionModel.extractQRCode(value.trim());
_qrCode = SessionModel.extractQRCode(text);
_share = SessionShare.tryDecode(text);
});
}
@@ -45,6 +52,7 @@ class _LoginPageState extends State<LoginPage> {
_qrController.text = result;
setState(() {
_qrCode = SessionModel.extractQRCode(result);
_share = SessionShare.tryDecode(result);
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@@ -70,44 +78,85 @@ class _LoginPageState extends State<LoginPage> {
Future<void> _onLogin() async {
if (_qrCode.isEmpty) return;
setState(() => _loading = true);
if (_qrCode.startsWith("SGWCMAID")) {
setState(() => _loading = true);
try {
final result = await SessionModel.instance.loginWithQr(_qrCode);
try {
final result = await SessionModel.instance.loginWithQr(_qrCode);
if (!mounted) return;
if (!mounted) return;
if (result.success) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => HomePage(
userId: result.userId,
token: result.token,
forcePreviewApi: _forcePreviewApi,
if (result.success) {
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => HomePage(
userId: result.userId,
token: result.token,
forcePreviewApi: _forcePreviewApi,
),
),
),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppStrings.loginFailed} (errorID: ${result.errorId})')),
);
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${AppStrings.loginFailed} (errorID: ${result.errorId})',
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppStrings.requestFailed}: $e')),
);
}
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
}
/// 实验性:用主页导出的连接信息恢复会话。带可用 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(
result.reusedCookie
? AppStrings.sessionRestoreCookie
: AppStrings.sessionRestoreToken,
);
Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => HomePage(
userId: share.userId,
token: share.token,
sessionRestored: true,
initialUserData: result.userData,
),
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppStrings.requestFailed}: $e')),
);
context.showSnack('${AppStrings.sessionRestoreFailed}: $e');
}
} finally {
if (mounted) {
setState(() => _loading = false);
}
if (mounted) setState(() => _loading = false);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isRestore = _share != null;
return Scaffold(
appBar: AppBar(
@@ -117,9 +166,9 @@ class _LoginPageState extends State<LoginPage> {
icon: const Icon(Icons.settings),
tooltip: AppStrings.settingsTooltip,
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const SettingsPage()),
);
Navigator.of(
context,
).push(MaterialPageRoute(builder: (_) => const SettingsPage()));
},
),
],
@@ -145,14 +194,7 @@ class _LoginPageState extends State<LoginPage> {
color: theme.colorScheme.primary,
),
),
const SizedBox(height: 8),
Text(
AppStrings.loginSubtitle,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 40),
const SizedBox(height: 48),
Card(
elevation: 0,
@@ -194,6 +236,7 @@ class _LoginPageState extends State<LoginPage> {
letterSpacing: 0.5,
),
decoration: InputDecoration(
hintText: AppStrings.qrHint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
@@ -205,40 +248,14 @@ class _LoginPageState extends State<LoginPage> {
),
),
if (_qrCode.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppStrings.qrParsedResult,
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 6),
Text(
_qrCode,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: theme.colorScheme.onSurface,
),
),
],
),
),
],
const SizedBox(height: 28),
if (isRestore)
const AppNotice(
AppStrings.sessionShareDetected,
icon: Icons.science_outlined,
),
Row(
children: [
Expanded(
@@ -257,8 +274,11 @@ class _LoginPageState extends State<LoginPage> {
const SizedBox(width: 16),
Expanded(
child: FilledButton.icon(
onPressed:
(_qrCode.isNotEmpty && !_loading) ? _onLogin : null,
onPressed: isRestore
? (_loading ? null : _onRestoreSession)
: (_qrCode.isNotEmpty && !_loading)
? _onLogin
: null,
icon: _loading
? const SizedBox(
width: 20,
@@ -268,8 +288,19 @@ class _LoginPageState extends State<LoginPage> {
color: Colors.white,
),
)
: const Icon(Icons.arrow_forward, size: 20),
label: Text(_loading ? AppStrings.loggingIn : AppStrings.login),
: 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(
@@ -281,14 +312,16 @@ class _LoginPageState extends State<LoginPage> {
],
),
const SizedBox(height: 16),
CheckboxListTile(
value: _forcePreviewApi,
onChanged: (v) => setState(() => _forcePreviewApi = v ?? false),
title: const Text(AppStrings.forcePreviewApi),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
),
if (!isRestore)
CheckboxListTile(
value: _forcePreviewApi,
onChanged: (v) =>
setState(() => _forcePreviewApi = v ?? false),
title: const Text(AppStrings.forcePreviewApi),
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
dense: true,
),
],
),
),