feat: 主页账号备份,并修好 B50 预览缩不小

账号备份(AccountBackupCard + AccountBackupService):
- 并发读 GetUserData/Extend/Option/Rating/Charge/Activity/MissionData +
  Character + Map(分页)+ KaleidxScope + 各 itemKind 的 GetUserItemApi,
  汇成一个 JSON 字符串可复制到剪贴板。舞里程与总游玩数据本就在
  userData 的 point/totalPoint/playCount 里。
- itemKind 15(KaleidxScopeKey)不列进回读清单:客户端 ExportUserItems
  从不输出它,下行也没有读它的路径,钥匙实际是
  userKaleidxScopeList[].isKeyFound。
- 全程只发 GetUser*Api,不碰 UpsertUserAllApi。
- 逐段容错:私服没实现某个接口时其余照样备出,失败段单独列出——
  「读到但是空的」和「没读到」在 userItemList 里按 kind 分开存,可区分。
- 取数与拼装分开,buildBackupDocument/encode 部分是纯函数,单测覆盖。

B50 预览:原因是 InteractiveViewer(constrained:false) 给孩子无界约束,
FittedBox(fit: fitWidth) 在无界下根本不缩放,海报按 1762x1850 原尺寸铺开;
而 minScale 又算成 1.0,于是既巨大又缩不动。改为给一个 contain 尺寸的
有限盒子让 FittedBox 真正缩放,minScale 固定 1.0(这个版本没有
initialScale 可用)。RepaintBoundary 留在 FittedBox 内侧,绘制尺寸仍是
1762x1850,导出分辨率不受影响,并加了断言守住这点。
This commit is contained in:
Cookiues committed 2026-10-02 12:26:03 +08:00
1 parent a5fb2c97cd
commit 0cb5320290
8 files changed
+524 -36

No files matched your search

+117
View File
@@ -0,0 +1,117 @@
import 'dart:convert';
import 'title_api_service.dart';
/// 一次账号备份的结果。
class AccountBackup {
static const int formatVersion = 1;
final Map<String, dynamic> document;
/// 读失败的段名(含原因)。备份不因为单段失败而整体作废——
/// 私服不一定实现了所有 GetUser* 接口。
final List<String> 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<String, dynamic> buildBackupDocument({
required int userId,
required Map<String, Object?> 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<int> 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<AccountBackup> run({void Function(String section)? onSection}) async {
final sections = <String, Object?>{};
final failed = <String>[];
Future<void> gather(String name, Future<Object?> 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 = <String, Object?>{};
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,
);
}
}