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

+5 -2
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
import '../config/strings.dart';
import '../models/session_model.dart';
import '../services/account_backup_service.dart';
import '../services/title_api_service.dart';
import 'app_card.dart';
@@ -28,7 +29,9 @@ class _AccountBackupCardState extends State<AccountBackupCard> {
String? _error;
Future<void> _run() async {
final service = TitleApiService.fromHolder(cookies: widget.cookies);
final service = SessionModel.instance.createTitleService(
cookies: widget.cookies,
);
if (service == null) {
context.showSnack(AppStrings.ticketNotConfigured);
return;
@@ -56,7 +59,7 @@ class _AccountBackupCardState extends State<AccountBackupCard> {
} catch (e) {
if (!mounted) return;
setState(() {
_error = '$e';
_error = describeApiError(e);
_running = false;
});
}
+11 -1
View File
@@ -11,6 +11,7 @@ class AppCard extends StatelessWidget {
final EdgeInsetsGeometry padding;
final Color? borderColor;
final EdgeInsetsGeometry? margin;
final VoidCallback? onTap;
const AppCard({
super.key,
@@ -18,11 +19,14 @@ class AppCard extends StatelessWidget {
this.padding = const EdgeInsets.all(20),
this.borderColor,
this.margin,
this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final content = Padding(padding: padding, child: child);
return Card(
elevation: 0,
margin: margin,
@@ -33,7 +37,13 @@ class AppCard extends StatelessWidget {
theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(padding: padding, child: child),
child: onTap == null
? content
: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: content,
),
);
}
}
+243
View File
@@ -0,0 +1,243 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../app_card.dart';
/// 主页头像:iconId 为 0 时显示占位人形。
class HomeAvatar extends StatelessWidget {
final int iconId;
const HomeAvatar({super.key, required this.iconId});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return CircleAvatar(
radius: 30,
backgroundColor: theme.colorScheme.primaryContainer,
backgroundImage: iconId > 0
? NetworkImage('https://assets2.lxns.net/maimai/icon/$iconId.png')
: null,
child: iconId == 0
? Icon(Icons.person, color: theme.colorScheme.onPrimaryContainer)
: null,
);
}
}
/// 主页顶部的账号概要卡,Preview 与完整数据共用。
///
/// [highestRating] 为 null 时不显示「历史最高」一行(Preview 数据里没有)。
class HomeProfileCard extends StatelessWidget {
final String userName;
final int iconId;
final int playerRating;
final String? highestRating;
const HomeProfileCard({
super.key,
required this.userName,
required this.iconId,
required this.playerRating,
this.highestRating,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
child: Row(
children: [
HomeAvatar(iconId: iconId),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
userName.isEmpty ? AppStrings.unknownUser : userName,
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Text(
AppStrings.rating(playerRating),
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
if (highestRating != null) ...[
const SizedBox(height: 2),
Text(
'${AppStrings.highestRating}: $highestRating',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
),
),
],
),
);
}
}
/// 主页的「标题 + 字段行」信息卡。
class HomeSectionCard extends StatelessWidget {
final String title;
final List<(String, String)> rows;
const HomeSectionCard({
super.key,
required this.title,
required this.rows,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
for (final (label, value) in rows)
_InfoRow(label: label, value: value),
],
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow({required this.label, required this.value});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 110,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(
child: Text(
value.isEmpty ? '-' : value,
style: theme.textTheme.bodyMedium,
),
),
],
),
);
}
}
/// 主页状态卡:标题 + 若干 [HomeStatusChip]。
class HomeStatusCard extends StatelessWidget {
final List<Widget> chips;
const HomeStatusCard({super.key, required this.chips});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppStrings.status,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 14),
...chips,
],
),
);
}
}
/// 状态行:bool 按成功/失败色渲染成胶囊,其他值原样显示。
class HomeStatusChip extends StatelessWidget {
final String label;
final Object? value;
final String trueText;
final String falseText;
const HomeStatusChip(
this.label,
this.value, {
super.key,
this.trueText = 'Yes',
this.falseText = 'No',
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final v = value;
final String text;
final Color color;
if (v is bool) {
text = v ? trueText : falseText;
color = v ? Colors.green : Colors.grey;
} else {
text = '$v';
color = theme.colorScheme.onSurface;
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 110,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(6),
),
child: Text(
text,
style: theme.textTheme.labelSmall?.copyWith(color: color),
),
),
],
),
);
}
}
+111
View File
@@ -0,0 +1,111 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../app_card.dart';
/// 调试用的原始 API 响应卡,默认折叠。
class DebugRawJsonCard extends StatefulWidget {
final String rawJson;
const DebugRawJsonCard({super.key, required this.rawJson});
@override
State<DebugRawJsonCard> createState() => _DebugRawJsonCardState();
}
class _DebugRawJsonCardState extends State<DebugRawJsonCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
borderColor: Colors.orange.withValues(alpha: 0.5),
onTap: () => setState(() => _expanded = !_expanded),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.bug_report, size: 16, color: Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Text(
AppStrings.rawApiResponse,
style: theme.textTheme.labelLarge?.copyWith(
color: Colors.orange,
fontWeight: FontWeight.w600,
),
),
),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
color: Colors.orange,
),
],
),
if (_expanded) ...[
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(8),
),
child: SelectableText(
widget.rawJson,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
fontSize: 11,
),
),
),
],
],
),
);
}
}
class ErrorIdBanner extends StatelessWidget {
final int errorId;
const ErrorIdBanner({super.key, required this.errorId});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: theme.colorScheme.errorContainer,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
Icon(
Icons.warning_amber_rounded,
size: 20,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 10),
Expanded(
child: Text(
'API returned errorId: $errorId',
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onErrorContainer,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../../models/user_preview.dart';
import 'home_common.dart';
class PreviewGameInfoCard extends StatelessWidget {
final UserPreviewDataBean preview;
const PreviewGameInfoCard({super.key, required this.preview});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.gameInfo,
rows: [
(AppStrings.lastGame, preview.lastGameId),
(AppStrings.lastPlay, preview.lastPlayDate),
(AppStrings.lastLogin, preview.lastLoginDate),
(AppStrings.romVersion, preview.lastRomVersion),
(AppStrings.dataVersion, preview.lastDataVersion),
],
);
}
}
class PreviewStatusCard extends StatelessWidget {
final UserPreviewDataBean preview;
const PreviewStatusCard({super.key, required this.preview});
@override
Widget build(BuildContext context) {
return HomeStatusCard(
chips: [
HomeStatusChip(AppStrings.netMember, preview.isNetMember),
HomeStatusChip(AppStrings.inherit, preview.isInherit),
HomeStatusChip(
AppStrings.banState,
preview.banState == 0,
trueText: AppStrings.clean,
falseText: '已封禁 (${preview.banState})',
),
],
);
}
}
+209
View File
@@ -0,0 +1,209 @@
import 'package:flutter/material.dart';
import '../../config/strings.dart';
import '../../models/user_data.dart';
import '../../models/user_preview.dart';
import '../app_card.dart';
import 'home_common.dart';
class RatingBreakdownCard extends StatelessWidget {
final UserDataBean data;
const RatingBreakdownCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.ratingBreakdown,
rows: [
(AppStrings.musicRating, '${data.musicRating}'),
(AppStrings.newChartRating, '${data.playerNewRating}'),
(AppStrings.oldChartRating, '${data.playerOldRating}'),
(AppStrings.highestRating, '${data.highestRating}'),
],
);
}
}
class FirstPlayCard extends StatelessWidget {
final UserDataBean data;
const FirstPlayCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.firstPlay,
rows: [
(AppStrings.joinDate, data.firstPlayDate),
(AppStrings.firstGame, data.firstGameId),
(AppStrings.firstRomVersion, data.firstRomVersion),
(AppStrings.firstDataVersion, data.firstDataVersion),
],
);
}
}
class GameInfoFromUserDataCard extends StatelessWidget {
final UserDataBean data;
const GameInfoFromUserDataCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.gameInfo,
rows: [
(AppStrings.lastGame, data.lastGameId),
(AppStrings.lastPlay, data.lastPlayDate),
(AppStrings.lastLogin, data.lastLoginDate),
(AppStrings.romVersion, data.lastRomVersion),
(AppStrings.dataVersion, data.lastDataVersion),
(
AppStrings.lastRegion,
'${data.lastRegionName}${data.lastRegionId > 0 ? ' (${data.lastRegionId})' : ''}',
),
],
);
}
}
class PlayStatsCard extends StatelessWidget {
final UserDataBean data;
const PlayStatsCard({super.key, required this.data});
@override
Widget build(BuildContext context) {
return HomeSectionCard(
title: AppStrings.playStats,
rows: [
(AppStrings.playCount, '${data.playCount}'),
(AppStrings.currentPlayCount, '${data.currentPlayCount}'),
(AppStrings.totalAchievement, '${data.totalAchievement}'),
(AppStrings.totalDxScore, '${data.totalDeluxscore}'),
(AppStrings.totalSync, '${data.totalSync}'),
(AppStrings.totalAwake, '${data.totalAwake}'),
],
);
}
}
class StatusFromUserDataCard extends StatelessWidget {
/// 用连接信息(仅 JSESSIONID)恢复的会话没有走 GetUserPreviewApi,可为空。
final UserPreviewDataBean? preview;
final UserDataBean data;
const StatusFromUserDataCard({
super.key,
required this.preview,
required this.data,
});
@override
Widget build(BuildContext context) {
final p = preview;
return HomeStatusCard(
chips: [
HomeStatusChip(AppStrings.netMember, data.isNetMember),
if (p != null) HomeStatusChip(AppStrings.inherit, p.isInherit),
HomeStatusChip(
AppStrings.banState,
data.banState == 0,
trueText: AppStrings.clean,
falseText: '已封禁 (${data.banState})',
),
HomeStatusChip(
AppStrings.dailyBonus,
data.dailyBonusDate.isNotEmpty &&
!data.dailyBonusDate.startsWith('1970'),
trueText: data.dailyBonusDate,
falseText: AppStrings.notClaimed,
),
],
);
}
}
/// 可展开的原始字段卡。
class UserDataDetailCard extends StatefulWidget {
final UserDataBean data;
const UserDataDetailCard({super.key, required this.data});
@override
State<UserDataDetailCard> createState() => _UserDataDetailCardState();
}
class _UserDataDetailCardState extends State<UserDataDetailCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final data = widget.data;
return AppCard(
onTap: () => setState(() => _expanded = !_expanded),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
AppStrings.moreDetails,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
Icon(
_expanded ? Icons.expand_less : Icons.expand_more,
color: theme.colorScheme.onSurfaceVariant,
),
],
),
if (_expanded) ...[
const SizedBox(height: 14),
_detailRow(theme, AppStrings.userId, '${data.userId}'),
_detailRow(theme, AppStrings.point, '${data.point}'),
_detailRow(theme, AppStrings.totalPoint, '${data.totalPoint}'),
_detailRow(theme, AppStrings.iconId, '${data.iconId}'),
_detailRow(theme, AppStrings.nameplateId, '${data.nameplateId}'),
_detailRow(theme, AppStrings.plateId, '${data.plateId}'),
_detailRow(theme, AppStrings.frameId, '${data.frameId}'),
_detailRow(theme, AppStrings.titleId, '${data.titleId}'),
_detailRow(theme, AppStrings.trophyId, '${data.trophyId}'),
_detailRow(theme, AppStrings.partnerId, '${data.partnerId}'),
_detailRow(theme, AppStrings.gradeRank, '${data.gradeRank}'),
_detailRow(theme, AppStrings.courseRank, '${data.courseRank}'),
_detailRow(theme, AppStrings.classRank, '${data.classRank}'),
],
],
),
);
}
Widget _detailRow(ThemeData theme, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
SizedBox(
width: 130,
child: Text(
label,
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
),
Expanded(child: Text(value, style: theme.textTheme.bodyMedium)),
],
),
);
}
}
+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),
),
),
),
],
),
);
}
}