chore: 批量代码优化与功能迭代更新

本次提交包含大量代码优化、功能新增与服务端配置更新:
1. 修复分析报告统计数据,调整CMake策略设置
2. 优化APP权限配置、编辑器与聊天界面组件
3. 更新依赖库版本与pubspec配置
4. 新增文件传输服务端、信令服务器相关配置与脚本
5. 完善用户注销功能与数据库迁移脚本
6. 优化多处动画效果、代码风格与日志输出
7. 新增多种调试与部署脚本,修复已知BUG
This commit is contained in:
Developer
2026-05-12 06:28:04 +08:00
parent 72f64f9ca9
commit 283950ea07
245 changed files with 50255 additions and 6160 deletions

View File

@@ -1,9 +1,9 @@
/// ============================================================
/// 闲言APP — 用户数据模型
/// 创建时间: 2026-04-28
/// 更新时间: 2026-04-29
/// 更新时间: 2026-05-11
/// 作用: 用户信息数据模型,对应后端 tool_user 表
/// 上次更新: 新增verification验证状态字段
/// 上次更新: v10.0.0 UserDevice新增ipCity/ipRange字段
/// ============================================================
class UserModel {
@@ -12,6 +12,7 @@ class UserModel {
required this.username,
this.nickname = '',
this.avatar = '',
this.avatarUrl = '',
this.email = '',
this.mobile = '',
this.score = 0,
@@ -25,12 +26,19 @@ class UserModel {
this.token,
this.title,
this.verification,
this.isOnline = 0,
this.vip,
this.cloudSpace,
this.devices = const [],
this.extra,
this.profileSlug = '',
});
final int id;
final String username;
final String nickname;
final String avatar;
final String avatarUrl;
final String email;
final String mobile;
final int score;
@@ -45,20 +53,42 @@ class UserModel {
final UserTitle? title;
final UserVerification? verification;
final int isOnline;
final UserVip? vip;
final UserCloudSpace? cloudSpace;
final List<UserDevice> devices;
final UserExtra? extra;
final String profileSlug;
String get displayName => nickname.isNotEmpty ? nickname : username;
String get avatarUrl {
if (avatar.isEmpty) return '';
if (avatar.startsWith('http')) return avatar;
return 'https://tools.wktyl.com$avatar';
String get avatarDisplayUrl {
if (avatarUrl.isNotEmpty) {
if (avatarUrl.startsWith('http')) return avatarUrl;
return 'https://tools.wktyl.com$avatarUrl';
}
if (avatar.isNotEmpty) {
if (avatar.startsWith('http')) return avatar;
if (avatar.startsWith('data:')) return avatar;
return 'https://tools.wktyl.com$avatar';
}
return '';
}
@Deprecated('Use avatarDisplayUrl instead')
String get avatarUrlCompat => avatarDisplayUrl;
bool get isVip => vip?.isVip ?? false;
bool get getIsOnline => isOnline == 1;
factory UserModel.fromJson(Map<String, dynamic> json) {
return UserModel(
id: json['id'] as int? ?? 0,
username: json['username'] as String? ?? '',
nickname: json['nickname'] as String? ?? '',
avatar: json['avatar'] as String? ?? '',
avatarUrl: json['avatar_url'] as String? ?? '',
email: json['email'] as String? ?? '',
mobile: json['mobile'] as String? ?? '',
score: json['score'] as int? ?? 0,
@@ -75,8 +105,25 @@ class UserModel {
: null,
verification: json['verification'] != null
? UserVerification.fromJson(
json['verification'] as Map<String, dynamic>)
json['verification'] as Map<String, dynamic>,
)
: null,
isOnline: json['is_online'] as int? ?? 0,
vip: json['vip'] != null
? UserVip.fromJson(json['vip'] as Map<String, dynamic>)
: null,
cloudSpace: json['cloud_space'] != null
? UserCloudSpace.fromJson(json['cloud_space'] as Map<String, dynamic>)
: null,
devices: json['devices'] != null
? (json['devices'] as List<dynamic>)
.map((e) => UserDevice.fromJson(e as Map<String, dynamic>))
.toList()
: [],
extra: json['extra'] != null
? UserExtra.fromJson(json['extra'] as Map<String, dynamic>)
: null,
profileSlug: json['profile_slug'] as String? ?? '',
);
}
@@ -85,6 +132,7 @@ class UserModel {
String? username,
String? nickname,
String? avatar,
String? avatarUrl,
String? email,
String? mobile,
int? score,
@@ -98,12 +146,19 @@ class UserModel {
String? token,
UserTitle? title,
UserVerification? verification,
int? isOnline,
UserVip? vip,
UserCloudSpace? cloudSpace,
List<UserDevice>? devices,
UserExtra? extra,
String? profileSlug,
}) {
return UserModel(
id: id ?? this.id,
username: username ?? this.username,
nickname: nickname ?? this.nickname,
avatar: avatar ?? this.avatar,
avatarUrl: avatarUrl ?? this.avatarUrl,
email: email ?? this.email,
mobile: mobile ?? this.mobile,
score: score ?? this.score,
@@ -117,6 +172,12 @@ class UserModel {
token: token ?? this.token,
title: title ?? this.title,
verification: verification ?? this.verification,
isOnline: isOnline ?? this.isOnline,
vip: vip ?? this.vip,
cloudSpace: cloudSpace ?? this.cloudSpace,
devices: devices ?? this.devices,
extra: extra ?? this.extra,
profileSlug: profileSlug ?? this.profileSlug,
);
}
}
@@ -145,10 +206,7 @@ class UserTitle {
}
class UserVerification {
const UserVerification({
this.email = 0,
this.mobile = 0,
});
const UserVerification({this.email = 0, this.mobile = 0});
final int email;
final int mobile;
@@ -163,3 +221,177 @@ class UserVerification {
);
}
}
class UserVip {
const UserVip({
this.isVip = false,
this.startTime = 0,
this.endTime = 0,
this.startDate = '',
this.endDate = '',
});
final bool isVip;
final int startTime;
final int endTime;
final String startDate;
final String endDate;
bool get isActive => isVip && endTime > 0;
factory UserVip.fromJson(Map<String, dynamic> json) {
return UserVip(
isVip: json['is_vip'] as bool? ?? false,
startTime: json['start_time'] as int? ?? 0,
endTime: json['end_time'] as int? ?? 0,
startDate: json['start_date'] as String? ?? '',
endDate: json['end_date'] as String? ?? '',
);
}
}
class UserCloudSpace {
const UserCloudSpace({
this.total = 0,
this.used = 0,
this.free = 0,
this.totalHuman = '',
this.usedHuman = '',
this.usagePercent = 0.0,
});
final int total;
final int used;
final int free;
final String totalHuman;
final String usedHuman;
final double usagePercent;
factory UserCloudSpace.fromJson(Map<String, dynamic> json) {
return UserCloudSpace(
total: json['total'] as int? ?? 0,
used: json['used'] as int? ?? 0,
free: json['free'] as int? ?? 0,
totalHuman: json['total_human'] as String? ?? '',
usedHuman: json['used_human'] as String? ?? '',
usagePercent: (json['usage_percent'] as num?)?.toDouble() ?? 0.0,
);
}
}
class UserDevice {
const UserDevice({
this.id = 0,
this.deviceName = '',
this.deviceModel = '',
this.platform = '',
this.appName = '',
this.ip = '',
this.ipCity = '',
this.ipRange = '',
this.lastActiveTime = 0,
this.isOnline = 0,
this.createtime = 0,
this.lastActiveText = '',
this.createtimeText = '',
});
final int id;
final String deviceName;
final String deviceModel;
final String platform;
final String appName;
final String ip;
final String ipCity;
final String ipRange;
final int lastActiveTime;
final int isOnline;
final int createtime;
final String lastActiveText;
final String createtimeText;
bool get getIsOnline => isOnline == 1;
bool get hasIpCity => ipCity.isNotEmpty;
bool get hasIpRange => ipRange.isNotEmpty;
String get displayLocation => hasIpCity ? '📍 $ipCity' : '';
factory UserDevice.fromJson(Map<String, dynamic> json) {
return UserDevice(
id: json['id'] as int? ?? 0,
deviceName: json['device_name'] as String? ?? '',
deviceModel: json['device_model'] as String? ?? '',
platform: json['platform'] as String? ?? '',
appName: json['app_name'] as String? ?? '',
ip: json['ip'] as String? ?? '',
ipCity: json['ip_city'] as String? ?? '',
ipRange: json['ip_range'] as String? ?? '',
lastActiveTime: json['last_active_time'] as int? ?? 0,
isOnline: json['is_online'] as int? ?? 0,
createtime: json['createtime'] as int? ?? 0,
lastActiveText: json['last_active_text'] as String? ?? '',
createtimeText: json['createtime_text'] as String? ?? '',
);
}
UserDevice copyWith({
int? id,
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? ip,
String? ipCity,
String? ipRange,
int? lastActiveTime,
int? isOnline,
int? createtime,
String? lastActiveText,
String? createtimeText,
}) {
return UserDevice(
id: id ?? this.id,
deviceName: deviceName ?? this.deviceName,
deviceModel: deviceModel ?? this.deviceModel,
platform: platform ?? this.platform,
appName: appName ?? this.appName,
ip: ip ?? this.ip,
ipCity: ipCity ?? this.ipCity,
ipRange: ipRange ?? this.ipRange,
lastActiveTime: lastActiveTime ?? this.lastActiveTime,
isOnline: isOnline ?? this.isOnline,
createtime: createtime ?? this.createtime,
lastActiveText: lastActiveText ?? this.lastActiveText,
createtimeText: createtimeText ?? this.createtimeText,
);
}
}
class UserExtra {
const UserExtra({
this.money = '0.00',
this.noteLimit = 50,
this.verification,
this.lastSigninDate = '',
});
final String money;
final int noteLimit;
final UserVerification? verification;
final String lastSigninDate;
factory UserExtra.fromJson(Map<String, dynamic> json) {
return UserExtra(
money: json['money']?.toString() ?? '0.00',
noteLimit: json['note_limit'] as int? ?? 50,
verification: json['verification'] != null
? UserVerification.fromJson(
json['verification'] as Map<String, dynamic>,
)
: null,
lastSigninDate: json['last_signin_date'] as String? ?? '',
);
}
}

View File

@@ -0,0 +1,522 @@
/// ============================================================
/// 闲言APP — 登录表单组件集
/// 创建时间: 2026-05-10
/// 更新时间: 2026-05-10
/// 作用: 登录页面的各模式表单组件(密码/验证码/Token/老用户)+ 通用输入框
/// 上次更新: 从 login_page.dart 拆分,支持左右滑动切换
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter_animate/flutter_animate.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../core/theme/app_radius.dart';
// ============================================================
// 密码登录表单
// ============================================================
class PasswordFormSection extends StatelessWidget {
const PasswordFormSection({
super.key,
required this.accountController,
required this.passwordController,
required this.obscurePassword,
required this.onToggleObscure,
required this.onForgotPassword,
required this.ext,
});
final TextEditingController accountController;
final TextEditingController passwordController;
final bool obscurePassword;
final VoidCallback onToggleObscure;
final VoidCallback onForgotPassword;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
LoginInputField(
controller: accountController,
placeholder: '用户名或邮箱',
icon: CupertinoIcons.person,
ext: ext,
),
const SizedBox(height: AppSpacing.md),
LoginInputField(
controller: passwordController,
placeholder: '密码',
icon: CupertinoIcons.lock,
ext: ext,
obscureText: obscurePassword,
suffix: GestureDetector(
onTap: onToggleObscure,
child: Icon(
obscurePassword ? CupertinoIcons.eye_slash : CupertinoIcons.eye,
size: 18,
color: ext.textHint,
),
),
),
Align(
alignment: Alignment.centerRight,
child: CupertinoButton(
padding: const EdgeInsets.only(top: AppSpacing.xs),
minimumSize: Size.zero,
onPressed: onForgotPassword,
child: Text(
'忘记密码?',
style: AppTypography.caption1.copyWith(
color: ext.accent,
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
}
}
// ============================================================
// 验证码登录表单
// ============================================================
class CodeFormSection extends StatelessWidget {
const CodeFormSection({
super.key,
required this.accountController,
required this.codeController,
required this.emsSending,
required this.emsCountdown,
required this.onSendCode,
required this.ext,
});
final TextEditingController accountController;
final TextEditingController codeController;
final bool emsSending;
final int emsCountdown;
final VoidCallback onSendCode;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
LoginInputField(
controller: accountController,
placeholder: '邮箱地址',
icon: CupertinoIcons.mail,
ext: ext,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: LoginInputField(
controller: codeController,
placeholder: '邮箱验证码',
icon: CupertinoIcons.number,
ext: ext,
keyboardType: TextInputType.number,
),
),
const SizedBox(width: AppSpacing.sm),
SizedBox(
width: 110,
height: 48,
child: CupertinoButton(
color: ext.accent.withValues(alpha: 0.15),
borderRadius: AppRadius.mdBorder,
onPressed: (emsSending || emsCountdown > 0)
? null
: onSendCode,
child: emsSending
? CupertinoActivityIndicator(radius: 8, color: ext.accent)
: Text(
emsCountdown > 0 ? '${emsCountdown}s' : '发送验证码',
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontSize: 13,
),
),
),
),
],
),
],
),
);
}
}
// ============================================================
// Token 登录表单
// ============================================================
class TokenFormSection extends StatelessWidget {
const TokenFormSection({
super.key,
required this.tokenController,
required this.ext,
});
final TextEditingController tokenController;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.md),
child: Column(
children: [
Icon(CupertinoIcons.lock_shield, size: 36, color: ext.accent),
const SizedBox(height: AppSpacing.xs),
Text(
'输入Token令牌快速登录\n适用于多设备同步',
textAlign: TextAlign.center,
style: AppTypography.footnote.copyWith(
color: ext.textSecondary,
),
),
],
),
),
LoginInputField(
controller: tokenController,
placeholder: '粘贴或输入Token',
icon: CupertinoIcons.lock_shield,
ext: ext,
),
const SizedBox(height: AppSpacing.xs),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(CupertinoIcons.lightbulb, size: 12, color: ext.textHint),
const SizedBox(width: 4),
Text(
'可在「安全与Token管理」中获取Token',
style: AppTypography.caption2.copyWith(color: ext.textHint),
),
],
),
],
),
);
}
}
// ============================================================
// 老用户登录表单
// ============================================================
class LegacyFormSection extends StatelessWidget {
const LegacyFormSection({
super.key,
required this.accountController,
required this.passwordController,
required this.ext,
});
final TextEditingController accountController;
final TextEditingController passwordController;
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpacing.lg),
child: Column(
children: [
Icon(
CupertinoIcons.person_2,
size: 40,
color: ext.accent.withValues(alpha: 0.6),
),
const SizedBox(height: AppSpacing.sm),
Text(
'老用户登录',
style: AppTypography.title3.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 4),
Text(
'使用旧版账号体系登录',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
),
),
],
),
),
LoginInputField(
controller: accountController,
placeholder: '旧版用户名',
icon: CupertinoIcons.person,
ext: ext,
),
const SizedBox(height: AppSpacing.md),
LoginInputField(
controller: passwordController,
placeholder: '旧版密码',
icon: CupertinoIcons.lock,
ext: ext,
obscureText: true,
),
const SizedBox(height: AppSpacing.md),
Container(
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.08),
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: ext.accent.withValues(alpha: 0.15),
width: 0.5,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
CupertinoIcons.info_circle,
size: 14,
color: ext.accent,
),
const SizedBox(width: 6),
Text(
'关于老用户登录',
style: AppTypography.footnote.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
Text(
'2019—2023.6 期间注册的为老用户,老用户享有持久权益:\n'
'• 保留原有积分和等级\n'
'• 专属老用户标识和称号\n'
'• 部分高级功能优先体验\n\n'
'该登录方式正在迁移中,请使用其他方式登录。',
style: AppTypography.caption1.copyWith(
color: ext.textSecondary,
height: 1.5,
),
),
],
),
),
],
),
);
}
}
// ============================================================
// 通用输入框
// ============================================================
class LoginInputField extends StatelessWidget {
const LoginInputField({
super.key,
required this.controller,
required this.placeholder,
required this.icon,
required this.ext,
this.obscureText = false,
this.suffix,
this.keyboardType,
});
final TextEditingController controller;
final String placeholder;
final IconData icon;
final AppThemeExtension ext;
final bool obscureText;
final Widget? suffix;
final TextInputType? keyboardType;
@override
Widget build(BuildContext context) {
return Container(
height: 50,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: ext.textHint.withValues(alpha: 0.15),
width: 0.5,
),
),
child: Row(
children: [
const SizedBox(width: AppSpacing.sm),
Icon(icon, size: 18, color: ext.textSecondary),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: CupertinoTextField(
controller: controller,
placeholder: placeholder,
placeholderStyle: AppTypography.subhead.copyWith(
color: ext.textHint,
),
style: AppTypography.body.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w500,
),
decoration: null,
obscureText: obscureText,
keyboardType: keyboardType,
cursorColor: ext.accent,
),
),
if (suffix != null) ...[
suffix!,
const SizedBox(width: AppSpacing.sm),
],
],
),
);
}
}
// ============================================================
// 带标签的输入框
// ============================================================
class LabeledInputField extends StatelessWidget {
const LabeledInputField({
super.key,
required this.label,
this.labelIcon,
required this.controller,
required this.placeholder,
required this.icon,
required this.ext,
this.obscureText = false,
this.suffix,
this.keyboardType,
});
final String label;
final IconData? labelIcon;
final TextEditingController controller;
final String placeholder;
final IconData icon;
final AppThemeExtension ext;
final bool obscureText;
final Widget? suffix;
final TextInputType? keyboardType;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (labelIcon != null) ...[
Icon(labelIcon, size: 14, color: ext.textSecondary),
const SizedBox(width: 4),
],
Text(
label,
style: AppTypography.footnote.copyWith(
color: ext.textSecondary,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 6),
LoginInputField(
controller: controller,
placeholder: placeholder,
icon: icon,
ext: ext,
obscureText: obscureText,
suffix: suffix,
keyboardType: keyboardType,
),
],
);
}
}
// ============================================================
// 登录成功过渡动画
// ============================================================
class LoginSuccessView extends StatelessWidget {
const LoginSuccessView({super.key, required this.ext});
final AppThemeExtension ext;
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: [ext.accent, ext.accentLight],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: const Icon(
CupertinoIcons.checkmark_alt,
size: 40,
color: CupertinoColors.white,
),
)
.animate()
.scale(
begin: const Offset(0.0, 0.0),
end: const Offset(1.0, 1.0),
duration: 500.ms,
curve: Curves.elasticOut,
)
.fadeIn(duration: 300.ms),
const SizedBox(height: AppSpacing.lg),
Text(
'登录成功',
style: AppTypography.title2.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
).animate().fadeIn(duration: 300.ms, delay: 200.ms),
const SizedBox(height: AppSpacing.xs),
Text(
'正在跳转...',
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
).animate().fadeIn(duration: 300.ms, delay: 400.ms),
],
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,993 @@
/// ============================================================
/// 闲言APP — 二维码登录页面
/// 创建时间: 2026-05-10
/// 更新时间: 2026-05-10
/// 作用: 扫码登录扫描Web端二维码确认登录+ 生成二维码Web端扫码登录
/// 上次更新: 初始创建
/// ============================================================
import 'dart:async';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mobile_scanner/mobile_scanner.dart';
import 'package:qr_flutter/qr_flutter.dart';
import '../../../core/theme/app_radius.dart';
import '../../../core/theme/app_spacing.dart';
import '../../../core/theme/app_theme.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/logger.dart';
import '../providers/qrcode_login_provider.dart';
import '../../../shared/widgets/app_toast.dart';
import '../../../shared/widgets/glass_container.dart';
// ============================================================
// 页面入口
// ============================================================
class QrcodeLoginPage extends ConsumerStatefulWidget {
const QrcodeLoginPage({super.key});
@override
ConsumerState<QrcodeLoginPage> createState() => _QrcodeLoginPageState();
}
class _QrcodeLoginPageState extends ConsumerState<QrcodeLoginPage> {
int _currentTab = 0;
final MobileScannerController _scannerController = MobileScannerController(
autoStart: false,
);
bool _cameraPermissionDenied = false;
bool _hasScanned = false;
StreamSubscription<Object>? _subscription;
Timer? _expireTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startScanner();
});
}
@override
void dispose() {
_subscription?.cancel();
_expireTimer?.cancel();
_scannerController.dispose();
super.dispose();
}
// ============================================================
// 扫码控制
// ============================================================
Future<void> _startScanner() async {
try {
await _scannerController.start();
if (mounted) {
setState(() => _cameraPermissionDenied = false);
}
_subscription = _scannerController.barcodes.listen(_onBarcodeDetect);
} catch (e) {
Log.w('相机启动失败: $e');
if (mounted) {
setState(() => _cameraPermissionDenied = true);
}
}
}
void _onBarcodeDetect(BarcodeCapture capture) {
if (_hasScanned) return;
final barcode = capture.barcodes.firstOrNull;
if (barcode == null) return;
final rawValue = barcode.rawValue;
if (rawValue == null || rawValue.isEmpty) return;
final code = _parseCodeFromUrl(rawValue);
if (code.isEmpty) {
AppToast.showWarning('无法识别此二维码');
return;
}
setState(() => _hasScanned = true);
_scannerController.stop();
Log.i('扫描到二维码: code=$code');
ref.read(qrcodeLoginProvider.notifier).confirmLogin(code);
}
/// 从URL中解析code参数
String _parseCodeFromUrl(String url) {
try {
final uri = Uri.parse(url);
final code = uri.queryParameters['code'];
if (code != null && code.isNotEmpty) return code;
if (!url.contains('/') && !url.contains('?') && url.length >= 8) {
return url;
}
return '';
} catch (_) {
if (url.length >= 8 && !url.contains(' ')) return url;
return '';
}
}
// ============================================================
// 生成二维码
// ============================================================
Future<void> _generateQrcode() async {
await ref.read(qrcodeLoginProvider.notifier).generateQrcode();
final result = ref.read(qrcodeLoginProvider).qrcodeResult;
if (result != null && !result.isExpired) {
_startExpireCountdown(result.expireSeconds);
}
}
void _startExpireCountdown(int seconds) {
_expireTimer?.cancel();
_expireTimer = Timer(Duration(seconds: seconds), () {
if (mounted) {
AppToast.showWarning('二维码已过期,请重新生成');
ref.read(qrcodeLoginProvider.notifier).cancel();
}
});
}
// ============================================================
// 构建UI
// ============================================================
@override
Widget build(BuildContext context) {
final ext = AppTheme.ext(context);
final qrcodeState = ref.watch(qrcodeLoginProvider);
ref.listen<QrcodeLoginState>(qrcodeLoginProvider, (prev, next) {
if (next.isSuccess && prev?.isSuccess != true) {
_showSuccessDialog(ext);
}
if (next.isError && next.errorMessage != null) {
AppToast.showError(next.errorMessage!);
}
});
return CupertinoPageScaffold(
backgroundColor: ext.bgPrimary,
navigationBar: CupertinoNavigationBar(
middle: Text(
'📷 二维码登录',
style: AppTypography.title3.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
backgroundColor: ext.bgPrimary.withValues(alpha: 0.85),
border: null,
leading: CupertinoButton(
padding: EdgeInsets.zero,
onPressed: () => Navigator.of(context).pop(),
child: Icon(CupertinoIcons.back, color: ext.accent),
),
),
child: SafeArea(
child: Column(
children: [
_buildTabBar(ext)
.animate()
.fadeIn(duration: 300.ms)
.slideY(begin: -0.05, end: 0, duration: 300.ms),
Expanded(
child: _currentTab == 0
? _buildScanTab(ext, qrcodeState)
: _buildGenerateTab(ext, qrcodeState),
),
],
),
),
);
}
// ============================================================
// Tab栏
// ============================================================
Widget _buildTabBar(AppThemeExtension ext) {
return Container(
margin: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.pillBorder,
),
child: Row(
children: [
Expanded(child: _buildTabItem(ext, '📷 扫码登录', 0)),
Expanded(child: _buildTabItem(ext, '📱 生成二维码', 1)),
],
),
);
}
Widget _buildTabItem(AppThemeExtension ext, String label, int index) {
final isActive = _currentTab == index;
return GestureDetector(
onTap: () {
if (_currentTab == index) return;
setState(() => _currentTab = index);
if (index == 1) {
_scannerController.stop();
_generateQrcode();
} else {
_hasScanned = false;
_startScanner();
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 250),
margin: const EdgeInsets.all(3),
padding: const EdgeInsets.symmetric(vertical: AppSpacing.sm),
decoration: BoxDecoration(
color: isActive
? ext.accent.withValues(alpha: 0.15)
: Colors.transparent,
borderRadius: AppRadius.pillBorder,
),
child: Center(
child: Text(
label,
style: AppTypography.caption1.copyWith(
color: isActive ? ext.accent : ext.textSecondary,
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
),
),
),
);
}
// ============================================================
// 扫码Tab
// ============================================================
Widget _buildScanTab(AppThemeExtension ext, QrcodeLoginState state) {
if (state.isConfirming) {
return _buildConfirmingView(ext);
}
if (_cameraPermissionDenied) {
return _buildPermissionDeniedView(ext);
}
return _buildScannerView(ext, state);
}
Widget _buildScannerView(AppThemeExtension ext, QrcodeLoginState state) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Column(
children: [
const SizedBox(height: AppSpacing.md),
Text(
'扫描Web端二维码',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
).animate().fadeIn(duration: 400.ms),
const SizedBox(height: AppSpacing.xs),
Text(
'请在网页端打开登录页面,使用本应用扫描二维码确认登录',
style: AppTypography.subhead.copyWith(color: ext.textHint),
textAlign: TextAlign.center,
).animate().fadeIn(duration: 400.ms, delay: 100.ms),
const SizedBox(height: AppSpacing.lg),
GlassContainer(
depth: GlassDepth.elevated,
width: double.infinity,
padding: EdgeInsets.zero,
child: ClipRRect(
borderRadius: AppRadius.lgBorder,
child: AspectRatio(
aspectRatio: 1,
child: Stack(
children: [
MobileScanner(
controller: _scannerController,
onDetect: (_) {},
),
_buildScannerOverlay(ext),
if (state.isScanning)
Positioned(
bottom: AppSpacing.md,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.md,
vertical: AppSpacing.sm,
),
decoration: BoxDecoration(
color: ext.bgPrimary.withValues(alpha: 0.8),
borderRadius: AppRadius.pillBorder,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CupertinoActivityIndicator(
radius: 8,
color: ext.accent,
),
const SizedBox(width: AppSpacing.sm),
Text(
'正在扫描…',
style: AppTypography.footnote.copyWith(
color: ext.textSecondary,
),
),
],
),
),
),
),
],
),
),
),
)
.animate()
.fadeIn(duration: 500.ms, delay: 200.ms)
.scale(
begin: const Offset(0.95, 0.95),
end: const Offset(1.0, 1.0),
duration: 500.ms,
delay: 200.ms,
),
const SizedBox(height: AppSpacing.lg),
_buildTipCard(ext),
],
),
);
}
/// 扫描框覆盖层 — 四角标记 + 半透明遮罩
Widget _buildScannerOverlay(AppThemeExtension ext) {
const cornerLen = 28.0;
const cornerWidth = 3.5;
final accentColor = ext.accent;
return LayoutBuilder(
builder: (context, constraints) {
final scanSize = constraints.maxWidth * 0.7;
final left = (constraints.maxWidth - scanSize) / 2;
final top = (constraints.maxHeight - scanSize) / 2;
final scanRect = Rect.fromLTWH(left, top, scanSize, scanSize);
return Stack(
children: [
_buildDimOverlay(constraints, scanRect, ext),
_buildCorner(
scanRect.left,
scanRect.top,
cornerLen,
cornerWidth,
accentColor,
isTopLeft: true,
),
_buildCorner(
scanRect.right - cornerLen,
scanRect.top,
cornerLen,
cornerWidth,
accentColor,
isTopRight: true,
),
_buildCorner(
scanRect.left,
scanRect.bottom - cornerLen,
cornerLen,
cornerWidth,
accentColor,
isBottomLeft: true,
),
_buildCorner(
scanRect.right - cornerLen,
scanRect.bottom - cornerLen,
cornerLen,
cornerWidth,
accentColor,
isBottomRight: true,
),
],
);
},
);
}
Widget _buildDimOverlay(
BoxConstraints constraints,
Rect scanRect,
AppThemeExtension ext,
) {
return ColorFiltered(
colorFilter: ColorFilter.mode(
ext.bgPrimary.withValues(alpha: 0.6),
BlendMode.srcOver,
),
child: CustomPaint(
size: Size(constraints.maxWidth, constraints.maxHeight),
painter: _ScanAreaPainter(scanRect: scanRect),
),
);
}
Widget _buildCorner(
double x,
double y,
double len,
double width,
Color color, {
bool isTopLeft = false,
bool isTopRight = false,
bool isBottomLeft = false,
bool isBottomRight = false,
}) {
return Positioned(
left: x,
top: y,
child: SizedBox(
width: len,
height: len,
child: CustomPaint(
painter: _CornerPainter(
color: color,
strokeWidth: width,
isTopLeft: isTopLeft,
isTopRight: isTopRight,
isBottomLeft: isBottomLeft,
isBottomRight: isBottomRight,
),
),
),
);
}
Widget _buildConfirmingView(AppThemeExtension ext) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.xlBorder,
),
child: Center(
child: CupertinoActivityIndicator(
radius: 18,
color: ext.accent,
),
),
)
.animate()
.fadeIn(duration: 300.ms)
.scale(
begin: const Offset(0.8, 0.8),
end: const Offset(1.0, 1.0),
duration: 300.ms,
),
const SizedBox(height: AppSpacing.lg),
Text(
'正在确认登录…',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.sm),
Text(
'请在Web端确认登录请求',
style: AppTypography.subhead.copyWith(color: ext.textHint),
),
],
),
);
}
Widget _buildPermissionDeniedView(AppThemeExtension ext) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.xl),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: ext.accent.withValues(alpha: 0.1),
borderRadius: AppRadius.xlBorder,
),
child: Center(
child: Icon(
CupertinoIcons.camera,
size: 36,
color: ext.accent,
),
),
)
.animate()
.fadeIn(duration: 400.ms)
.scale(
begin: const Offset(0.8, 0.8),
end: const Offset(1.0, 1.0),
duration: 400.ms,
),
const SizedBox(height: AppSpacing.lg),
Text(
'需要相机权限',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: AppSpacing.sm),
Text(
'请在系统设置中开启相机权限,以使用扫码登录功能',
style: AppTypography.subhead.copyWith(color: ext.textHint),
textAlign: TextAlign.center,
),
const SizedBox(height: AppSpacing.lg),
CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.xl,
vertical: AppSpacing.sm,
),
color: ext.accent,
borderRadius: AppRadius.mdBorder,
onPressed: _startScanner,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.refresh,
size: 16,
color: CupertinoColors.white,
),
const SizedBox(width: AppSpacing.sm),
Text(
'重新授权',
style: AppTypography.subhead.copyWith(
color: CupertinoColors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
),
),
);
}
Widget _buildTipCard(AppThemeExtension ext) {
return GlassContainer(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
CupertinoIcons.lightbulb_fill,
size: 14,
color: ext.accent,
),
const SizedBox(width: 6),
Text(
'使用提示',
style: AppTypography.subhead.copyWith(
color: ext.accent,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: AppSpacing.sm),
_buildTipRow(ext, '1⃣ 在电脑浏览器打开闲言Web版'),
_buildTipRow(ext, '2⃣ 点击登录页面的二维码图标'),
_buildTipRow(ext, '3⃣ 使用本应用扫描二维码'),
_buildTipRow(ext, '4⃣ 确认后即可在Web端登录'),
],
),
)
.animate()
.fadeIn(duration: 400.ms, delay: 400.ms)
.slideY(begin: 0.1, end: 0, duration: 400.ms, delay: 400.ms);
}
Widget _buildTipRow(AppThemeExtension ext, String text) {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xs),
child: Text(
text,
style: AppTypography.footnote.copyWith(color: ext.textSecondary),
),
);
}
// ============================================================
// 生成二维码Tab
// ============================================================
Widget _buildGenerateTab(AppThemeExtension ext, QrcodeLoginState state) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.md),
child: Column(
children: [
const SizedBox(height: AppSpacing.md),
Text(
'Web端扫码登录',
style: AppTypography.headline.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
).animate().fadeIn(duration: 400.ms),
const SizedBox(height: AppSpacing.xs),
Text(
'在Web端扫描下方二维码即可登录',
style: AppTypography.subhead.copyWith(color: ext.textHint),
textAlign: TextAlign.center,
).animate().fadeIn(duration: 400.ms, delay: 100.ms),
const SizedBox(height: AppSpacing.lg),
_buildQrcodeCard(ext, state),
const SizedBox(height: AppSpacing.lg),
_buildQrcodeActions(ext, state),
],
),
);
}
Widget _buildQrcodeCard(AppThemeExtension ext, QrcodeLoginState state) {
final result = state.qrcodeResult;
final isExpired = result?.isExpired ?? false;
return GlassContainer(
depth: GlassDepth.elevated,
width: double.infinity,
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
children: [
if (result != null && !isExpired) ...[
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: ext.bgPrimary,
borderRadius: AppRadius.lgBorder,
border: Border.all(
color: ext.accent.withValues(alpha: 0.2),
width: 1.5,
),
),
child: QrImageView(
data: result.qrcodeUrl.isNotEmpty
? result.qrcodeUrl
: 'xianyan://qrcode?code=${result.code}',
size: 200,
eyeStyle: QrEyeStyle(
eyeShape: QrEyeShape.square,
color: ext.textPrimary,
),
dataModuleStyle: QrDataModuleStyle(
dataModuleShape: QrDataModuleShape.square,
color: ext.textPrimary,
),
backgroundColor: ext.bgPrimary,
),
)
.animate()
.fadeIn(duration: 500.ms)
.scale(
begin: const Offset(0.9, 0.9),
end: const Offset(1.0, 1.0),
duration: 500.ms,
),
const SizedBox(height: AppSpacing.md),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(CupertinoIcons.timer, size: 14, color: ext.textHint),
const SizedBox(width: 4),
Text(
'有效期 ${result.expireSeconds ~/ 60} 分钟',
style: AppTypography.caption1.copyWith(
color: ext.textHint,
),
),
],
),
] else if (isExpired) ...[
_buildExpiredPlaceholder(ext),
] else ...[
_buildLoadingPlaceholder(ext),
],
],
),
)
.animate()
.fadeIn(duration: 500.ms, delay: 200.ms)
.slideY(begin: 0.05, end: 0, duration: 500.ms, delay: 200.ms);
}
Widget _buildExpiredPlaceholder(AppThemeExtension ext) {
return Column(
children: [
Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.lgBorder,
),
child: Center(
child: Column(
children: [
const Icon(
CupertinoIcons.exclamationmark_triangle,
size: 40,
color: CupertinoColors.systemOrange,
),
const SizedBox(height: AppSpacing.sm),
Text(
'二维码已过期',
style: AppTypography.subhead.copyWith(color: ext.textHint),
),
],
),
),
),
const SizedBox(height: AppSpacing.md),
CupertinoButton(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.lg,
vertical: AppSpacing.sm,
),
color: ext.accent,
borderRadius: AppRadius.mdBorder,
onPressed: _generateQrcode,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.refresh,
size: 16,
color: CupertinoColors.white,
),
const SizedBox(width: AppSpacing.sm),
Text(
'重新生成',
style: AppTypography.subhead.copyWith(
color: CupertinoColors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
],
);
}
Widget _buildLoadingPlaceholder(AppThemeExtension ext) {
return Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: ext.bgSecondary,
borderRadius: AppRadius.lgBorder,
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CupertinoActivityIndicator(radius: 14, color: ext.accent),
const SizedBox(height: AppSpacing.md),
Text(
'正在生成二维码…',
style: AppTypography.subhead.copyWith(color: ext.textHint),
),
],
),
),
);
}
Widget _buildQrcodeActions(AppThemeExtension ext, QrcodeLoginState state) {
return GlassContainer(
padding: const EdgeInsets.all(AppSpacing.md),
child: Column(
children: [
SizedBox(
width: double.infinity,
height: 50,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.mdBorder,
onPressed: _generateQrcode,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.arrow_2_circlepath,
size: 16,
color: CupertinoColors.white,
),
const SizedBox(width: AppSpacing.sm),
Text(
'刷新二维码',
style: AppTypography.subhead.copyWith(
color: CupertinoColors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
const SizedBox(height: AppSpacing.sm),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(CupertinoIcons.info_circle, size: 14, color: ext.textHint),
const SizedBox(width: 4),
Flexible(
child: Text(
'请勿将二维码分享给他人,以免账号被盗',
style: AppTypography.caption2.copyWith(color: ext.textHint),
textAlign: TextAlign.center,
),
),
],
),
],
),
).animate().fadeIn(duration: 400.ms, delay: 400.ms);
}
// ============================================================
// 成功弹窗
// ============================================================
void _showSuccessDialog(AppThemeExtension ext) {
showCupertinoDialog<void>(
context: context,
builder: (ctx) => CupertinoAlertDialog(
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(''),
Text(
'登录成功',
style: AppTypography.headline.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
content: Padding(
padding: const EdgeInsets.only(top: AppSpacing.sm),
child: Text(
'已成功在Web端登录即将返回',
style: AppTypography.subhead.copyWith(color: ext.textSecondary),
),
),
),
);
Future.delayed(const Duration(seconds: 2), () {
if (mounted) {
Navigator.of(context).pop();
Navigator.of(context).pop();
}
});
}
}
// ============================================================
// 自定义Painter — 扫描区域遮罩
// ============================================================
class _ScanAreaPainter extends CustomPainter {
const _ScanAreaPainter({required this.scanRect});
final Rect scanRect;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = const Color(0x80000000);
final path = Path()
..addRect(Rect.fromLTWH(0, 0, size.width, size.height))
..addRRect(RRect.fromRectAndRadius(scanRect, Radius.zero));
path.fillType = PathFillType.evenOdd;
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant _ScanAreaPainter oldDelegate) =>
scanRect != oldDelegate.scanRect;
}
// ============================================================
// 自定义Painter — 四角标记
// ============================================================
class _CornerPainter extends CustomPainter {
const _CornerPainter({
required this.color,
required this.strokeWidth,
this.isTopLeft = false,
this.isTopRight = false,
this.isBottomLeft = false,
this.isBottomRight = false,
});
final Color color;
final double strokeWidth;
final bool isTopLeft;
final bool isTopRight;
final bool isBottomLeft;
final bool isBottomRight;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = color
..strokeWidth = strokeWidth
..strokeCap = StrokeCap.round
..style = PaintingStyle.stroke;
if (isTopLeft) {
canvas.drawLine(Offset.zero, Offset(size.width, 0), paint);
canvas.drawLine(Offset.zero, Offset(0, size.height), paint);
} else if (isTopRight) {
canvas.drawLine(Offset(size.width, 0), Offset.zero, paint);
canvas.drawLine(
Offset(size.width, 0),
Offset(size.width, size.height),
paint,
);
} else if (isBottomLeft) {
canvas.drawLine(Offset(0, size.height), Offset.zero, paint);
canvas.drawLine(
Offset(0, size.height),
Offset(size.width, size.height),
paint,
);
} else if (isBottomRight) {
canvas.drawLine(
Offset(size.width, size.height),
Offset(0, size.height),
paint,
);
canvas.drawLine(
Offset(size.width, size.height),
Offset(size.width, 0),
paint,
);
}
}
@override
bool shouldRepaint(covariant _CornerPainter oldDelegate) =>
color != oldDelegate.color || strokeWidth != oldDelegate.strokeWidth;
}

