refactor: 拆分主页与公共组件,扫码登录改走 WinHTTP

- 抽出 lib/core 的日期/JSON 工具,收敛各页重复解析
- 主页与音乐风险页拆成组件,风险功能共用一套运行框架
- 修复设置页作为主页页签保存时弹掉根路由导致的黑屏
- 登录对齐 Astral aimedb 协议:84 位二维码校验、签名与请求头修正,
  补 GlobalSign 中间证书;Windows 改用 WinHTTP 绕过服务端对
  BoringSSL 的 TLS 指纹拦截(证书校验保持开启)
- 补开发预览数据与测试
This commit is contained in:
Bad0RANG3 committed 2026-10-25 15:51:10 +08:00
1 parent 0cb5320290
commit e9357f5969
55 files changed
+4387 -3392

No files matched your search

+232
View File
@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../app_card.dart';
import 'risk_list_widgets.dart';
import 'risk_widgets.dart';
/// 一行待提交的收藏品道具(对应 wire 上的 UserItem)。
class PendingItem {
final int kind;
final int id;
const PendingItem(this.kind, this.id);
}
/// 一行待提交的歌曲解锁,展开成 itemKind 5/6/7 的若干行。
class PendingMusic {
final int musicId;
final bool base;
final bool master;
final bool remaster;
const PendingMusic(this.musicId, this.base, this.master, this.remaster);
/// 难度由 itemKind 表达,不写在 musicDetail 上。
List<int> get itemKinds => [
if (base) 5,
if (master) 6,
if (remaster) 7,
];
String get optionLabel => [
if (base) '歌曲',
if (master) 'Master',
if (remaster) 'Re:Master',
].join('/');
/// ConstParameter.DxScoreBaseID=10000 / StrongScoreBaseID=20000
/// (`MAI2System/ConstParameter.cs:168,171`)。这段里的歌 Master 需要
/// 显式 itemKind 6,不像 id < 10000 那样在 IsUnlockMaster 里直接放过。
bool get isDeluxeRange => musicId >= 10000 && musicId < 20000;
}
/// 歌曲解锁的输入卡。输入框与三个难度勾选由本组件持有,
/// 校验通过后把待提交行交给父级;父级返回 false 表示重复、保留输入。
class UnlockMusicEditorCard extends StatefulWidget {
final bool enabled;
final bool Function(PendingMusic music) onAdd;
final ValueChanged<String> onError;
final Widget pendingList;
final bool showDxNotice;
const UnlockMusicEditorCard({
super.key,
required this.enabled,
required this.onAdd,
required this.onError,
required this.pendingList,
required this.showDxNotice,
});
@override
State<UnlockMusicEditorCard> createState() => _UnlockMusicEditorCardState();
}
class _UnlockMusicEditorCardState extends State<UnlockMusicEditorCard> {
final _controller = TextEditingController();
bool _music = false;
bool _master = false;
bool _remaster = false;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add() {
final id = int.tryParse(_controller.text.trim()) ?? -1;
if (id <= 0) {
widget.onError(AppStrings.unlockNeedMusicId);
return;
}
if (!_music && !_master && !_remaster) {
widget.onError(AppStrings.unlockNeedOption);
return;
}
final added = widget.onAdd(PendingMusic(id, _music, _master, _remaster));
if (!added) return;
setState(() {
_controller.clear();
_music = _master = _remaster = false;
});
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final enabled = widget.enabled;
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RiskNumberField(
controller: _controller,
label: AppStrings.unlockMusicIdLabel,
hint: AppStrings.unlockMusicIdHint,
enabled: enabled,
),
const SizedBox(height: 6),
RiskCheckRow(
value: _music,
label: AppStrings.unlockMusicOption,
enabled: enabled,
onChanged: (v) => setState(() => _music = v),
),
RiskCheckRow(
value: _master,
label: AppStrings.unlockMasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _master = v),
),
RiskCheckRow(
value: _remaster,
label: AppStrings.unlockRemasterOption,
enabled: enabled,
onChanged: (v) => setState(() => _remaster = v),
),
const SizedBox(height: 8),
RiskAddButton(onPressed: enabled ? _add : null),
widget.pendingList,
// DX 段的 Master / Re:Master 不白送,勾选项里漏一个就开不出来,
// 这是这段 ID 最容易踩的坑,只在列表真含 DX 曲时才说明。
if (widget.showDxNotice) ...[
const SizedBox(height: 10),
Text(
AppStrings.unlockDxRangeNotice,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.error,
height: 1.5,
),
),
],
],
),
);
}
}
/// 收藏品获取的输入卡(类型下拉 + ID)。
class CollectiblesEditorCard extends StatefulWidget {
final bool enabled;
final bool Function(PendingItem item) onAdd;
final ValueChanged<String> onError;
final Widget pendingList;
const CollectiblesEditorCard({
super.key,
required this.enabled,
required this.onAdd,
required this.onError,
required this.pendingList,
});
@override
State<CollectiblesEditorCard> createState() => _CollectiblesEditorCardState();
}
class _CollectiblesEditorCardState extends State<CollectiblesEditorCard> {
final _controller = TextEditingController();
int _kind = 3;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add() {
final id = int.tryParse(_controller.text.trim()) ?? -1;
if (id <= 0) {
widget.onError(AppStrings.collectiblesNeedItemId);
return;
}
final added = widget.onAdd(PendingItem(_kind, id));
if (!added) return;
setState(() => _controller.clear());
}
@override
Widget build(BuildContext context) {
final enabled = widget.enabled;
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
RiskDropdownField<int>(
value: _kind,
label: AppStrings.collectiblesItemKindLabel,
enabled: enabled,
items: [
for (final kind in AppStrings.collectiblesItemKinds)
DropdownMenuItem(
value: kind,
child: Text(
'${AppStrings.collectiblesItemKindName(kind)} ($kind)',
),
),
],
onChanged: (v) => setState(() => _kind = v),
),
const SizedBox(height: 12),
RiskNumberField(
controller: _controller,
label: AppStrings.collectiblesItemIdLabel,
hint: AppStrings.collectiblesItemIdHint,
enabled: enabled,
),
const SizedBox(height: 8),
RiskAddButton(onPressed: enabled ? _add : null),
widget.pendingList,
],
),
);
}
}
+186
View File
@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
/// 全宽的「添加到列表」按钮,样式统一。
class RiskAddButton extends StatelessWidget {
final VoidCallback? onPressed;
const RiskAddButton({super.key, this.onPressed});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: onPressed,
icon: const Icon(Icons.add, size: 18),
label: const Text(AppStrings.listAddButton),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
);
}
}
/// 待提交列表:标题带数量,空列表时显示提示。
class RiskPendingList extends StatelessWidget {
final String title;
final String emptyHint;
final int count;
final List<Widget> rows;
const RiskPendingList({
super.key,
required this.title,
required this.emptyHint,
required this.count,
required this.rows,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 14),
Text(
'$title ($count)',
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
if (count == 0)
Text(
emptyHint,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
)
else
...rows,
],
);
}
}
/// 待提交列表的一行:等宽文本 + 删除按钮。
class RiskPendingRow extends StatelessWidget {
final String leading;
final String trailing;
final VoidCallback onRemove;
final bool enabled;
const RiskPendingRow({
super.key,
required this.leading,
required this.trailing,
required this.onRemove,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Row(
children: [
Expanded(
child: Text(
'$leading $trailing',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
),
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
tooltip: AppStrings.listRemoveTooltip,
onPressed: enabled ? onRemove : null,
color: theme.colorScheme.onSurfaceVariant,
visualDensity: VisualDensity.compact,
),
],
);
}
}
/// 风险页通用下拉框(圆角 10、内容内边距 14)。
class RiskDropdownField<T> extends StatelessWidget {
final T value;
final String label;
final List<DropdownMenuItem<T>> items;
final ValueChanged<T>? onChanged;
final bool enabled;
const RiskDropdownField({
super.key,
required this.value,
required this.label,
required this.items,
required this.onChanged,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return DropdownButtonFormField<T>(
initialValue: value,
decoration: InputDecoration(
labelText: label,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
items: items,
onChanged: !enabled || onChanged == null
? null
: (v) {
if (v == null) return;
onChanged!(v);
},
);
}
}
/// 与 [RiskDropdownField] 配对的普通文本输入框,保持同样的圆角与内边距。
class RiskNumberField extends StatelessWidget {
final TextEditingController controller;
final String label;
final String hint;
final bool enabled;
const RiskNumberField({
super.key,
required this.controller,
required this.label,
required this.hint,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
return TextField(
controller: controller,
enabled: enabled,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
),
contentPadding: const EdgeInsets.all(14),
),
);
}
}
+96
View File
@@ -0,0 +1,96 @@
import 'package:flutter/widgets.dart';
import '../../config/strings.dart';
import '../../config/title_server_config.dart';
import '../../services/title_api_service.dart';
import '../step_progress.dart';
/// 高危功能共用的执行状态:发包中、自动退登、阶段与错误。
///
/// 六个页面原来各自维护一份完全相同的字段和 `_updateStep`,导致同样的
/// 「失败落到哪一步、退登时序怎么走」要在每个文件里各看一遍。
mixin RiskRunMixin<T extends StatefulWidget> on State<T> {
bool riskFetching = false;
bool riskRunning = false;
bool riskAutoLogout = true;
RiskStep riskStep = RiskStep.idle;
String riskStepMessage = '';
String? riskError;
void updateRiskStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
riskStep = step;
riskStepMessage = message ?? '';
});
}
void setRiskFetching(bool value) {
if (!mounted) return;
setState(() => riskFetching = value);
}
void setRiskRunning(bool value) {
if (!mounted) return;
setState(() => riskRunning = value);
}
void setRiskAutoLogout(bool value) {
if (!mounted) return;
setState(() => riskAutoLogout = value);
}
void clearRiskError() {
if (!mounted || riskError == null) return;
setState(() => riskError = null);
}
/// 把异常写进阶段卡。`TitleApiException` 的 message 已经是给用户看的文案,
/// 其他异常保留 `toString()` 以便排查。
void reportRiskError(Object error) {
final message = describeApiError(error);
if (!mounted) return;
setState(() {
riskError = message;
riskStep = RiskStep.failed;
riskStepMessage = message;
});
}
/// 成功后的自动退登:留一点时间让阶段卡显示完成态,再由 HomePage 结算并
/// pop 回标题页。调用方负责先判断 `onExitToTitle` 是否存在。
Future<void> autoLogoutAndExit(
Future<void> Function()? onExitToTitle, {
Duration delay = const Duration(seconds: 2),
}) async {
if (!riskAutoLogout || onExitToTitle == null) return;
await Future<void>.delayed(delay);
if (!mounted) return;
updateRiskStep(RiskStep.logout, AppStrings.exitingToTitle);
await onExitToTitle();
}
}
/// 执行前的统一门禁,顺序固定为「配置 → 登录态 → 冷却」。
///
/// 返回要提示给用户的文案,`null` 表示可以继续。功能票只依赖
/// `loginDateTime`,其余 `UpsertUserAll` 页面还需要 `loginId`。
String? riskPreconditionError({
required int? loginDateTime,
required int? loginId,
required int cooldownRemaining,
required String notLoggedInMessage,
required String Function(int remaining) cooldownNotice,
bool requireLoginId = true,
}) {
if (!TitleServerConfigHolder().isConfigured) {
return AppStrings.ticketNotConfigured;
}
if (loginDateTime == null || (requireLoginId && loginId == null)) {
return notLoggedInMessage;
}
if (cooldownRemaining > 0) {
return cooldownNotice(cooldownRemaining);
}
return null;
}
+332
View File
@@ -0,0 +1,332 @@
import 'package:flutter/material.dart';
import '../../config/responsive.dart';
import '../app_card.dart';
import '../app_notice.dart';
/// 尚未配置 Title Server 时的占位视图。
class RiskConfigMissingView extends StatelessWidget {
final String message;
const RiskConfigMissingView({
super.key,
required this.message,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.settings_ethernet,
size: 48,
color: theme.colorScheme.primary,
),
const SizedBox(height: 16),
Text(message, style: theme.textTheme.titleMedium),
],
),
),
);
}
}
/// 高危页统一的滚动容器与内边距。
class RiskPageBody extends StatelessWidget {
final List<Widget> children;
const RiskPageBody({super.key, required this.children});
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: responsiveBody(
context,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
);
}
}
/// 未登录 / 冷却横幅。
class RiskStatusNotices extends StatelessWidget {
final bool isLoggedIn;
final int cooldownRemaining;
final String notLoggedInText;
final String Function(int remaining) cooldownText;
const RiskStatusNotices({
super.key,
required this.isLoggedIn,
required this.cooldownRemaining,
required this.notLoggedInText,
required this.cooldownText,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
if (!isLoggedIn)
AppNotice(
notLoggedInText,
error: true,
icon: Icons.warning_amber_rounded,
),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
cooldownText(cooldownRemaining),
icon: Icons.timer_outlined,
),
],
);
}
}
/// 功能说明卡。[danger] 用于高危中转页的红色警示卡。
class RiskFeatureCard extends StatelessWidget {
final IconData icon;
final String title;
final String? description;
final Widget? child;
final bool danger;
const RiskFeatureCard({
super.key,
required this.icon,
required this.title,
this.description,
this.child,
this.danger = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tint = danger ? theme.colorScheme.error : null;
return AppCard(
borderColor:
danger ? theme.colorScheme.error.withValues(alpha: 0.4) : null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SectionTitle(icon: icon, title: title, color: tint),
if (description != null) ...[
const SizedBox(height: 12),
Text(
description!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
height: 1.5,
),
),
],
?child,
],
),
);
}
}
/// 「已拉取服务器数据」状态 + 刷新按钮。各页原来各抄一份 Row。
class RiskFetchCard extends StatelessWidget {
final bool hasData;
final String statusText;
final String loadingLabel;
final String actionLabel;
final String? error;
final bool loading;
final bool enabled;
final VoidCallback onFetch;
const RiskFetchCard({
super.key,
required this.hasData,
required this.statusText,
required this.loadingLabel,
required this.actionLabel,
required this.loading,
required this.onFetch,
this.error,
this.enabled = true,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
hasData ? Icons.check_circle : Icons.download,
size: 18,
color: hasData ? Colors.green : theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
statusText,
style: theme.textTheme.bodySmall?.copyWith(
color: hasData
? Colors.green
: theme.colorScheme.onSurfaceVariant,
),
),
),
SizedBox(
height: 32,
child: OutlinedButton.icon(
onPressed: loading || !enabled ? null : onFetch,
icon: loading
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh, size: 16),
label: Text(
loading ? loadingLabel : actionLabel,
style: theme.textTheme.labelSmall,
),
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
),
],
),
if (error != null) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Text(
error!,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontFamily: 'monospace',
),
),
),
],
],
),
);
}
}
/// 高危页底部的执行按钮。
class RiskRunButton extends StatelessWidget {
final bool running;
final bool enabled;
final IconData icon;
final String label;
final String runningLabel;
final String? disabledLabel;
final VoidCallback onPressed;
const RiskRunButton({
super.key,
required this.running,
required this.enabled,
required this.icon,
required this.label,
required this.runningLabel,
required this.onPressed,
this.disabledLabel,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: enabled && !running ? onPressed : null,
icon: running
? SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: theme.colorScheme.onPrimary,
),
)
: Icon(icon, size: 20),
label: Text(
running ? runningLabel : (disabledLabel ?? label),
),
style: FilledButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
);
}
}
/// 勾选行(解锁难度 / 万花筒动作共用)。
class RiskCheckRow extends StatelessWidget {
final bool value;
final String label;
final bool enabled;
final ValueChanged<bool> onChanged;
const RiskCheckRow({
super.key,
required this.value,
required this.label,
required this.enabled,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => onChanged(!value) : null,
child: Row(
children: [
Checkbox(
value: value,
onChanged: enabled ? (v) => onChanged(v ?? false) : null,
),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.6),
),
),
),
],
),
);
}
}