refactor: 抽取高危功能页共享脚手架与传输层日志

诊断:功能票 / 解锁歌曲 / 收藏品 / 旅行伙伴 / 数值修改 / 一键跑图 /
万花筒 / 高危 hub 这些页面各自抄了一份同样的脚手架——冷却计时器、_Step
枚举、自动退登开关、卡片/提示/进度卡;TitleApiService.getUserPreview 还
重复实现了整套传输层;服务层有 37 处裸 print 会把 token / JSESSIONID
打进日志。

- 新增共享原语,7 个功能页切换到:
  * widgets/cooldown_mixin.dart  CooldownMixin(登录后冷却倒计时)
  * widgets/step_progress.dart   RiskStep + StepProgressCard
  * widgets/app_card.dart        AppCard / SectionTitle / AutoLogoutToggle
  * widgets/app_notice.dart      AppNotice + context.showSnack
- TitleApiService: getUserPreview 收敛进通用 _callApi;新增 fromHolder()
  工厂与 config getter,消除散落的 isConfigured + config!
- 传输层 print -> ApiLog(仅 debug 输出),删除全部 avoid_print 忽略
- UserAllPayloadBuilder 新增 placeholderMusicData()(原 5 处重复)
- LoginPage 从 main.dart 抽到 pages/login_page.dart,入口只留 main + MyApp
- 更新 README / CLAUDE.md 目录结构与已过时的 web/依赖说明

验证:flutter analyze 0 issue(含 info);flutter test 96/96 通过。
纯结构重构,行为保持不变;仅有意统一了功能票 / 音乐风险 / hub 的
卡片与步骤视觉样式。

净删除约 2176 行重复代码。
This commit is contained in:
Bad0RANG3 committed 2026-09-28 14:50:51 +08:00
1 parent 0808e367f7
commit 45214b11a0
21 files changed
+1196 -2176

No files matched your search