View File

@@ -0,0 +1,482 @@
/// ============================================================
/// 闲言APP — 注册区域组件
/// 创建时间: 2026-05-10
/// 更新时间: 2026-05-10
/// 作用: 登录页面的注册区域,分步式注册流程
/// 上次更新: 从 login_page.dart 拆分,独立管理注册状态
/// ============================================================
import 'package:flutter/cupertino.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../../core/theme/app_theme.dart';
import '../../../../core/theme/app_spacing.dart';
import '../../../../core/theme/app_typography.dart';
import '../../../../core/theme/app_radius.dart';
import '../../../../core/utils/logger.dart';
import '../../../../shared/widgets/app_toast.dart';
import '../../../../shared/widgets/glass_container.dart';
import '../providers/auth_provider.dart';
import '../services/email_service.dart';
import 'login_form_sections.dart';
class RegisterSection extends ConsumerStatefulWidget {
const RegisterSection({
super.key,
required this.ext,
required this.onSwitchToLogin,
required this.onRegisterSuccess,
});
final AppThemeExtension ext;
final VoidCallback onSwitchToLogin;
final VoidCallback onRegisterSuccess;
@override
ConsumerState<RegisterSection> createState() => _RegisterSectionState();
}
class _RegisterSectionState extends ConsumerState<RegisterSection> {
int _regStep = 1;
bool _obscureRegPassword = true;
bool _emsSending = false;
int _emsCountdown = 0;
final _usernameController = TextEditingController();
final _emailController = TextEditingController();
final _regPasswordController = TextEditingController();
final _regConfirmPasswordController = TextEditingController();
final _regCodeController = TextEditingController();
@override
void initState() {
super.initState();
_usernameController.addListener(() => setState(() {}));
_emailController.addListener(() => setState(() {}));
_regPasswordController.addListener(() => setState(() {}));
_regConfirmPasswordController.addListener(() => setState(() {}));
_regCodeController.addListener(() => setState(() {}));
}
@override
void dispose() {
_usernameController.dispose();
_emailController.dispose();
_regPasswordController.dispose();
_regConfirmPasswordController.dispose();
_regCodeController.dispose();
super.dispose();
}
AppThemeExtension get ext => widget.ext;
@override
Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
return Column(
children: [
_buildHeader(),
_buildStepIndicator(),
const SizedBox(height: AppSpacing.md),
GlassContainer(
depth: GlassDepth.elevated,
padding: const EdgeInsets.all(AppSpacing.lg),
child: _buildRegStepContent(authState),
),
const SizedBox(height: AppSpacing.md),
CupertinoButton(
onPressed: () {
widget.onSwitchToLogin();
setState(() => _regStep = 1);
},
child: Text(
'已有账号?去登录',
style: AppTypography.subhead.copyWith(color: ext.accent),
),
),
],
);
}
Widget _buildHeader() {
return Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.xl),
child: Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [ext.accent, ext.accentLight],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: AppRadius.lgBorder,
),
child: Icon(
CupertinoIcons.person_add,
size: 28,
color: ext.textInverse,
),
),
const SizedBox(width: AppSpacing.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'创建账号',
style: AppTypography.title2.copyWith(
color: ext.textPrimary,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
'注册一个新账号开始使用',
style: AppTypography.subhead.copyWith(
color: ext.textSecondary,
),
),
],
),
),
],
),
);
}
Widget _buildStepIndicator() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(3, (i) {
final step = i + 1;
final isActive = step == _regStep;
final isDone = step < _regStep;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: isActive ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: isDone
? ext.accent.withValues(alpha: 0.5)
: isActive
? ext.accent
: ext.textHint.withValues(alpha: 0.3),
borderRadius: AppRadius.smBorder,
),
),
);
}),
);
}
Widget _buildRegStepContent(AuthState authState) {
switch (_regStep) {
case 1:
return Column(
children: [
LabeledInputField(
label: '用户名',
labelIcon: CupertinoIcons.person_add,
controller: _usernameController,
placeholder: '3-30位字母/数字/下划线/中文',
icon: CupertinoIcons.person_add,
ext: ext,
),
const SizedBox(height: AppSpacing.md),
LabeledInputField(
label: '邮箱(必填)',
labelIcon: CupertinoIcons.mail,
controller: _emailController,
placeholder: '用于验证和找回密码',
icon: CupertinoIcons.mail,
ext: ext,
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
width: double.infinity,
height: 50,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.mdBorder,
onPressed:
_usernameController.text.trim().isNotEmpty &&
_emailController.text.trim().isNotEmpty
? () => setState(() => _regStep = 2)
: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.arrow_right,
size: 16,
color: CupertinoColors.white,
),
const SizedBox(width: 6),
Text(
'下一步',
style: AppTypography.callout.copyWith(
color: CupertinoColors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
);
case 2:
return Column(
children: [
Padding(
padding: const EdgeInsets.only(bottom: AppSpacing.md),
child: Column(
children: [
Icon(CupertinoIcons.mail, size: 36, color: ext.accent),
const SizedBox(height: AppSpacing.xs),
Text(
'验证码已发送至 ${_emailController.text.trim()}',
style: AppTypography.footnote.copyWith(
color: ext.textSecondary,
),
textAlign: TextAlign.center,
),
],
),
),
LabeledInputField(
label: '验证码',
labelIcon: CupertinoIcons.number,
controller: _regCodeController,
placeholder: '输入6位验证码',
icon: CupertinoIcons.number,
ext: ext,
keyboardType: TextInputType.number,
suffix: SizedBox(
width: 90,
child: CupertinoButton(
color: ext.accent.withValues(alpha: 0.15),
borderRadius: AppRadius.mdBorder,
padding: EdgeInsets.zero,
onPressed: (_emsSending || _emsCountdown > 0)
? null
: () => _sendCode(_emailController.text.trim()),
child: _emsSending
? CupertinoActivityIndicator(radius: 6, color: ext.accent)
: Text(
_emsCountdown > 0 ? '${_emsCountdown}s' : '重新发送',
style: AppTypography.caption1.copyWith(
color: ext.accent,
),
),
),
),
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
width: double.infinity,
height: 50,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.mdBorder,
onPressed: _regCodeController.text.trim().isNotEmpty
? () => setState(() => _regStep = 3)
: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.checkmark_shield,
size: 16,
color: CupertinoColors.white,
),
const SizedBox(width: 6),
Text(
'验证并继续',
style: AppTypography.callout.copyWith(
color: CupertinoColors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
);
case 3:
return Column(
children: [
LabeledInputField(
label: '设置密码',
labelIcon: CupertinoIcons.lock,
controller: _regPasswordController,
placeholder: '6-30位密码',
icon: CupertinoIcons.lock,
ext: ext,
obscureText: _obscureRegPassword,
suffix: GestureDetector(
onTap: () =>
setState(() => _obscureRegPassword = !_obscureRegPassword),
child: Icon(
_obscureRegPassword
? CupertinoIcons.eye_slash
: CupertinoIcons.eye,
size: 18,
color: ext.textHint,
),
),
),
const SizedBox(height: AppSpacing.md),
LabeledInputField(
label: '确认密码',
labelIcon: CupertinoIcons.lock_shield,
controller: _regConfirmPasswordController,
placeholder: '再次输入密码',
icon: CupertinoIcons.lock_shield,
ext: ext,
obscureText: true,
),
const SizedBox(height: AppSpacing.lg),
SizedBox(
width: double.infinity,
height: 50,
child: CupertinoButton(
color: ext.accent,
borderRadius: AppRadius.mdBorder,
onPressed:
(_regPasswordController.text.isNotEmpty &&
_regConfirmPasswordController.text.isNotEmpty &&
!authState.isLoading)
? _handleRegister
: null,
child: authState.isLoading
? const CupertinoActivityIndicator(
color: CupertinoColors.white,
)
: Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
CupertinoIcons.checkmark_circle,
size: 16,
color: CupertinoColors.white,
),
const SizedBox(width: 6),
Text(
'完成注册',
style: AppTypography.callout.copyWith(
color: CupertinoColors.white,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
);
default:
return const SizedBox.shrink();
}
}
Future<void> _handleRegister() async {
final username = _usernameController.text.trim();
final password = _regPasswordController.text;
final confirmPassword = _regConfirmPasswordController.text;
final email = _emailController.text.trim();
final code = _regCodeController.text.trim();
if (username.isEmpty || password.isEmpty || email.isEmpty) {
AppToast.showWarning('请填写所有必填项');
return;
}
if (password != confirmPassword) {
AppToast.showError('两次密码不一致');
return;
}
if (password.length < 6) {
AppToast.showWarning('密码长度不能少于6位');
return;
}
if (code.isNotEmpty && !EmailService.verifyCode(email: email, code: code)) {
AppToast.showError('验证码错误');
return;
}
final success = await ref
.read(authProvider.notifier)
.register(username: username, password: password, email: email);
if (success && mounted) {
AppToast.showSuccess('注册成功,欢迎加入!');
widget.onRegisterSuccess();
}
}
Future<void> _sendCode(String email) async {
if (_emsSending || _emsCountdown > 0 || email.isEmpty) return;
if (!email.contains('@')) {
AppToast.showWarning('请输入有效的邮箱地址');
return;
}
setState(() {
_emsSending = true;
_emsCountdown = 60;
});
_startCountdown();
try {
final code = EmailService.generateCode();
final ok = await EmailService.sendVerificationCode(
toEmail: email,
code: code,
);
if (!ok && mounted) {
AppToast.showError('验证码发送失败,请检查邮箱地址');
setState(() {
_emsSending = false;
_emsCountdown = 0;
});
} else if (mounted) {
AppToast.showSuccess('验证码已发送');
setState(() => _emsSending = false);
}
} catch (e) {
Log.e('发送验证码失败', e);
if (mounted) {
AppToast.showError('验证码发送失败');
setState(() {
_emsSending = false;
_emsCountdown = 0;
});
}
}
}
void _startCountdown() {
Future.doWhile(() async {
await Future<void>.delayed(const Duration(seconds: 1));
if (!mounted) return false;
setState(() => _emsCountdown--);
return _emsCountdown > 0;
});
}
}

View File

@@ -3,12 +3,13 @@
/// 创建时间: 2026-04-28
/// 更新时间: 2026-04-29
/// 作用: 管理用户登录状态、Token、用户信息
/// 上次更新: v4.18.0 缓存优先初始化后台Token检测修复冷启动闪烁
/// 上次更新: v5.28.0 登录/注册成功后自动注册设备; 退出时重置设备注册
/// ============================================================
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_exception.dart';
import '../../../core/services/device_info_service.dart';
import '../../../core/storage/secure_storage.dart';
import '../../../core/utils/logger.dart';
import '../models/user_model.dart';
@@ -69,7 +70,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
final success = await AuthService.tryAutoLogin();
if (success) {
try {
final user = await AuthService.getUserInfo();
final data = await AuthService.getUserInfo();
final user = UserModel.fromJson(data);
state = state.copyWith(
user: user,
isLoggedIn: true,
@@ -103,7 +105,8 @@ class AuthNotifier extends StateNotifier<AuthState> {
Log.i('Token已失效且刷新失败已清除登录状态');
} else {
try {
final user = await AuthService.getUserInfo();
final data = await AuthService.getUserInfo();
final user = UserModel.fromJson(data);
state = state.copyWith(user: user);
} catch (_) {}
}
@@ -124,6 +127,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
password: password,
);
state = state.copyWith(user: user, isLoggedIn: true, isLoading: false);
DeviceInfoService.registerDeviceIfNeeded();
return true;
} on ApiException catch (e) {
state = state.copyWith(isLoading: false, error: e.message);
@@ -151,6 +155,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
mobileCode: mobileCode,
);
state = state.copyWith(user: user, isLoggedIn: true, isLoading: false);
DeviceInfoService.registerDeviceIfNeeded();
return true;
} on ApiException catch (e) {
state = state.copyWith(isLoading: false, error: e.message);
@@ -162,13 +167,15 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
Future<void> logout() async {
await DeviceInfoService.resetRegistration();
await AuthService.logout();
state = const AuthState();
}
Future<void> refreshUser() async {
try {
final user = await AuthService.getUserInfo();
final data = await AuthService.getUserInfo();
final user = UserModel.fromJson(data);
state = state.copyWith(user: user);
} catch (e) {
Log.e('刷新用户信息失败', e);
@@ -178,15 +185,15 @@ class AuthNotifier extends StateNotifier<AuthState> {
Future<bool> updateProfile({
String? nickname,
String? bio,
String? avatar,
String? avatarUrl,
}) async {
try {
final user = await AuthService.updateProfile(
await AuthService.updateProfile(
nickname: nickname,
bio: bio,
avatar: avatar,
avatarUrl: avatarUrl,
);
state = state.copyWith(user: user);
await refreshUser();
return true;
} on ApiException catch (e) {
state = state.copyWith(error: e.message);

View File

@@ -0,0 +1,145 @@
/// ============================================================
/// 闲言APP — 二维码登录状态管理
/// 创建时间: 2026-05-10
/// 更新时间: 2026-05-10
/// 作用: 管理扫码登录流程状态(扫描/确认/成功/错误)
/// 上次更新: 初始创建
/// ============================================================
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/utils/logger.dart';
import '../services/user_security_service.dart';
// ============================================================
// 状态定义
// ============================================================
/// 二维码登录步骤
enum QrcodeLoginStep {
idle,
scanning,
confirming,
success,
error,
}
/// 二维码登录状态
class QrcodeLoginState {
const QrcodeLoginState({
this.step = QrcodeLoginStep.idle,
this.qrCode = '',
this.qrcodeResult,
this.errorMessage,
});
final QrcodeLoginStep step;
final String qrCode;
final QrcodeGenerateResult? qrcodeResult;
final String? errorMessage;
QrcodeLoginState copyWith({
QrcodeLoginStep? step,
String? qrCode,
QrcodeGenerateResult? qrcodeResult,
bool clearQrcodeResult = false,
String? errorMessage,
bool clearError = false,
}) {
return QrcodeLoginState(
step: step ?? this.step,
qrCode: qrCode ?? this.qrCode,
qrcodeResult: clearQrcodeResult ? null : (qrcodeResult ?? this.qrcodeResult),
errorMessage: clearError ? null : (errorMessage ?? this.errorMessage),
);
}
bool get isIdle => step == QrcodeLoginStep.idle;
bool get isScanning => step == QrcodeLoginStep.scanning;
bool get isConfirming => step == QrcodeLoginStep.confirming;
bool get isSuccess => step == QrcodeLoginStep.success;
bool get isError => step == QrcodeLoginStep.error;
}
// ============================================================
// Notifier
// ============================================================
/// 二维码登录状态管理器
class QrcodeLoginNotifier extends StateNotifier<QrcodeLoginState> {
QrcodeLoginNotifier() : super(const QrcodeLoginState());
/// 扫码确认登录
Future<void> confirmLogin(String code) async {
if (code.isEmpty) return;
if (state.step == QrcodeLoginStep.confirming) return;
state = state.copyWith(
step: QrcodeLoginStep.confirming,
qrCode: code,
clearError: true,
);
try {
await UserSecurityService.qrcodeConfirm(
code: code,
platform: 'mobile',
appName: 'xianyan',
);
state = state.copyWith(step: QrcodeLoginStep.success);
Log.i('扫码确认登录成功: $code');
} catch (e) {
state = state.copyWith(
step: QrcodeLoginStep.error,
errorMessage: '扫码确认失败: $e',
);
Log.e('扫码确认登录失败', e);
}
}
/// 生成二维码Web端登录用
Future<void> generateQrcode() async {
state = state.copyWith(clearError: true);
try {
final result = await UserSecurityService.qrcodeGenerate();
state = state.copyWith(
qrCode: result.code,
qrcodeResult: result,
);
Log.i('二维码生成成功: ${result.code}');
} catch (e) {
state = state.copyWith(
step: QrcodeLoginStep.error,
errorMessage: '生成二维码失败: $e',
);
Log.e('生成二维码失败', e);
}
}
/// 取消/重置
void cancel() {
state = const QrcodeLoginState();
}
/// 重置到扫描状态
void resetToScan() {
state = const QrcodeLoginState(step: QrcodeLoginStep.scanning);
}
/// 清除错误
void clearError() {
state = state.copyWith(
step: QrcodeLoginStep.idle,
clearError: true,
);
}
}
// ============================================================
// Provider
// ============================================================
final qrcodeLoginProvider =
StateNotifierProvider<QrcodeLoginNotifier, QrcodeLoginState>((ref) {
return QrcodeLoginNotifier();
});

View File

@@ -1,365 +1,353 @@
/// ============================================================
/// 闲言APP — 认证服务门面模式)
/// 创建时间: 2026-04-28
/// 更新时间: 2026-04-29
/// 作用: 用户认证相关API统一入口委托给UserSecurityService和UserCenterService
/// 上次更新: v4.18.0 新增validateLocalToken缓存优先初始化
/// 闲言APP — 认证服务门面
/// 创建时间: 2026-04-29
/// 更新时间: 2026-05-10
/// 作用: 统一认证入口委托给UserSecurityService和UserCenterService
/// 上次更新: v9.0.0 同步设备信息参数+二维码登录+账号注销+设备管理
/// ============================================================
import '../../../core/network/api_client.dart';
import '../../../core/storage/secure_storage.dart';
import '../../../core/utils/logger.dart';
import 'user_security_service.dart';
import '../models/user_model.dart';
import '../../../core/services/token_service.dart';
import 'user_security_service.dart';
import '../../user_center/services/user_center_service.dart';
class AuthService {
AuthService._();
static final ApiClient _api = ApiClient.instance;
// ============================================================
// 登录相关 → UserSecurityService
// 登录
// ============================================================
/// 账号密码登录
static Future<UserModel> login({
required String account,
required String password,
}) => UserSecurityService.login(account: account, password: password);
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? deviceId,
}) async {
return UserSecurityService.login(
account: account,
password: password,
deviceName: deviceName,
deviceModel: deviceModel,
platform: platform,
appName: appName,
deviceId: deviceId,
);
}
/// 回执登录 (无需密码)
static Future<UserModel> receiptLogin({
required String account,
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? deviceId,
}) async {
return UserSecurityService.receiptLogin(
account: account,
deviceName: deviceName,
deviceModel: deviceModel,
platform: platform,
appName: appName,
deviceId: deviceId,
);
}
/// 手机号登录
static Future<UserModel> mobileLogin({
required String mobile,
required String captcha,
}) => UserSecurityService.mobileLogin(mobile: mobile, captcha: captcha);
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? deviceId,
}) async {
return UserSecurityService.mobileLogin(
mobile: mobile,
captcha: captcha,
deviceName: deviceName,
deviceModel: deviceModel,
platform: platform,
appName: appName,
deviceId: deviceId,
);
}
/// Token令牌登录
static Future<UserModel> tokenLogin(String token) =>
UserSecurityService.tokenLogin(token);
/// 退出登录
static Future<void> logout() => UserSecurityService.logout();
static Future<UserModel> tokenLogin(
String token, {
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? deviceId,
}) async {
return UserSecurityService.tokenLogin(
token,
deviceName: deviceName,
deviceModel: deviceModel,
platform: platform,
appName: appName,
deviceId: deviceId,
);
}
/// 第三方平台登录
static Future<UserModel> thirdLogin({
required String platform,
required String code,
}) => UserSecurityService.thirdLogin(platform: platform, code: code);
}) async {
return UserSecurityService.thirdLogin(platform: platform, code: code);
}
/// 回执登录 (账号+回执+签名,无需密码)
static Future<UserModel> receiptLogin({required String account}) =>
UserSecurityService.receiptLogin(account: account);
/// 退出登录
static Future<void> logout() async {
await UserSecurityService.logout();
}
// ============================================================
// 注册 → UserSecurityService
// 注册
// ============================================================
/// 注册新用户 (回执验证替代邮箱验证码)
/// 注册新用户 (回执验证)
static Future<UserModel> register({
required String username,
required String password,
required String email,
String? mobile,
String? mobileCode,
}) => UserSecurityService.register(
username: username,
password: password,
email: email,
mobile: mobile,
mobileCode: mobileCode,
);
}) async {
return UserSecurityService.register(
username: username,
password: password,
email: email,
mobile: mobile,
mobileCode: mobileCode,
);
}
// ============================================================
// 密码管理 → UserSecurityService
// 密码管理
// ============================================================
/// 修改密码 (非测试用户需回执验证)
/// 修改密码
static Future<void> changePassword({
required String oldPassword,
required String newPassword,
String? userId,
}) => UserSecurityService.changePassword(
oldPassword: oldPassword,
newPassword: newPassword,
userId: userId,
);
}) async {
return UserSecurityService.changePassword(
oldPassword: oldPassword,
newPassword: newPassword,
userId: userId,
);
}
/// 重置密码 (回执验证)
/// 重置密码
static Future<void> resetPassword({
required String newPassword,
String type = 'email',
String? email,
String? mobile,
}) => UserSecurityService.resetPassword(
newPassword: newPassword,
type: type,
email: email,
mobile: mobile,
);
}) async {
return UserSecurityService.resetPassword(
newPassword: newPassword,
type: type,
email: email,
mobile: mobile,
);
}
// ============================================================
// 邮箱/手机变更 → UserSecurityService
// 邮箱/手机变更
// ============================================================
/// 修改邮箱 (回执验证)
static Future<void> changeEmail({required String email}) =>
UserSecurityService.changeEmail(email: email);
static Future<Map<String, dynamic>> changeEmail({
required String email,
}) async {
return UserCenterService.changeEmail(email: email);
}
/// 修改手机号 (回执验证)
static Future<void> changeMobile({required String mobile}) =>
UserSecurityService.changeMobile(mobile: mobile);
static Future<Map<String, dynamic>> changeMobile({
required String mobile,
}) async {
return UserCenterService.changeMobile(mobile: mobile);
}
// ============================================================
// 验证码 → UserSecurityService
// 验证码
// ============================================================
/// 发送邮箱验证码
static Future<void> sendEms({required String email, required String event}) =>
UserSecurityService.sendEms(email: email, event: event);
static Future<void> sendEms({
required String email,
required String event,
}) async {
return UserSecurityService.sendEms(email: email, event: event);
}
/// 校验邮箱验证码
static Future<bool> checkEms({
required String email,
required String captcha,
required String event,
}) => UserSecurityService.checkEms(
email: email,
captcha: captcha,
event: event,
);
}) async {
return UserSecurityService.checkEms(
email: email,
captcha: captcha,
event: event,
);
}
/// 发送短信验证码
static Future<void> sendSms({required String mobile}) async {
return UserSecurityService.sendSms(mobile: mobile);
}
// ============================================================
// 用户信息 → UserCenterService
// 二维码登录 (v9.0.0新增)
// ============================================================
/// 获取当前用户信息
static Future<UserModel> getUserInfo() async {
final data = await UserCenterService.getUserInfo();
return UserModel.fromJson(data);
/// 生成二维码
static Future<QrcodeGenerateResult> qrcodeGenerate() async {
return UserSecurityService.qrcodeGenerate();
}
/// 扫码确认
static Future<void> qrcodeConfirm({
required String code,
String? platform,
String? deviceName,
String? appName,
}) async {
return UserSecurityService.qrcodeConfirm(
code: code,
platform: platform,
deviceName: deviceName,
appName: appName,
);
}
/// 轮询二维码状态
static Future<QrcodePollResult> qrcodePoll({required String code}) async {
return UserSecurityService.qrcodePoll(code: code);
}
/// 取消二维码
static Future<void> qrcodeCancel({required String code}) async {
return UserSecurityService.qrcodeCancel(code: code);
}
// ============================================================
// 账号注销 (v9.0.0新增)
// ============================================================
/// 申请账号注销 (3天审核期)
static Future<Map<String, dynamic>> requestDeletion({
required String userId,
String? reason,
}) async {
return UserSecurityService.requestDeletion(userId: userId, reason: reason);
}
/// 查询注销状态
static Future<Map<String, dynamic>> deletionStatus() async {
return UserSecurityService.deletionStatus();
}
/// 取消注销申请
static Future<void> cancelDeletion() async {
return UserSecurityService.cancelDeletion();
}
// ============================================================
// 设备管理 (v1.5.0新增)
// ============================================================
/// 设备操作
static Future<Map<String, dynamic>> devices({
required String action,
int? deviceId,
}) async {
return UserCenterService.devices(action: action, deviceId: deviceId);
}
/// 注册设备
static Future<Map<String, dynamic>> registerDevice({
required String deviceId,
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
}) async {
return UserCenterService.registerDevice(
deviceId: deviceId,
deviceName: deviceName,
deviceModel: deviceModel,
platform: platform,
appName: appName,
);
}
// ============================================================
// 用户信息
// ============================================================
/// 获取用户信息
static Future<Map<String, dynamic>> getUserInfo() async {
return UserCenterService.getUserInfo();
}
/// 修改个人信息
static Future<UserModel> updateProfile({
static Future<Map<String, dynamic>> updateProfile({
String? username,
String? nickname,
String? bio,
String? avatar,
String? avatarUrl,
}) async {
final data = await UserCenterService.updateProfile(
return UserCenterService.updateProfile(
username: username,
nickname: nickname,
bio: bio,
avatar: avatar,
avatarUrl: avatarUrl,
);
return UserModel.fromJson(data);
}
// ============================================================
// 自动登录
// ============================================================
static Future<bool> tryAutoLogin() async {
final token = await SecureStorage.authToken;
if (token == null || token.isEmpty) return false;
try {
await tokenLogin(token);
return true;
} catch (e) {
Log.w('自动登录失败: $e');
return false;
}
/// 获取缓存用户
static Future<UserModel?> getCachedUser() async {
return UserSecurityService.getCachedUser();
}
/// 验证本地Token是否有效
static Future<bool> validateLocalToken() async {
final token = await SecureStorage.authToken;
if (token == null || token.isEmpty) return false;
try {
final result = await checkToken();
return result.valid;
} catch (e) {
Log.w('本地Token验证失败: $e');
final info = await getUserInfo();
return info.isNotEmpty;
} catch (_) {
return false;
}
}
// ============================================================
// Token管理 (独立路径 /api/token/*)
// ============================================================
static Future<TokenCheckResult> checkToken() async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/token/check',
);
final data = response.data as Map<String, dynamic>;
final code = data['code'] as int? ?? 0;
if (code == 1) {
final tokenData = data['data'] as Map<String, dynamic>? ?? {};
return TokenCheckResult(
valid: true,
expiresIn: tokenData['expires_in'] as int? ?? 0,
);
}
return const TokenCheckResult(valid: false);
} catch (e) {
Log.e('Token检测失败', e);
return const TokenCheckResult(valid: false);
}
/// 尝试自动登录使用已存储的Token
static Future<bool> tryAutoLogin() async {
return validateLocalToken();
}
/// 刷新Token
static Future<bool> refreshToken() async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/token/refresh',
);
final data = response.data as Map<String, dynamic>;
final code = data['code'] as int? ?? 0;
if (code == 1) {
final tokenData = data['data'] as Map<String, dynamic>?;
final newToken = tokenData?['token'] as String?;
if (newToken != null && newToken.isNotEmpty) {
await SecureStorage.setAuthToken(newToken);
Log.i('Token刷新成功');
return true;
}
}
return false;
} catch (e) {
Log.e('Token刷新失败', e);
return false;
}
return TokenService.refreshToken();
}
// ============================================================
// 验证接口 (独立路径 /api/validate/*)
// ============================================================
static Future<bool> checkEmailAvailable(String email) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/validate/check_email_available',
data: {'email': email},
);
final data = response.data as Map<String, dynamic>;
return data['code'] == 1;
} catch (e) {
return false;
}
/// 检测Token状态
static Future<TokenCheckResult> checkToken() async {
return TokenService.checkToken();
}
static Future<bool> checkUsernameAvailable(String username) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/validate/check_username_available',
data: {'username': username},
);
final data = response.data as Map<String, dynamic>;
return data['code'] == 1;
} catch (e) {
return false;
}
}
static Future<bool> checkNicknameAvailable(String nickname) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/validate/check_nickname_available',
data: {'nickname': nickname},
);
final data = response.data as Map<String, dynamic>;
return data['code'] == 1;
} catch (e) {
return false;
}
}
static Future<bool> checkMobileAvailable(String mobile) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/validate/check_mobile_available',
data: {'mobile': mobile},
);
final data = response.data as Map<String, dynamic>;
return data['code'] == 1;
} catch (e) {
return false;
}
}
/// 检测手机号是否已存在
static Future<bool> checkMobileExist(String mobile) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/validate/check_mobile_exist',
data: {'mobile': mobile},
);
final data = response.data as Map<String, dynamic>;
return data['code'] == 1;
} catch (e) {
return false;
}
}
/// 检测邮箱是否已存在
static Future<bool> checkEmailExist(String email) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/validate/check_email_exist',
data: {'email': email},
);
final data = response.data as Map<String, dynamic>;
return data['code'] == 1;
} catch (e) {
return false;
}
}
/// 验证短信验证码
static Future<bool> checkSmsCorrect({
required String mobile,
required String captcha,
}) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/validate/check_sms_correct',
data: {'mobile': mobile, 'captcha': captcha},
);
final data = response.data as Map<String, dynamic>;
return data['code'] == 1;
} catch (e) {
return false;
}
}
/// 验证邮箱验证码
static Future<bool> checkEmsCorrect({
required String email,
required String captcha,
}) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/validate/check_ems_correct',
data: {'email': email, 'captcha': captcha},
);
final data = response.data as Map<String, dynamic>;
return data['code'] == 1;
} catch (e) {
return false;
}
}
// ============================================================
// 缓存
// ============================================================
static Future<UserModel?> getCachedUser() =>
UserSecurityService.getCachedUser();
}
class TokenCheckResult {
const TokenCheckResult({required this.valid, this.expiresIn = 0});
final bool valid;
final int expiresIn;
}

