feat: 完成 Flutter 版智能电网 IoT 管理应用初始化搭建

This commit is contained in:
andyesfly committed 2026-10-02 17:21:53 +08:00
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,
);
+27
View File
@@ -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');
}
+80
View File
@@ -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)],
),
),
),
);
}
}