+49 -250
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'package:flutter/material.dart';
@@ -7,6 +6,10 @@ import '../config/strings.dart';
import '../config/title_server_config.dart';
import '../services/title_api_service.dart';
import '../services/user_all_payload_builder.dart';
import '../widgets/app_card.dart';
import '../widgets/app_notice.dart';
import '../widgets/cooldown_mixin.dart';
import '../widgets/step_progress.dart';
/// 数值修改:Rating 与舞里程。两者的流程一模一样(拉数据 → 改一个数 →
/// UpsertUserAll),差别只在改哪个字段,所以合成一个带模式的视图。
@@ -65,8 +68,6 @@ class NumberPatchPage extends StatelessWidget {
}
}
enum _Step { idle, fetchData, upload, logout, complete, failed }
class NumberPatchView extends StatefulWidget {
final int userId;
final String? cookies;
@@ -96,11 +97,8 @@ class NumberPatchView extends StatefulWidget {
State<NumberPatchView> createState() => _NumberPatchViewState();
}
class _NumberPatchViewState extends State<NumberPatchView> {
/// 与解锁/收藏品/旅行伙伴一致:这些操作不写真实成绩,
/// playlog 沿用同一条占位记录,Rating 修改需要它来带 before/after。
static const int _placeholderMusicId = 11538;
class _NumberPatchViewState extends State<NumberPatchView>
with CooldownMixin<NumberPatchView> {
final _valueController = TextEditingController();
Map<String, Map<String, dynamic>> _userAllData = const {};
@@ -109,68 +107,21 @@ class _NumberPatchViewState extends State<NumberPatchView> {
bool _loading = false;
bool _running = false;
bool _autoLogout = true;
_Step _step = _Step.idle;
RiskStep _step = RiskStep.idle;
String _stepMessage = '';
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
bool get _isRating => widget.mode == NumberPatchMode.rating;
bool get _loggedIn => widget.loginDateTime != null;
@override
void initState() {
super.initState();
_syncCooldown();
}
@override
void didUpdateWidget(covariant NumberPatchView oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.loginDateTime != widget.loginDateTime) {
_syncCooldown();
}
}
int? get cooldownLoginDateTime => widget.loginDateTime;
@override
void dispose() {
_cooldownTimer?.cancel();
_valueController.dispose();
super.dispose();
}
void _syncCooldown() {
_cooldownTimer?.cancel();
final loginDateTime = widget.loginDateTime;
if (loginDateTime == null) {
setState(() => _cooldownRemaining = 0);
return;
}
setState(() => _cooldownRemaining = _computeRemaining(loginDateTime));
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
final remaining = _computeRemaining(loginDateTime);
setState(() => _cooldownRemaining = remaining);
if (remaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final remaining =
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
return remaining < 0 ? 0 : remaining;
}
void _snack(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
void _snack(String message) => context.showSnack(message);
static int _toInt(dynamic v) {
if (v is int) return v;
@@ -225,8 +176,7 @@ class _NumberPatchViewState extends State<NumberPatchView> {
setState(() => _loading = true);
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final data = await service.fetchUserAllData(widget.userId);
if (!mounted) return;
setState(() {
@@ -251,8 +201,8 @@ class _NumberPatchViewState extends State<NumberPatchView> {
_snack(AppStrings.numberPatchNotLoggedIn);
return;
}
if (_cooldownRemaining > 0) {
_snack(AppStrings.numberPatchCooldownNotice(_cooldownRemaining));
if (cooldownRemaining > 0) {
_snack(AppStrings.numberPatchCooldownNotice(cooldownRemaining));
return;
}
final inputError = _checkInput();
@@ -265,23 +215,22 @@ class _NumberPatchViewState extends State<NumberPatchView> {
setState(() => _running = true);
try {
final config = TitleServerConfigHolder().config!;
final service = TitleApiService(config, cookies: widget.cookies);
final builder = UserAllPayloadBuilder(config);
final service = TitleApiService.fromHolder(cookies: widget.cookies)!;
final builder = UserAllPayloadBuilder(service.config);
var data = _userAllData;
if (data.isEmpty) {
_updateStep(_Step.fetchData);
_updateStep(RiskStep.fetchData);
data = await service.fetchUserAllData(widget.userId);
if (mounted) setState(() => _userAllData = data);
}
_updateStep(_Step.upload);
_updateStep(RiskStep.upload);
final packet = builder.build(
userId: widget.userId,
loginId: loginId,
loginDateTime: loginDateTime,
musicData: _placeholderMusicData(),
musicData: UserAllPayloadBuilder.placeholderMusicData(),
generalUserInfo: data,
);
@@ -304,36 +253,24 @@ class _NumberPatchViewState extends State<NumberPatchView> {
}
await service.upsertUserAll(packet, widget.userId);
_updateStep(_Step.complete, resultMessage);
_updateStep(RiskStep.complete, resultMessage);
if (_autoLogout && widget.onExitToTitle != null) {
await Future.delayed(const Duration(seconds: 2));
if (!mounted) return;
_updateStep(_Step.logout, AppStrings.exitingToTitle);
_updateStep(RiskStep.logout, AppStrings.exitingToTitle);
await widget.onExitToTitle!();
}
} on TitleApiException catch (e) {
_updateStep(_Step.failed, e.message);
_updateStep(RiskStep.failed, e.message);
} catch (e) {
_updateStep(_Step.failed, '$e');
_updateStep(RiskStep.failed, '$e');
} finally {
if (mounted) setState(() => _running = false);
}
}
Map<String, dynamic> _placeholderMusicData() => {
'musicId': _placeholderMusicId,
'level': 0,
'playCount': 1,
'achievement': 0,
'comboStatus': 0,
'syncStatus': 0,
'deluxscoreMax': 0,
'scoreRank': 0,
'extNum1': 0,
};
void _updateStep(_Step step, [String? message]) {
void _updateStep(RiskStep step, [String? message]) {
if (!mounted) return;
setState(() {
_step = step;
@@ -352,12 +289,11 @@ class _NumberPatchViewState extends State<NumberPatchView> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (!_loggedIn)
_notice(theme, AppStrings.numberPatchNotLoggedIn, error: true),
if (_loggedIn && _cooldownRemaining > 0)
_notice(
theme,
AppStrings.numberPatchCooldownNotice(_cooldownRemaining),
if (!isLoggedIn)
const AppNotice(AppStrings.numberPatchNotLoggedIn, error: true),
if (isLoggedIn && cooldownRemaining > 0)
AppNotice(
AppStrings.numberPatchCooldownNotice(cooldownRemaining),
),
_descCard(theme),
const SizedBox(height: 12),
@@ -365,12 +301,24 @@ class _NumberPatchViewState extends State<NumberPatchView> {
const SizedBox(height: 12),
_fetchCard(theme),
const SizedBox(height: 12),
_autoLogoutToggle(theme),
AutoLogoutToggle(
value: _autoLogout,
enabled: widget.onExitToTitle != null && !_running,
onChanged: (v) => setState(() => _autoLogout = v),
),
const SizedBox(height: 12),
_runButton(theme),
if (_step != _Step.idle) ...[
if (_step != RiskStep.idle) ...[
const SizedBox(height: 16),
_progressCard(theme),
StepProgressCard(
step: _step,
message: _stepMessage,
steps: [
(RiskStep.fetchData, AppStrings.unlockStepFetch),
(RiskStep.upload, AppStrings.numberPatchStepUpload),
if (_autoLogout) (RiskStep.logout, AppStrings.stepLogout),
],
),
],
],
),
@@ -378,65 +326,14 @@ class _NumberPatchViewState extends State<NumberPatchView> {
);
}
Widget _notice(ThemeData theme, String text, {bool error = false}) {
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: error
? theme.colorScheme.errorContainer
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(10),
),
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
color: error
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onTertiaryContainer,
),
),
);
}
Widget _card(ThemeData theme, {required Widget child}) {
return 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: child),
);
}
Widget _descCard(ThemeData theme) {
return _card(
theme,
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_isRating ? Icons.speed : Icons.commute,
size: 18,
color: theme.colorScheme.primary,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.featureTitle,
style: theme.textTheme.labelLarge?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
],
SectionTitle(
icon: _isRating ? Icons.speed : Icons.commute,
title: widget.featureTitle,
),
const SizedBox(height: 12),
Text(
@@ -455,8 +352,7 @@ class _NumberPatchViewState extends State<NumberPatchView> {
final enabled = !_running;
final hasInput = int.tryParse(_valueController.text.trim()) != null;
return _card(
theme,
return AppCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -541,8 +437,7 @@ class _NumberPatchViewState extends State<NumberPatchView> {
? AppStrings.ratingCurrentValue
: AppStrings.maiMileCurrentBalance;
return _card(
theme,
return AppCard(
child: Row(
children: [
Icon(
@@ -596,38 +491,11 @@ class _NumberPatchViewState extends State<NumberPatchView> {
);
}
Widget _autoLogoutToggle(ThemeData theme) {
final enabled = widget.onExitToTitle != null && !_running;
return _card(
theme,
child: InkWell(
borderRadius: BorderRadius.circular(10),
onTap: enabled ? () => setState(() => _autoLogout = !_autoLogout) : null,
child: Row(
children: [
Checkbox(
value: _autoLogout,
onChanged: enabled
? (v) => setState(() => _autoLogout = v ?? false)
: null,
),
Expanded(
child: Text(
AppStrings.autoLogoutAndExit,
style: theme.textTheme.bodyMedium,
),
),
],
),
),
);
}
Widget _runButton(ThemeData theme) {
return SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _running || !_loggedIn ? null : _run,
onPressed: _running || !isLoggedIn ? null : _run,
icon: _running
? const SizedBox(
width: 20,
@@ -647,73 +515,4 @@ class _NumberPatchViewState extends State<NumberPatchView> {
),
);
}
Widget _progressCard(ThemeData theme) {
final isFailed = _step == _Step.failed;
return _card(
theme,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_stepRow(theme, _Step.fetchData, AppStrings.unlockStepFetch),
_stepRow(theme, _Step.upload, AppStrings.numberPatchStepUpload),
if (_autoLogout)
_stepRow(theme, _Step.logout, AppStrings.stepLogout),
if (_stepMessage.isNotEmpty) ...[
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: isFailed
? theme.colorScheme.errorContainer
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_stepMessage,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: isFailed
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onSurface,
),
),
),
],
],
),
);
}
Widget _stepRow(ThemeData theme, _Step step, String label) {
final order = [_Step.fetchData, _Step.upload, _Step.logout, _Step.complete];
final current = _step == _Step.failed ? _Step.upload : _step;
final done = order.indexOf(current) > order.indexOf(step);
final active = _step == step;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
children: [
Icon(
done
? Icons.check_circle
: active
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 18,
color: done
? Colors.green
: active
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant,
),
const SizedBox(width: 10),
Expanded(child: Text(label, style: theme.textTheme.bodySmall)),
],
),
);
}
}