feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建
This commit is contained in:
commit
f61b7bf90b
120 files changed
+15229
No files matched your search
@@ -0,0 +1,95 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../../core/providers.dart';
|
||||
import '../../../core/storage/token_storage.dart';
|
||||
import '../data/auth_api.dart';
|
||||
import '../data/auth_models.dart';
|
||||
|
||||
final authApiProvider = Provider<AuthApi>(
|
||||
(ref) => AuthApi(ref.watch(apiClientProvider)),
|
||||
);
|
||||
|
||||
class SessionState {
|
||||
const SessionState({this.user, required this.isLoggedIn});
|
||||
|
||||
final UserInfo? user;
|
||||
final bool isLoggedIn;
|
||||
|
||||
SessionState copyWith({UserInfo? user, bool? isLoggedIn, bool clearUser = false}) =>
|
||||
SessionState(
|
||||
user: clearUser ? null : (user ?? this.user),
|
||||
isLoggedIn: isLoggedIn ?? this.isLoggedIn,
|
||||
);
|
||||
}
|
||||
|
||||
/// 登录态,对应 uni-app 版 `store/user.ts`。
|
||||
class SessionController extends Notifier<SessionState> {
|
||||
TokenStorage get _storage => ref.read(tokenStorageProvider);
|
||||
|
||||
@override
|
||||
SessionState build() {
|
||||
// 拦截器判定会话失效(刷新失败或非过期类 401)时,清空本地会话并回到登录页。
|
||||
final sub = ref
|
||||
.watch(apiClientProvider)
|
||||
.onUnauthorized
|
||||
.listen((_) => clearLocal());
|
||||
ref.onDispose(sub.cancel);
|
||||
|
||||
final cached = _storage.userInfo;
|
||||
return SessionState(
|
||||
user: cached == null ? null : UserInfo.fromJson(cached),
|
||||
isLoggedIn: _storage.hasSession,
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> login({
|
||||
required String username,
|
||||
required String password,
|
||||
}) async {
|
||||
final res = await ref
|
||||
.read(authApiProvider)
|
||||
.login(username: username, password: password);
|
||||
if (!res.isOk || res.data == null) return false;
|
||||
|
||||
final data = res.data!;
|
||||
await _storage.setAccessToken(data.accessToken);
|
||||
if (data.refreshToken.isNotEmpty) {
|
||||
await _storage.setRefreshToken(data.refreshToken);
|
||||
}
|
||||
final user = UserInfo(
|
||||
id: data.userInfo.id,
|
||||
username: data.userInfo.username,
|
||||
realName: data.userInfo.realName,
|
||||
roles: data.roles,
|
||||
);
|
||||
await _storage.setUserInfo(user.toJson());
|
||||
state = SessionState(user: user, isLoggedIn: true);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> fetchUserInfo() async {
|
||||
try {
|
||||
final res = await ref.read(authApiProvider).currentUser();
|
||||
if (res.isOk && res.data != null) {
|
||||
await _storage.setUserInfo(res.data!.toJson());
|
||||
state = SessionState(user: res.data, isLoggedIn: true);
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
// 拉取失败保持原状态
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
await _storage.clearSession();
|
||||
state = const SessionState(isLoggedIn: false, user: null);
|
||||
}
|
||||
|
||||
/// 清空本地会话但不触发网络请求(被拦截器判定会话失效时调用)。
|
||||
Future<void> clearLocal() => logout();
|
||||
}
|
||||
|
||||
final sessionProvider = NotifierProvider<SessionController, SessionState>(
|
||||
SessionController.new,
|
||||
);
|
||||
@@ -0,0 +1,27 @@
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/network/api_response.dart';
|
||||
import 'auth_models.dart';
|
||||
|
||||
/// 认证相关接口,对应 uni-app 版 `api/auth.ts`。
|
||||
class AuthApi {
|
||||
AuthApi(this._client);
|
||||
|
||||
final ApiClient _client;
|
||||
|
||||
Future<ApiResponse<LoginResult>> login({
|
||||
required String username,
|
||||
required String password,
|
||||
}) => _client.post<LoginResult>(
|
||||
'/auth/login',
|
||||
body: {'username': username, 'password': password},
|
||||
parse: (d) => LoginResult.fromJson((d as Map).cast<String, dynamic>()),
|
||||
needAuth: false,
|
||||
);
|
||||
|
||||
Future<ApiResponse<UserInfo>> currentUser() => _client.get<UserInfo>(
|
||||
'/auth/currentUser',
|
||||
parse: (d) => UserInfo.fromJson((d as Map).cast<String, dynamic>()),
|
||||
);
|
||||
|
||||
Future<ApiResponse<void>> logout() => _client.post<void>('/auth/logout');
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import '../../../core/utils/id_util.dart';
|
||||
|
||||
/// 登录态用户信息,对应 `types/auth.ts` 的 `UserInfo`。
|
||||
class UserInfo {
|
||||
const UserInfo({
|
||||
required this.id,
|
||||
required this.username,
|
||||
required this.realName,
|
||||
required this.roles,
|
||||
});
|
||||
|
||||
final int id;
|
||||
final String username;
|
||||
final String realName;
|
||||
final List<String> roles;
|
||||
|
||||
String get displayName => realName.isNotEmpty ? realName : username;
|
||||
|
||||
/// 头像占位文字:取显示名首个字符(按 Unicode 码点截取,兼容中文)。
|
||||
String get avatarText {
|
||||
if (displayName.isEmpty) return '?';
|
||||
return String.fromCharCode(displayName.runes.first);
|
||||
}
|
||||
|
||||
bool get isAdmin => roles.contains('ADMIN');
|
||||
|
||||
factory UserInfo.fromJson(Map<String, dynamic> json) => UserInfo(
|
||||
id: toIntOr(json['id'], 0),
|
||||
username: json['username'] as String? ?? '',
|
||||
realName: json['realName'] as String? ?? '',
|
||||
roles: (json['roles'] as List?)?.map((e) => e.toString()).toList() ?? const [],
|
||||
);
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'id': id,
|
||||
'username': username,
|
||||
'realName': realName,
|
||||
'roles': roles,
|
||||
};
|
||||
}
|
||||
|
||||
/// 登录接口返回体,对应 `types/auth.ts` 的 `LoginResponse`。
|
||||
class LoginResult {
|
||||
const LoginResult({
|
||||
required this.accessToken,
|
||||
required this.refreshToken,
|
||||
required this.userInfo,
|
||||
required this.roles,
|
||||
});
|
||||
|
||||
final String accessToken;
|
||||
final String refreshToken;
|
||||
final UserInfo userInfo;
|
||||
final List<String> roles;
|
||||
|
||||
factory LoginResult.fromJson(Map<String, dynamic> json) => LoginResult(
|
||||
accessToken: json['accessToken'] as String? ?? '',
|
||||
refreshToken: json['refreshToken'] as String? ?? '',
|
||||
userInfo: UserInfo.fromJson(
|
||||
(json['userInfo'] as Map?)?.cast<String, dynamic>() ?? const {},
|
||||
),
|
||||
roles: (json['roles'] as List?)?.map((e) => e.toString()).toList() ?? const [],
|
||||
);
|
||||
}
|
||||
|
||||
/// 后端角色编码 → 中文名称映射。
|
||||
const roleLabelMap = <String, String>{
|
||||
'ADMIN': '运营管理员',
|
||||
'SALESMAN': '业务员',
|
||||
'WORKER': '共享电工',
|
||||
'CUSTOMER': '客户',
|
||||
};
|
||||
|
||||
/// 将角色编码数组映射为可读文本(多个角色以 / 分隔)。
|
||||
String roleLabel(List<String>? roles) {
|
||||
if (roles != null && roles.isNotEmpty) {
|
||||
return roles.map((r) => roleLabelMap[r] ?? r).join(' / ');
|
||||
}
|
||||
return '运营管理员';
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../core/theme/app_colors.dart';
|
||||
import '../../../core/theme/app_dimens.dart';
|
||||
import '../../../core/theme/app_typography.dart';
|
||||
import '../../../shared/widgets/x_card.dart';
|
||||
import '../../../shared/widgets/x_common.dart';
|
||||
import '../../../shared/widgets/x_icon.dart';
|
||||
import '../application/session_controller.dart';
|
||||
|
||||
/// 登录页,对应 `pages/login/index.vue`。
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _username = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
|
||||
bool _loading = false;
|
||||
String _errorMsg = '';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_username.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _handleLogin() async {
|
||||
final username = _username.text.trim();
|
||||
final password = _password.text;
|
||||
|
||||
if (username.isEmpty || password.isEmpty) {
|
||||
setState(() => _errorMsg = '请输入账号和密码');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_errorMsg = '';
|
||||
});
|
||||
|
||||
try {
|
||||
final ok = await ref
|
||||
.read(sessionProvider.notifier)
|
||||
.login(username: username, password: password);
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
context.go('/tabs');
|
||||
} else {
|
||||
setState(() => _errorMsg = '登录失败,请检查账号密码');
|
||||
}
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _errorMsg = e.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.bgPage,
|
||||
body: Stack(
|
||||
children: [
|
||||
// 源页面右上/左下的两处柔光渐变
|
||||
const Positioned(
|
||||
right: -80,
|
||||
top: -80,
|
||||
child: _SoftBlob(color: Color(0x0F0054CD), size: 280),
|
||||
),
|
||||
const Positioned(
|
||||
left: -80,
|
||||
bottom: -80,
|
||||
child: _SoftBlob(color: Color(0x0D712AE2), size: 280),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (context, constraints) => SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40),
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: (constraints.maxHeight - 80).clamp(0, double.infinity),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_buildBrand(),
|
||||
const SizedBox(height: 40),
|
||||
_buildForm(),
|
||||
const SizedBox(height: 40),
|
||||
Text(
|
||||
'© 鑫潼智电 · 运营管理',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBrand() {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.brandGradientStart, AppColors.brandGradientEnd],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x401D6FFF),
|
||||
blurRadius: 20,
|
||||
offset: Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: const XIcon('bolt', size: 34, color: Colors.white),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'鑫潼智电',
|
||||
style: AppTypography.headlineMd.copyWith(
|
||||
fontSize: 26,
|
||||
letterSpacing: 2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'鑫潼智电运营管理平台',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildForm() {
|
||||
return XCard(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildField(
|
||||
label: '账号',
|
||||
icon: 'person',
|
||||
controller: _username,
|
||||
hintText: '请输入账号',
|
||||
obscure: false,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_buildField(
|
||||
label: '密码',
|
||||
icon: 'more',
|
||||
controller: _password,
|
||||
hintText: '请输入密码',
|
||||
obscure: true,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
XPrimaryButton(
|
||||
label: '登 录',
|
||||
loading: _loading,
|
||||
onPressed: _handleLogin,
|
||||
),
|
||||
if (_errorMsg.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 14),
|
||||
child: Text(
|
||||
_errorMsg,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.statusError,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField({
|
||||
required String label,
|
||||
required String icon,
|
||||
required TextEditingController controller,
|
||||
required String hintText,
|
||||
required bool obscure,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: AppTypography.labelBold.copyWith(
|
||||
color: AppColors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 48,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceBright,
|
||||
border: Border.all(color: AppColors.borderRule),
|
||||
borderRadius: BorderRadius.circular(AppRadius.lg),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
XIcon(icon, size: 20, color: AppColors.textMuted),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
obscureText: obscure,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.onSurface,
|
||||
),
|
||||
cursorColor: AppColors.primary,
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
border: InputBorder.none,
|
||||
hintText: hintText,
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPlaceholder,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 登录页背景的柔光圆斑(对应源页面两处 radial-gradient)。
|
||||
class _SoftBlob extends StatelessWidget {
|
||||
const _SoftBlob({required this.color, required this.size});
|
||||
|
||||
final Color color;
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return IgnorePointer(
|
||||
child: Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [color, color.withValues(alpha: 0)],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user