Files
Ichino/lib/pages/login_page.dart
T
Cookiues 44c4a1daae feat: 连接信息恢复的会话在 isLogin 时直接开放票据与风险功能
- 恢复时除了 GetUserDataApi 再拉一次 GetUserPreviewApi:服务器说 isLogin 为 true
  就把这次会话当已登录,_gameLogin 落地,票据与风险页按「登录后 60 秒冷却」计时
  (loginDateTime 取恢复当下),不必再发一次 UserLoginApi
- GetUserPreviewApi 的响应里没有 loginId,而 UpsertUserAll 要拿它当 playlogId,
  所以导出连接信息时把本机的 loginId 一起带过去,恢复端优先用这个值,
  拉不到才退回 preview 里的字段(多半是 0)
- 只有 Cookie、preview 拉不到、或服务器说没在登录中时仍按只读处理,
  提示横幅与 SnackBar 文案区分「可写」和「只能读」两种恢复结果
- HomePage 的恢复入口收成 restored: SessionRestoreResult?,把 preview 与
  userData 一起当种子,省掉两次重复请求
2026-09-29 13:47:03 +08:00

342 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 '../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 _qrCode = '';
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;
_qrCode = SessionModel.extractQRCode(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) {
_qrController.text = result;
setState(() {
_rawInput = result.trim();
_qrCode = SessionModel.extractQRCode(result);
_share = SessionShare.tryDecode(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 (_qrCode.isEmpty) return;
// 令牌是粘贴内容的后 64 位,前缀可能在截取之后就没了,所以要拿原文判断;
// 认不出来时必须给提示,否则点了按钮一声不响,像是坏了。
if (!_rawInput.contains('SGWCMAID')) {
context.showSnack(AppStrings.qrTokenUnrecognized);
return;
}
setState(() => _loading = true);
try {
final result = await SessionModel.instance.loginWithQr(_qrCode);
if (!mounted) return;
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})',
),
),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${AppStrings.requestFailed}: $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}: $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)
: (_qrCode.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,
),
],
),
),
),
),
);
}
}