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

+126
View File
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import '../config/strings.dart';
/// 全站统一的卡片外壳:无阴影、16 圆角、outline 描边。
///
/// 之前每个页面各写一份 `Card(elevation: 0, shape: RoundedRectangleBorder(...))`,
/// 改一处圆角/描边要改十几个文件。
class AppCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry padding;
final Color? borderColor;
final EdgeInsetsGeometry? margin;
const AppCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(20),
this.borderColor,
this.margin,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
margin: margin,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: borderColor ??
theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Padding(padding: padding, child: child),
);
}
}
/// 卡片内的标题行:图标 + 彩色标题。
class SectionTitle extends StatelessWidget {
final IconData icon;
final String title;
final Color? color;
final double iconSize;
const SectionTitle({
super.key,
required this.icon,
required this.title,
this.color,
this.iconSize = 18,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tint = color ?? theme.colorScheme.primary;
return Row(
children: [
Icon(icon, size: iconSize, color: tint),
const SizedBox(width: 8),
Expanded(
child: Text(
title,
style: theme.textTheme.labelLarge?.copyWith(
color: tint,
fontWeight: FontWeight.w600,
),
),
),
],
);
}
}
/// 「完成后自动退出登录并返回标题页」勾选框。
///
/// 六个功能页原本各抄一份 `InkWell + Checkbox + Text`,现在统一成一个卡片。
class AutoLogoutToggle extends StatelessWidget {
final bool value;
final bool enabled;
final ValueChanged<bool>? onChanged;
final String label;
const AutoLogoutToggle({
super.key,
required this.value,
required this.enabled,
required this.onChanged,
this.label = AppStrings.autoLogoutAndExit,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return AppCard(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: enabled ? () => onChanged?.call(!value) : null,
child: Row(
children: [
Checkbox(
value: value,
onChanged:
enabled ? (v) => onChanged?.call(v ?? false) : null,
),
Expanded(
child: Text(
label,
style: theme.textTheme.bodyMedium?.copyWith(
color: enabled
? null
: theme.colorScheme.onSurfaceVariant
.withValues(alpha: 0.6),
),
),
),
],
),
),
);
}
}
+62
View File
@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
/// 页面顶部横幅(未登录 / 冷却 / 错误)。
///
/// [error] 为 true 用 errorContainer,否则用 tertiaryContainer;传了 [icon]
/// 会渲染成「图标 + 文本」,否则退化成纯文本块(保持各页原有外观)。
class AppNotice extends StatelessWidget {
final String text;
final bool error;
final IconData? icon;
const AppNotice(
this.text, {
super.key,
this.error = false,
this.icon,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final bg = error
? theme.colorScheme.errorContainer
: theme.colorScheme.tertiaryContainer.withValues(alpha: 0.5);
final fg = error
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onTertiaryContainer;
return Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(10),
),
child: icon == null
? Text(text, style: theme.textTheme.bodySmall?.copyWith(color: fg))
: Row(
children: [
Icon(icon, size: 18, color: fg),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(color: fg),
),
),
],
),
);
}
}
/// 统一的 SnackBar 入口,替代各页重复的 `ScaffoldMessenger...showSnackBar`。
extension AppSnack on BuildContext {
void showSnack(String message) {
ScaffoldMessenger.of(this).showSnackBar(
SnackBar(content: Text(message)),
);
}
}
+92
View File
@@ -0,0 +1,92 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
import '../config/strings.dart';
/// 功能页共用的「登录后冷却」计时。
///
/// 六个功能页(功能票 / 数值修改 / 旅行伙伴 / 一键跑图 / 万花筒 / 解锁)原本
/// 各自抄了一份 `Timer.periodic` + `_computeRemaining`,这里收口成一个 mixin。
///
/// 用法:
/// ```dart
/// class _FooState extends State<Foo> with CooldownMixin<Foo> {
/// @override
/// int? get cooldownLoginDateTime => widget.loginDateTime;
/// }
/// ```
/// 页面**不要**再自己覆写 `initState` / `didUpdateWidget` / `dispose` 里与冷却
/// 相关的逻辑;如需覆写,记得调用 `super`。
mixin CooldownMixin<T extends StatefulWidget> on State<T> {
Timer? _cooldownTimer;
int _cooldownRemaining = 0;
int? _appliedLoginDateTime;
/// 当前会话的 `UserLoginApi` 时间戳;`null` 表示没有有效登录态。
int? get cooldownLoginDateTime;
bool get isLoggedIn => cooldownLoginDateTime != null;
int get cooldownRemaining => _cooldownRemaining;
@override
void initState() {
super.initState();
_applyCooldown();
}
@override
void didUpdateWidget(covariant T oldWidget) {
super.didUpdateWidget(oldWidget);
if (cooldownLoginDateTime != _appliedLoginDateTime) {
_applyCooldown();
}
}
@override
void dispose() {
_cooldownTimer?.cancel();
_cooldownTimer = null;
super.dispose();
}
void _applyCooldown() {
_cooldownTimer?.cancel();
_cooldownTimer = null;
_appliedLoginDateTime = cooldownLoginDateTime;
final loginDateTime = _appliedLoginDateTime;
if (loginDateTime == null) {
_setRemaining(0);
return;
}
_setRemaining(_computeRemaining(loginDateTime));
if (_cooldownRemaining <= 0) return;
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted) return;
_setRemaining(_computeRemaining(loginDateTime));
if (_cooldownRemaining <= 0) {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
});
}
void _setRemaining(int value) {
if (!mounted) {
_cooldownRemaining = value;
return;
}
setState(() => _cooldownRemaining = value);
}
int _computeRemaining(int loginDateTime) {
final nowSec = DateTime.now().millisecondsSinceEpoch ~/ 1000;
final remaining =
AppStrings.ticketCooldownSeconds - (nowSec - loginDateTime);
return remaining < 0 ? 0 : remaining;
}
}
+133
View File
@@ -0,0 +1,133 @@
import 'package:flutter/material.dart';
import 'app_card.dart';
/// 高危操作共用的执行阶段。
///
/// 六个功能页原来各自定义 `_Step` / `TicketStep` / `MusicRiskStep`,值完全一样
/// (功能票用 `chargeTicket` 表示「发包」,语义上就是 [upload])。
enum RiskStep { idle, fetchData, upload, logout, complete, failed }
/// 执行进度卡:按顺序展示各阶段,前/当前/后三种状态。
///
/// 说明文字 [message] 用错误色高亮;[error] 用于额外展示底层异常文本。
class StepProgressCard extends StatelessWidget {
final RiskStep step;
final String message;
final String? error;
/// 要展示的 (阶段, 文案) 列表,按执行顺序给出。不含 [RiskStep.idle] / [RiskStep.failed]。
final List<(RiskStep, String)> steps;
const StepProgressCard({
super.key,
required this.step,
required this.steps,
this.message = '',
this.error,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isFailed = step == RiskStep.failed;
final isDone = step == RiskStep.complete;
return AppCard(
borderColor: isFailed
? theme.colorScheme.error.withValues(alpha: 0.5)
: isDone
? Colors.green.withValues(alpha: 0.5)
: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final (stage, label) in steps) _row(theme, stage, label),
if (message.isNotEmpty) ...[
const SizedBox(height: 12),
_banner(
theme,
message,
background: isFailed
? theme.colorScheme.errorContainer
: isDone
? Colors.green.withValues(alpha: 0.1)
: theme.colorScheme.surfaceContainerHighest,
foreground: isFailed
? theme.colorScheme.onErrorContainer
: theme.colorScheme.onSurface,
),
],
if (error != null) ...[
const SizedBox(height: 12),
_banner(
theme,
error!,
background: theme.colorScheme.errorContainer,
foreground: theme.colorScheme.onErrorContainer,
),
],
],
),
);
}
Widget _banner(
ThemeData theme,
String text, {
required Color background,
required Color foreground,
}) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: background,
borderRadius: BorderRadius.circular(8),
),
child: Text(
text,
style: theme.textTheme.bodySmall?.copyWith(
fontFamily: 'monospace',
color: foreground,
),
),
);
}
Widget _row(ThemeData theme, RiskStep stage, String label) {
// failed 卡在发包阶段,按原逻辑回退到 upload。
final current = step == RiskStep.failed ? RiskStep.upload : step;
const order = [
RiskStep.fetchData,
RiskStep.upload,
RiskStep.logout,
RiskStep.complete,
];
final done = order.indexOf(current) > order.indexOf(stage);
final active = step == stage;
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)),
],
),
);
}
}