chore: 批量代码优化与功能迭代更新
本次提交包含大量代码优化、功能新增与服务端配置更新: 1. 修复分析报告统计数据,调整CMake策略设置 2. 优化APP权限配置、编辑器与聊天界面组件 3. 更新依赖库版本与pubspec配置 4. 新增文件传输服务端、信令服务器相关配置与脚本 5. 完善用户注销功能与数据库迁移脚本 6. 优化多处动画效果、代码风格与日志输出 7. 新增多种调试与部署脚本,修复已知BUG
This commit is contained in:
@@ -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? ?? '',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
522
lib/features/auth/presentation/login_form_sections.dart
Normal file
522
lib/features/auth/presentation/login_form_sections.dart
Normal 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
993
lib/features/auth/presentation/qrcode_login_page.dart
Normal file
993
lib/features/auth/presentation/qrcode_login_page.dart
Normal 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;
|
||||
}
|
||||
482
lib/features/auth/presentation/register_section.dart
Normal file
482
lib/features/auth/presentation/register_section.dart
Normal 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
145
lib/features/auth/providers/qrcode_login_provider.dart
Normal file
145
lib/features/auth/providers/qrcode_login_provider.dart
Normal 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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user