diff --git a/README.md b/README.md index 54b1c94..210f76e 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,13 @@ Android / iOS / Windows / macOS / Linux。 | 入口 | 说明 | | --- | --- | -| 主页 | Aime 登录(QR / 令牌)、Rating 详情、入坑信息、游玩统计、状态 | +| 主页 | Aime 登录(QR / 令牌)、Rating 详情、入坑信息、游玩统计、状态、账号备份 | | 票据 | 功能票查询与使用 | | B50 | Best 50 成绩图,可导出 PNG(移植自 Empurple 的 `Best50ImageRenderer`) | | 风险 | 歌曲解锁、收藏品获取、旅行伙伴发放与编组(含等级)、修改总 Rating、添加舞里程、一键跑图、万花筒专区 | | 设置 | Title Server / Auth Server / 机器参数,支持配置导入导出 | | 连接信息(实验性) | 主页导出 uid + JSESSIONID + token 的 Base64 快照,登录页粘贴即可恢复会话;内容未加密,请勿外传 | +| 账号备份 | 主页只读地把服务器上的用户数据汇成一串 JSON(道具、舞里程、游玩统计、旅行伙伴、区域与万花筒进度、Rating/成绩)。全程只发 `GetUser*Api`,不写任何数据;单段读失败不整体作废,失败段列在结果里 | ## 开发 diff --git a/lib/config/strings.dart b/lib/config/strings.dart index 0aa034f..7f20636 100644 --- a/lib/config/strings.dart +++ b/lib/config/strings.dart @@ -37,6 +37,23 @@ class AppStrings { static const connectionShareEmpty = '当前会话既没有 Cookie 也没有令牌,无法导出。'; static const restoreSession = '恢复会话'; static const restoringSession = '恢复中...'; + + // 账号备份:把服务器可读到的用户数据汇成一个 JSON 字符串 + static const accountBackupTitle = '账号备份'; + static const accountBackupDesc = + '只读地把服务器上的用户数据汇成一串 JSON:道具清单、舞里程、游玩统计、' + '旅行伙伴、区域进度、万花筒进度、Rating 与成绩等。全程只发 GetUser* 接口,' + '不会写任何数据。'; + static const accountBackupRun = '生成备份'; + static String accountBackupRunning(String section) => + section.isEmpty ? '备份中...' : '备份中:$section'; + static const accountBackupCopy = '复制到剪贴板'; + static const accountBackupCopied = '备份内容已复制到剪贴板'; + static String accountBackupSize(String size) => '共 $size'; + static String accountBackupPartial(List failures) => + '${failures.length} 段没读到(私服可能未实现该接口),其余照常:' + '${failures.join('、')}'; + static const accountBackupSecurity = '备份含账号标识与进度数据;内容未加密,请勿贴到公开场合。'; static const sessionShareDetected = '已识别为连接信息(实验性功能),点击「恢复会话」直接续上这次登录。'; static const sessionRestoreFailed = '会话恢复失败'; static const sessionRestoreCookie = diff --git a/lib/pages/best50_page.dart b/lib/pages/best50_page.dart index ce36e3e..01d4278 100644 --- a/lib/pages/best50_page.dart +++ b/lib/pages/best50_page.dart @@ -232,30 +232,41 @@ class _Best50PageState extends State { Expanded( child: LayoutBuilder( builder: (context, constraints) { - // 按视口宽度适配:横屏/桌面下海报占满宽度、竖向拖动看全图, - // 而不是被 contain 缩成中间一小块、两侧大片空白。 - // minScale 再允许缩到整张可见,导出仍是原始 1762x1850。 + // 海报要按视口整体缩放一档,进来才能看见完整一张(之前的现象是 + // 「太大且缩不小」)。两处坑: + // 1. InteractiveViewer(constrained:false) 给子节点无界约束, + // 此时 FittedBox 根本不会缩放,必须先给一个有限大小的盒子。 + // 2. InteractiveViewer 没有 initialScale,所以「初始就缩到位」 + // 只能靠把子节点做成 contain 尺寸、minScale 固定 1.0。 + // RepaintBoundary 放在 FittedBox 内侧:它自身的绘制尺寸仍是 + // 1762x1850,导出分辨率不受这里缩放影响。 final fitWidth = constraints.maxWidth / Best50Poster.canvasWidth; - final scaledHeight = Best50Poster.canvasHeight * fitWidth; - final containScale = constraints.maxHeight / scaledHeight; - final minScale = containScale < 1 ? containScale : 1.0; + final fitHeight = + constraints.maxHeight / Best50Poster.canvasHeight; + final contain = fitWidth < fitHeight ? fitWidth : fitHeight; - return InteractiveViewer( - constrained: false, - minScale: minScale, - maxScale: minScale * 6, - boundaryMargin: const EdgeInsets.all(24), - child: FittedBox( - fit: BoxFit.fitWidth, - child: RepaintBoundary( - key: _posterKey, - child: Best50Poster( - userName: widget.userName, - iconId: widget.iconId, - playerRating: widget.playerRating, - best35: best35, - best15: best15, + return Center( + child: InteractiveViewer( + constrained: false, + minScale: 1.0, + maxScale: 10.0, + boundaryMargin: const EdgeInsets.all(24), + child: SizedBox( + width: Best50Poster.canvasWidth * contain, + height: Best50Poster.canvasHeight * contain, + child: FittedBox( + fit: BoxFit.fill, + child: RepaintBoundary( + key: _posterKey, + child: Best50Poster( + userName: widget.userName, + iconId: widget.iconId, + playerRating: widget.playerRating, + best35: best35, + best15: best15, + ), + ), ), ), ), diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 0febdec..90a785c 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -10,6 +10,7 @@ import '../models/session_model.dart'; import '../models/user_data.dart'; import '../models/user_preview.dart'; import '../services/title_api_service.dart'; +import '../widgets/account_backup_card.dart'; import '../widgets/app_notice.dart'; import '../widgets/connection_share_card.dart'; import 'about_page.dart'; @@ -539,6 +540,8 @@ class _HomePageState extends State { ), const SizedBox(height: 12), ConnectionShareCard(userId: widget.userId, token: widget.token), + const SizedBox(height: 12), + AccountBackupCard(userId: widget.userId, cookies: _session.cookies), if (TitleApiService.lastRawResponse != null) ...[ const SizedBox(height: 12), _DebugRawJsonCard( diff --git a/lib/services/account_backup_service.dart b/lib/services/account_backup_service.dart new file mode 100644 index 0000000..726e9c1 --- /dev/null +++ b/lib/services/account_backup_service.dart @@ -0,0 +1,117 @@ +import 'dart:convert'; + +import 'title_api_service.dart'; + +/// 一次账号备份的结果。 +class AccountBackup { + static const int formatVersion = 1; + + final Map document; + + /// 读失败的段名(含原因)。备份不因为单段失败而整体作废—— + /// 私服不一定实现了所有 GetUser* 接口。 + final List failedSections; + + const AccountBackup({required this.document, required this.failedSections}); + + /// 备份正文就是这一长串 JSON。 + String encode() => const JsonEncoder.withIndent(' ').convert(document); + + int get byteLength => utf8.encode(encode()).length; + + bool get isComplete => failedSections.isEmpty; +} + +/// 把已经读到的各段数据拼成备份文档。与取数分开,方便单测。 +Map buildBackupDocument({ + required int userId, + required Map sections, + DateTime? exportedAt, +}) { + return { + 'backupFormat': AccountBackup.formatVersion, + 'exportedAt': (exportedAt ?? DateTime.now()).toUtc().toIso8601String(), + 'userId': userId, + 'data': sections, + }; +} + +/// 备份要读的道具类别,对应 `Net.VO.Mai2.ItemKind`。 +/// +/// 只列客户端下行真会读的那些:`StateUserDownload.cs:49-52` 逐个 kind 拉 +/// `GetUserItemApi`,而 itemKind 15(KaleidxScopeKey)客户端从不读 +/// (钥匙是 `userKaleidxScopeList[].isKeyFound`),所以不在此列。 +const List kBackupItemKinds = [1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12]; + +/// 读取用户数据并汇总成备份。 +/// +/// 每段独立容错:某私服没实现某个接口时,其余部分照样能备出来, +/// 失败原因记在 [AccountBackup.failedSections] 里给界面显示。 +class AccountBackupService { + final TitleApiService _api; + final int userId; + + const AccountBackupService(this._api, {required this.userId}); + + /// [onSection] 每开始读一段回调一次,供界面显示进度。 + Future run({void Function(String section)? onSection}) async { + final sections = {}; + final failed = []; + + Future gather(String name, Future Function() read) async { + onSection?.call(name); + try { + final value = await read(); + sections[name] = value; + } catch (e) { + failed.add('$name: $e'); + } + } + + // 单段一个请求:并发跑,且一段失败不牵连其他段。 + await Future.wait([ + gather('userData', () => _api.getUserData(userId)), + gather('userExtend', () => _api.getUserExtend(userId)), + gather('userOption', () => _api.getUserOption(userId)), + gather('userRating', () => _api.getUserRating(userId)), + gather('userCharge', () => _api.getUserCharge(userId)), + gather('userActivity', () => _api.getUserActivity(userId)), + gather('userMissionData', () => _api.getUserMissionData(userId)), + gather( + 'userCharacterList', + () async => [ + for (final c in await _api.getUserCharacters(userId)) c.toWireJson(), + ], + ), + gather('userMapList', () => _api.getUserMaps(userId)), + gather( + 'userKaleidxScopeList', + () async => [ + for (final s in await _api.getUserKaleidxScopes(userId)) + s.toWireJson(), + ], + ), + ]); + + // userItemList 是按 itemKind 分页拉的,按 kind 分组存放, + // 读回时一眼能看出哪类是空的、哪类根本没读到。 + final items = {}; + await Future.wait([ + for (final kind in kBackupItemKinds) + () async { + onSection?.call('itemKind $kind'); + try { + items['$kind'] = await _api.getUserItems(userId, kind); + } catch (e) { + failed.add('userItemList/$kind: $e'); + } + }(), + ]); + sections['userItemList'] = items; + + return AccountBackup( + document: buildBackupDocument(userId: userId, sections: sections), + failedSections: failed, + ); + } +} diff --git a/lib/widgets/account_backup_card.dart b/lib/widgets/account_backup_card.dart new file mode 100644 index 0000000..1bd8e17 --- /dev/null +++ b/lib/widgets/account_backup_card.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart' show Clipboard, ClipboardData; + +import '../config/strings.dart'; +import '../services/account_backup_service.dart'; +import '../services/title_api_service.dart'; +import 'app_card.dart'; +import 'app_notice.dart'; + +/// 账号备份:把服务器能读到的用户数据汇成一长串 JSON,可复制到剪贴板。 +/// +/// 只读不写——全程只发 `GetUser*Api`,不会碰 UpsertUserAllApi,所以和 +/// 「风险」页那些伪造包相比是安全操作。 +class AccountBackupCard extends StatefulWidget { + final int userId; + final String? cookies; + + const AccountBackupCard({super.key, required this.userId, this.cookies}); + + @override + State createState() => _AccountBackupCardState(); +} + +class _AccountBackupCardState extends State { + bool _running = false; + String _progress = ''; + AccountBackup? _result; + String? _error; + + Future _run() async { + final service = TitleApiService.fromHolder(cookies: widget.cookies); + if (service == null) { + context.showSnack(AppStrings.ticketNotConfigured); + return; + } + + setState(() { + _running = true; + _error = null; + _result = null; + _progress = ''; + }); + + try { + final backup = await AccountBackupService(service, userId: widget.userId) + .run( + onSection: (section) { + if (mounted) setState(() => _progress = section); + }, + ); + if (!mounted) return; + setState(() { + _result = backup; + _running = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = '$e'; + _running = false; + }); + } + } + + Future _copy(String text) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!mounted) return; + context.showSnack(AppStrings.accountBackupCopied); + } + + String _sizeLabel(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / 1024 / 1024).toStringAsFixed(2)} MB'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final result = _result; + + return AppCard( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SectionTitle( + icon: Icons.folder_zip_outlined, + title: AppStrings.accountBackupTitle, + ), + const SizedBox(height: 10), + Text( + AppStrings.accountBackupDesc, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton.tonalIcon( + onPressed: _running ? null : _run, + icon: _running + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.download_outlined, size: 20), + label: Text( + _running + ? AppStrings.accountBackupRunning(_progress) + : AppStrings.accountBackupRun, + ), + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 13), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ), + if (_error != null) ...[ + const SizedBox(height: 12), + AppNotice(_error!, icon: Icons.error_outline, error: true), + ], + if (result != null) ...[ + const SizedBox(height: 12), + if (!result.isComplete) ...[ + AppNotice( + AppStrings.accountBackupPartial(result.failedSections), + icon: Icons.warning_amber_rounded, + ), + const SizedBox(height: 10), + ], + Row( + children: [ + Expanded( + child: Text( + AppStrings.accountBackupSize(_sizeLabel(result.byteLength)), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + TextButton.icon( + onPressed: () => _copy(result.encode()), + icon: const Icon(Icons.copy_all, size: 18), + label: const Text(AppStrings.accountBackupCopy), + ), + ], + ), + Container( + width: double.infinity, + constraints: const BoxConstraints(maxHeight: 260), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.5, + ), + borderRadius: BorderRadius.circular(8), + ), + child: SingleChildScrollView( + child: SelectableText( + result.encode(), + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontSize: 11, + ), + ), + ), + ), + const SizedBox(height: 10), + Text( + AppStrings.accountBackupSecurity, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.error, + ), + ), + ], + ], + ), + ); + } +} diff --git a/test/account_backup_test.dart b/test/account_backup_test.dart new file mode 100644 index 0000000..9185ec7 --- /dev/null +++ b/test/account_backup_test.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:ichino/services/account_backup_service.dart'; + +void main() { + group('账号备份文档', () { + final sections = { + 'userData': { + 'userData': {'point': 123456, 'totalPoint': 999999, 'playCount': 1234}, + }, + 'userCharacterList': [ + {'characterId': 101, 'level': 999999, 'awakening': 0, 'useCount': 7}, + ], + 'userMapList': [ + {'mapId': 550002, 'distance': 999999999, 'isComplete': true}, + ], + 'userItemList': { + '5': [ + {'itemKind': 5, 'itemId': 11820, 'stock': 1, 'isValid': true}, + ], + '6': const [], + }, + }; + + test('带格式版本、userId 与 exportedAt,各段挂在 data 下', () { + final doc = buildBackupDocument( + userId: 42, + sections: sections, + exportedAt: DateTime.utc(2026, 10, 2, 3, 4, 5), + ); + + expect(doc['backupFormat'], AccountBackup.formatVersion); + expect(doc['userId'], 42); + expect(doc['exportedAt'], '2026-10-02T03:04:05.000Z'); + expect(doc['data'], sections); + }); + + test('encode 出来的是能原样解回来的 JSON 字符串', () { + final backup = AccountBackup( + document: buildBackupDocument(userId: 42, sections: sections), + failedSections: const [], + ); + + final text = backup.encode(); + expect(text, isA()); + expect(text.contains('11820'), isTrue); + + final back = jsonDecode(text) as Map; + final data = back['data'] as Map; + final userData = + (data['userData'] as Map)['userData'] + as Map; + expect(userData['point'], 123456); + + final items = data['userItemList'] as Map; + expect(items['5'], hasLength(1)); + // 空的那一类要留着:「读到但是空的」和「没读到」不是一回事。 + expect(items.containsKey('6'), isTrue); + expect(items['6'], isEmpty); + }); + + test('单段失败不算整体失败,但会记下来并让 isComplete 变 false', () { + final backup = AccountBackup( + document: buildBackupDocument(userId: 7, sections: sections), + failedSections: const ['userMaps: HTTP 500', 'userItemList/12: 超时'], + ); + + expect(backup.isComplete, isFalse); + expect(backup.failedSections, hasLength(2)); + // 失败信息不进 data,备份正文仍然可用。 + expect( + (jsonDecode(backup.encode()) as Map)['data'].containsKey('userMaps'), + isFalse, + ); + }); + + test('byteLength 与实际编码长度一致', () { + final backup = AccountBackup( + document: buildBackupDocument(userId: 7, sections: sections), + failedSections: const [], + ); + + expect(backup.byteLength, utf8.encode(backup.encode()).length); + }); + + test('备份要覆盖用户点名的那几类数据', () { + expect(kBackupItemKinds, containsAll([1, 2, 3, 5, 6, 7, 10, 11, 12])); + // 15 = KaleidxScopeKey,客户端从不读它,不该出现在回读清单里。 + expect(kBackupItemKinds, isNot(contains(15))); + }); + }); +} diff --git a/test/best50_poster_test.dart b/test/best50_poster_test.dart index 03dc0eb..e755b62 100644 --- a/test/best50_poster_test.dart +++ b/test/best50_poster_test.dart @@ -16,8 +16,20 @@ List _cards(int count, {String tag = 'a'}) => [ syncStatus: i % 5, ra: 50 + i, rate: const [ - 'D', 'C', 'B', 'BB', 'BBB', 'A', 'AA', - 'AAA', 'S', 'Sp', 'SS', 'SSp', 'SSS', 'SSSp', + 'D', + 'C', + 'B', + 'BB', + 'BBB', + 'A', + 'AA', + 'AAA', + 'S', + 'Sp', + 'SS', + 'SSp', + 'SSS', + 'SSSp', ][i % 14], ), ]; @@ -71,10 +83,10 @@ void main() { test('满 50 张时最后一张不越出底图', () { final right = Best50Poster.cardLeft(Best50Poster.columnsPerRow - 1) + - Best50Poster.cardWidth; + Best50Poster.cardWidth; final bottom = Best50Poster.cardTopBest15(Best50Poster.maxBest15 - 1) + - Best50Poster.cardHeight; + Best50Poster.cardHeight; expect(right, lessThanOrEqualTo(Best50Poster.canvasWidth)); expect(bottom, lessThanOrEqualTo(Best50Poster.canvasHeight)); }); @@ -99,7 +111,11 @@ void main() { testWidgets('BEST35 未打满时 BEST15 仍固定在第二区块', (tester) async { // 回归用例:以前按 35+15 拼接后的全局序号算行,BEST35 不满 35 条时 // BEST15 会整体上移、混进上半区。 - await _pumpPoster(tester, best35: _cards(3), best15: _cards(5, tag: 'b')); + await _pumpPoster( + tester, + best35: _cards(3), + best15: _cards(5, tag: 'b'), + ); expect(tester.takeException(), isNull); final firstOf35 = tester.getTopLeft(find.text('a#0')).dy; @@ -113,10 +129,9 @@ void main() { expect(lastOf15, greaterThanOrEqualTo(Best50Poster.gridTopSecondBlock)); }); - testWidgets('窄视口里海报仍保持底图尺寸(FittedBox 预览)', (tester) async { - // 回归用例:InteractiveViewer 的 constrained 默认为 true,直接把固定尺寸的 - // 海报当子节点会被压成视口大小——底图压扁、Positioned 的卡片掉出画布被裁掉。 - // 中间垫一层 FittedBox 才是对的:视口拿到的是缩放后的显示,边界录到的仍是原尺寸。 + testWidgets('constrained:false 下海报仍保持底图原尺寸', (tester) async { + // InteractiveViewer(constrained:false) 会给孩子无界约束。不加限定的话 + // 海报会按 1762x1850 的逻辑尺寸直接铺开,视口里只看得到左上角。 tester.view.physicalSize = const Size(400, 900); tester.view.devicePixelRatio = 1.0; addTearDown(tester.view.reset); @@ -151,15 +166,62 @@ void main() { expect(tester.takeException(), isNull); expect( tester.getSize(find.byType(Best50Poster)), - const Size( - Best50Poster.canvasWidth, - Best50Poster.canvasHeight, - ), + const Size(Best50Poster.canvasWidth, Best50Poster.canvasHeight), ); }); + testWidgets('缩放到 contain 盒子后海报仍是原尺寸(导出分辨率不受影响)', (tester) async { + // 页面现在用「有限大小的盒子 + FittedBox」把海报整体缩到视口内, + // RepaintBoundary 在 FittedBox 内侧,所以 toImage 出来的还是 + // 1762x1850 * pixelRatio。这条守的是:别把 RepaintBoundary 挪到盒子外面。 + tester.view.physicalSize = const Size(400, 900); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.reset); + + // 视口 400x900,宽度是限制边,所以 contain 按宽算——和页面里的取法一致。 + const contain = 400 / Best50Poster.canvasWidth; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: Best50Poster.canvasWidth * contain, + height: Best50Poster.canvasHeight * contain, + child: const FittedBox( + fit: BoxFit.fill, + child: Best50Poster( + userName: 'Test Player', + iconId: 0, + playerRating: 12345, + best35: [], + best15: [], + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + + expect(tester.takeException(), isNull); + expect( + tester.getSize(find.byType(Best50Poster)), + const Size(Best50Poster.canvasWidth, Best50Poster.canvasHeight), + ); + // 显示盒子确实缩到视口内,所以一进来能看到完整一张。 + final boxSize = tester.getSize(find.byType(FittedBox).first); + expect(boxSize.width, closeTo(400, 0.5)); + expect(boxSize.height, closeTo(Best50Poster.canvasHeight * contain, 0.5)); + expect(boxSize.height, lessThan(900)); + }); + testWidgets('超出上限的条目被裁剪到 35+15', (tester) async { - await _pumpPoster(tester, best35: _cards(60), best15: _cards(40, tag: 'b')); + await _pumpPoster( + tester, + best35: _cards(60), + best15: _cards(40, tag: 'b'), + ); expect(tester.takeException(), isNull); // BEST35 只保留前 35 条,BEST15 只保留前 15 条。