View File

@@ -1,9 +1,9 @@
/// ============================================================
/// 闲言APP — 用户安全服务
/// 创建时间: 2026-04-29
/// 更新时间: 2026-04-30
/// 作用: 用户安全相关API封装登录/注册/密码/邮箱/手机/回执验证/第三方)
/// 上次更新: v8.0.0 回执验证替代邮箱验证码新增receiptLogin
/// 更新时间: 2026-05-10
/// 作用: 用户安全相关API封装登录/注册/密码/邮箱/手机/回执验证/第三方/二维码登录
/// 上次更新: v9.0.0 新增设备信息参数+二维码登录4接口+账号注销
/// ============================================================
import 'package:dio/dio.dart';
@@ -31,11 +31,26 @@ class UserSecurityService {
static Future<UserModel> login({
required String account,
required String password,
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? deviceId,
}) async {
try {
final data = <String, dynamic>{'account': account, 'password': password};
_appendDeviceInfo(
data,
deviceName,
deviceModel,
platform,
appName,
deviceId,
);
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/login',
data: {'account': account, 'password': password},
data: data,
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
@@ -43,10 +58,11 @@ class UserSecurityService {
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
final data = apiResp.data;
if (data == null) throw const ApiException(code: 0, message: '登录数据为空');
final respData = apiResp.data;
if (respData == null)
throw const ApiException(code: 0, message: '登录数据为空');
final userinfo = data['userinfo'] as Map<String, dynamic>?;
final userinfo = respData['userinfo'] as Map<String, dynamic>?;
if (userinfo == null) {
throw const ApiException(code: 0, message: '用户信息为空');
}
@@ -70,6 +86,11 @@ class UserSecurityService {
/// 回执登录 (账号+回执+签名,无需密码)
static Future<UserModel> receiptLogin({
required String account,
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? deviceId,
}) async {
try {
final receipt = ReceiptHelper.generate(
@@ -77,13 +98,23 @@ class UserSecurityService {
account,
);
final data = <String, dynamic>{
'account': account,
'receipt': receipt.receipt,
'sig': receipt.sig,
};
_appendDeviceInfo(
data,
deviceName,
deviceModel,
platform,
appName,
deviceId,
);
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/receiptLogin',
data: {
'account': account,
'receipt': receipt.receipt,
'sig': receipt.sig,
},
data: data,
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
@@ -91,10 +122,11 @@ class UserSecurityService {
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
final data = apiResp.data;
if (data == null) throw const ApiException(code: 0, message: '登录数据为空');
final respData = apiResp.data;
if (respData == null)
throw const ApiException(code: 0, message: '登录数据为空');
final userinfo = data['userinfo'] as Map<String, dynamic>?;
final userinfo = respData['userinfo'] as Map<String, dynamic>?;
if (userinfo == null) {
throw const ApiException(code: 0, message: '用户信息为空');
}
@@ -119,11 +151,26 @@ class UserSecurityService {
static Future<UserModel> mobileLogin({
required String mobile,
required String captcha,
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? deviceId,
}) async {
try {
final data = <String, dynamic>{'mobile': mobile, 'captcha': captcha};
_appendDeviceInfo(
data,
deviceName,
deviceModel,
platform,
appName,
deviceId,
);
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/mobilelogin',
data: {'mobile': mobile, 'captcha': captcha},
data: data,
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
@@ -131,10 +178,11 @@ class UserSecurityService {
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
final data = apiResp.data;
if (data == null) throw const ApiException(code: 0, message: '登录数据为空');
final respData = apiResp.data;
if (respData == null)
throw const ApiException(code: 0, message: '登录数据为空');
final userinfo = data['userinfo'] as Map<String, dynamic>?;
final userinfo = respData['userinfo'] as Map<String, dynamic>?;
if (userinfo == null) {
throw const ApiException(code: 0, message: '用户信息为空');
}
@@ -156,11 +204,28 @@ class UserSecurityService {
}
/// Token令牌登录
static Future<UserModel> tokenLogin(String token) async {
static Future<UserModel> tokenLogin(
String token, {
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? deviceId,
}) async {
try {
final data = <String, dynamic>{'token': token};
_appendDeviceInfo(
data,
deviceName,
deviceModel,
platform,
appName,
deviceId,
);
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/tokenLogin',
data: {'token': token},
data: data,
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
@@ -169,10 +234,11 @@ class UserSecurityService {
await SecureStorage.removeAuthToken();
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
final data = apiResp.data;
if (data == null) throw const ApiException(code: 0, message: '登录数据为空');
final respData = apiResp.data;
if (respData == null)
throw const ApiException(code: 0, message: '登录数据为空');
final userinfo = data['userinfo'] as Map<String, dynamic>?;
final userinfo = respData['userinfo'] as Map<String, dynamic>?;
if (userinfo == null) {
throw const ApiException(code: 0, message: '用户信息为空');
}
@@ -213,10 +279,7 @@ class UserSecurityService {
String? mobileCode,
}) async {
try {
final receipt = ReceiptHelper.generate(
ReceiptAction.register,
email,
);
final receipt = ReceiptHelper.generate(ReceiptAction.register, email);
final data = <String, dynamic>{
'username': username,
@@ -266,10 +329,7 @@ class UserSecurityService {
};
if (userId != null && userId.isNotEmpty) {
final receipt = ReceiptHelper.generate(
ReceiptAction.changepwd,
userId,
);
final receipt = ReceiptHelper.generate(ReceiptAction.changepwd, userId);
data['receipt'] = receipt.receipt;
data['sig'] = receipt.sig;
}
@@ -299,10 +359,7 @@ class UserSecurityService {
}) async {
try {
final payload = type == 'email' ? (email ?? '') : (mobile ?? '');
final receipt = ReceiptHelper.generate(
ReceiptAction.resetpwd,
payload,
);
final receipt = ReceiptHelper.generate(ReceiptAction.resetpwd, payload);
final data = <String, dynamic>{
'type': type,
@@ -330,24 +387,17 @@ class UserSecurityService {
}
// ============================================================
// 邮箱/手机变更
// 邮箱/手机变更 (回执验证)
// ============================================================
/// 修改邮箱 (需登录,回执验证)
static Future<void> changeEmail({required String email}) async {
try {
final receipt = ReceiptHelper.generate(
ReceiptAction.changeemail,
email,
);
final receipt = ReceiptHelper.generate(ReceiptAction.changeemail, email);
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/changeemail',
data: {
'email': email,
'receipt': receipt.receipt,
'sig': receipt.sig,
},
data: {'email': email, 'receipt': receipt.receipt, 'sig': receipt.sig},
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
@@ -389,6 +439,195 @@ class UserSecurityService {
}
}
// ============================================================
// 二维码登录 (v9.0.0新增)
// ============================================================
/// 生成二维码 (Web端调用)
static Future<QrcodeGenerateResult> qrcodeGenerate() async {
try {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/qrcodeGenerate',
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
);
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
final data = apiResp.data ?? {};
return QrcodeGenerateResult(
code: data['code'] as String? ?? '',
expireTime: data['expire_time'] as int? ?? 0,
expireSeconds: data['expire_seconds'] as int? ?? 300,
qrcodeUrl: data['qrcode_url'] as String? ?? '',
);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// 扫码确认 (APP端调用需登录)
static Future<void> qrcodeConfirm({
required String code,
String? platform,
String? deviceName,
String? appName,
}) async {
try {
final data = <String, dynamic>{'code': code};
if (platform != null) data['platform'] = platform;
if (deviceName != null) data['device_name'] = deviceName;
if (appName != null) data['app_name'] = appName;
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/qrcodeConfirm',
data: data,
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
);
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
Log.i('扫码确认成功');
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// 轮询二维码状态 (Web端调用)
static Future<QrcodePollResult> qrcodePoll({required String code}) async {
try {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/qrcodePoll',
queryParameters: {'code': code},
);
final respData = response.data as Map<String, dynamic>;
final respCode = respData['code'] as int? ?? 0;
final data = respData['data'] as Map<String, dynamic>? ?? {};
final status = data['status'] as String? ?? 'pending';
final message = data['message'] as String? ?? '';
String? token;
UserModel? userinfo;
if (status == 'confirmed') {
token = data['token'] as String?;
if (data['userinfo'] != null) {
userinfo = UserModel.fromJson(
data['userinfo'] as Map<String, dynamic>,
);
}
}
return QrcodePollResult(
success: respCode == 1 || status == 'pending' || status == 'scanned',
status: status,
message: message.isNotEmpty
? message
: (respData['msg'] as String? ?? ''),
token: token,
userinfo: userinfo,
);
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// 取消二维码 (Web端调用)
static Future<void> qrcodeCancel({required String code}) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/qrcodeCancel',
data: {'code': code},
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
);
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
Log.i('二维码已取消');
} on DioException catch (e) {
throw _handleDioError(e);
}
}
// ============================================================
// 账号注销 (v9.0.0新增)
// ============================================================
/// 申请账号注销 (需登录,回执验证)
/// 提交后进入3天审核期管理员审核通过或超时后自动删除用户及所有数据
static Future<Map<String, dynamic>> requestDeletion({
required String userId,
String? reason,
}) async {
try {
final receipt = ReceiptHelper.generate(
ReceiptAction.deleteAccount,
userId,
);
final data = <String, dynamic>{
'receipt': receipt.receipt,
'sig': receipt.sig,
};
if (reason != null && reason.isNotEmpty) data['reason'] = reason;
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/requestDeletion',
data: data,
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
);
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
Log.i('账号注销申请已提交');
return apiResp.data ?? {};
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// 查询注销状态 (需登录)
static Future<Map<String, dynamic>> deletionStatus() async {
try {
final response = await _api.get<Map<String, dynamic>>(
'$_basePath/deletionStatus',
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
);
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
return apiResp.data ?? {};
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// 取消注销申请 (需登录)
static Future<void> cancelDeletion() async {
try {
final response = await _api.post<Map<String, dynamic>>(
'$_basePath/cancelDeletion',
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
);
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
Log.i('注销申请已取消');
} on DioException catch (e) {
throw _handleDioError(e);
}
}
// ============================================================
// 验证码 (保留兼容旧客户端)
// ============================================================
@@ -433,6 +672,44 @@ class UserSecurityService {
}
}
/// 发送短信验证码
static Future<void> sendSms({required String mobile}) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/sms/send',
data: {'mobile': mobile},
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
);
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
Log.i('短信验证码发送成功: $mobile');
} on DioException catch (e) {
throw _handleDioError(e);
}
}
/// 发送邮箱验证码 (公共接口)
static Future<void> sendEmsPublic({required String email}) async {
try {
final response = await _api.post<Map<String, dynamic>>(
'/api/ems/send',
data: {'email': email},
);
final apiResp = ApiResponse<Map<String, dynamic>>.fromJson(
response.data as Map<String, dynamic>,
);
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
Log.i('邮箱验证码发送成功: $email');
} on DioException catch (e) {
throw _handleDioError(e);
}
}
// ============================================================
// 第三方登录
// ============================================================
@@ -453,10 +730,11 @@ class UserSecurityService {
if (!apiResp.isSuccess) {
throw ApiException(code: apiResp.code, message: apiResp.msg);
}
final data = apiResp.data;
if (data == null) throw const ApiException(code: 0, message: '登录数据为空');
final respData = apiResp.data;
if (respData == null)
throw const ApiException(code: 0, message: '登录数据为空');
final userinfo = data['userinfo'] as Map<String, dynamic>?;
final userinfo = respData['userinfo'] as Map<String, dynamic>?;
if (userinfo == null) {
throw const ApiException(code: 0, message: '用户信息为空');
}
@@ -481,6 +759,21 @@ class UserSecurityService {
// 私有方法
// ============================================================
static void _appendDeviceInfo(
Map<String, dynamic> data,
String? deviceName,
String? deviceModel,
String? platform,
String? appName,
String? deviceId,
) {
if (deviceName != null) data['device_name'] = deviceName;
if (deviceModel != null) data['device_model'] = deviceModel;
if (platform != null) data['platform'] = platform;
if (appName != null) data['app_name'] = appName;
if (deviceId != null) data['device_id'] = deviceId;
}
static Future<void> _cacheUserInfo(Map<String, dynamic> userinfo) async {
try {
final encoded = _encodeMap(userinfo);
@@ -554,3 +847,45 @@ class EmsEvent {
static const String resetpwd = 'resetpwd';
static const String changeemail = 'changeemail';
}
/// 二维码生成结果
class QrcodeGenerateResult {
const QrcodeGenerateResult({
required this.code,
required this.expireTime,
required this.expireSeconds,
required this.qrcodeUrl,
});
final String code;
final int expireTime;
final int expireSeconds;
final String qrcodeUrl;
bool get isExpired =>
DateTime.now().millisecondsSinceEpoch ~/ 1000 > expireTime;
}
/// 二维码轮询结果
class QrcodePollResult {
const QrcodePollResult({
required this.success,
required this.status,
this.message = '',
this.token,
this.userinfo,
});
final bool success;
final String status;
final String message;
final String? token;
final UserModel? userinfo;
bool get isPending => status == 'pending';
bool get isScanned => status == 'scanned';
bool get isConfirmed => status == 'confirmed';
bool get isExpired => status == 'expired';
bool get isCancelled => status == 'cancelled';
bool get isTerminal => isConfirmed || isExpired || isCancelled;
}