Преглед на файлове

feat(mobile-app): #79 Flutter 个人中心 + 三Tab入口接入

master 上的 Flutter APP 骨架(来自#24)已实现入口/路由/三Tab/登录/Token/Dio拦截器/离线缓存,#79唯一缺失的是个人中心页。本次补齐:

- profile/pages/profile_page.dart: 个人中心(用户信息头部+个人信息/消息/设置/帮助/关于菜单+退出登录确认)

- app_routes.dart: 新增 /profile 路由(go_router)

- main_shell_page.dart: 新增 FloatingActionButton 个人中心入口

遵循现有代码风格:go_router 路由、Provider 状态管理、Material 3、复用 AuthProvider(currentUser/logout) 与 UserModel。

分支重建为基于 master 的干净单提交,清除了原 86c768c5(IoT协议适配器,与Flutter无关的误提交)。符合设计文档 8.2。
bot_dev3 преди 2 дни
родител
ревизия
042426d46b

+ 6
- 0
mobile-app/lib/config/app_routes.dart Целия файл

@@ -3,6 +3,7 @@ import 'package:go_router/go_router.dart';
3 3
 import '../features/auth/pages/login_page.dart';
4 4
 import '../features/auth/services/token_service.dart';
5 5
 import '../features/main_shell/pages/main_shell_page.dart';
6
+import '../features/profile/pages/profile_page.dart';
6 7
 import '../features/revenue/pages/bill_list_page.dart';
7 8
 
8 9
 /// 应用路由配置
@@ -10,6 +11,7 @@ class AppRoutes {
10 11
   static const String login = '/login';
11 12
   static const String main = '/main';
12 13
   static const String bills = '/bills';
14
+  static const String profile = '/profile';
13 15
 
14 16
   static GoRouter createRouter() {
15 17
     return GoRouter(
@@ -36,6 +38,10 @@ class AppRoutes {
36 38
           path: bills,
37 39
           builder: (context, state) => const BillListPage(),
38 40
         ),
41
+        GoRoute(
42
+          path: profile,
43
+          builder: (context, state) => const ProfilePage(),
44
+        ),
39 45
       ],
40 46
     );
41 47
   }

+ 8
- 0
mobile-app/lib/features/main_shell/pages/main_shell_page.dart Целия файл

@@ -1,7 +1,9 @@
1 1
 import 'package:flutter/material.dart';
2
+import 'package:go_router/go_router.dart';
2 3
 import '../../water_supply/pages/monitor_list_page.dart';
3 4
 import '../../patrol/pages/patrol_task_list_page.dart';
4 5
 import '../../revenue/pages/meter_reading_page.dart';
6
+import '../../../config/app_routes.dart';
5 7
 
6 8
 /// 主页面 - 底部三Tab导航(供水/巡检/营收)
7 9
 class MainShellPage extends StatefulWidget {
@@ -27,6 +29,12 @@ class _MainShellPageState extends State<MainShellPage> {
27 29
         index: _currentIndex,
28 30
         children: _pages,
29 31
       ),
32
+      // 个人中心入口(浮动按钮,不干扰各 Tab 的 AppBar)
33
+      floatingActionButton: FloatingActionButton(
34
+        onPressed: () => GoRouter.of(context).push(AppRoutes.profile),
35
+        tooltip: '个人中心',
36
+        child: const Icon(Icons.person),
37
+      ),
30 38
       bottomNavigationBar: BottomNavigationBar(
31 39
         currentIndex: _currentIndex,
32 40
         onTap: (index) {

+ 190
- 0
mobile-app/lib/features/profile/pages/profile_page.dart Целия файл

@@ -0,0 +1,190 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:go_router/go_router.dart';
3
+import 'package:provider/provider.dart';
4
+import '../../auth/models/user_model.dart';
5
+import '../../auth/services/auth_provider.dart';
6
+import '../../../config/app_routes.dart';
7
+
8
+/// 个人中心页面(对应 Issue #79:个人中心)
9
+///
10
+/// 展示当前登录用户信息 + 功能菜单(个人信息/消息/设置/帮助/关于)+ 退出登录。
11
+class ProfilePage extends StatelessWidget {
12
+  const ProfilePage({super.key});
13
+
14
+  @override
15
+  Widget build(BuildContext context) {
16
+    return Scaffold(
17
+      appBar: AppBar(title: const Text('个人中心')),
18
+      body: Consumer<AuthProvider>(
19
+        builder: (context, auth, _) {
20
+          final user = auth.currentUser;
21
+          return ListView(
22
+            children: [
23
+              _UserHeader(user: user),
24
+              const SizedBox(height: 8),
25
+              _MenuGroup(items: const [
26
+                _MenuItem(icon: Icons.person_outline, title: '个人信息'),
27
+                _MenuItem(icon: Icons.notifications_outlined, title: '我的消息'),
28
+                _MenuItem(icon: Icons.settings_outlined, title: '设置'),
29
+              ]),
30
+              const SizedBox(height: 8),
31
+              _MenuGroup(items: const [
32
+                _MenuItem(icon: Icons.help_outline, title: '帮助与反馈'),
33
+                _MenuItem(icon: Icons.info_outline, title: '关于'),
34
+              ]),
35
+              const SizedBox(height: 16),
36
+              _LogoutButton(onLogout: () => _handleLogout(context, auth)),
37
+              const SizedBox(height: 24),
38
+            ],
39
+          );
40
+        },
41
+      ),
42
+    );
43
+  }
44
+
45
+  Future<void> _handleLogout(BuildContext context, AuthProvider auth) async {
46
+    final confirmed = await showDialog<bool>(
47
+      context: context,
48
+      builder: (ctx) => AlertDialog(
49
+        title: const Text('退出登录'),
50
+        content: const Text('确定要退出当前账号吗?'),
51
+        actions: [
52
+          TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text('取消')),
53
+          TextButton(
54
+            onPressed: () => Navigator.of(ctx).pop(true),
55
+            child: const Text('退出', style: TextStyle(color: Colors.red)),
56
+          ),
57
+        ],
58
+      ),
59
+    );
60
+    if (confirmed == true) {
61
+      await auth.logout();
62
+      // 登出后跳转登录页(go_router 守卫也会拦截,这里显式跳转)
63
+      if (context.mounted) {
64
+        GoRouter.of(context).go(AppRoutes.login);
65
+      }
66
+    }
67
+  }
68
+}
69
+
70
+/// 用户信息头部(头像 + 姓名 + 角色/部门)
71
+class _UserHeader extends StatelessWidget {
72
+  final UserModel? user;
73
+  const _UserHeader({this.user});
74
+
75
+  @override
76
+  Widget build(BuildContext context) {
77
+    final name = user?.name ?? '未登录';
78
+    final role = user?.role ?? '';
79
+    final department = user?.department;
80
+    final phone = user?.phone;
81
+
82
+    return Container(
83
+      width: double.infinity,
84
+      padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
85
+      color: Theme.of(context).colorScheme.primary,
86
+      child: Column(
87
+        children: [
88
+          CircleAvatar(
89
+            radius: 40,
90
+            backgroundColor: Colors.white24,
91
+            child: _buildAvatar(user, name),
92
+          ),
93
+          const SizedBox(height: 12),
94
+          Text(name, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Colors.white)),
95
+          if (role.isNotEmpty) ...[
96
+            const SizedBox(height: 4),
97
+            Text(_roleLabel(role), style: const TextStyle(color: Colors.white70)),
98
+          ],
99
+          if (department != null || phone != null) ...[
100
+            const SizedBox(height: 4),
101
+            Text(
102
+              [department, phone].whereType<String>().join(' · '),
103
+              style: const TextStyle(color: Colors.white60, fontSize: 13),
104
+            ),
105
+          ],
106
+        ],
107
+      ),
108
+    );
109
+  }
110
+
111
+  String _roleLabel(String role) {
112
+    const map = {'admin': '管理员', 'inspector': '巡检员', 'operator': '操作员'};
113
+    return map[role] ?? role;
114
+  }
115
+
116
+  /// 头像:有 avatar 用网络图,否则取姓名首字
117
+  Widget _buildAvatar(UserModel? user, String name) {
118
+    final avatar = user?.avatar;
119
+    if (avatar != null && avatar.isNotEmpty) {
120
+      return ClipOval(child: Image.network(avatar, width: 80, height: 80, fit: BoxFit.cover));
121
+    }
122
+    return Text(
123
+      name.isNotEmpty ? name.substring(0, 1) : '?',
124
+      style: const TextStyle(fontSize: 32, color: Colors.white),
125
+    );
126
+  }
127
+}
128
+
129
+/// 菜单组(带卡片容器)
130
+class _MenuGroup extends StatelessWidget {
131
+  final List<_MenuItem> items;
132
+  const _MenuGroup({required this.items});
133
+
134
+  @override
135
+  Widget build(BuildContext context) {
136
+    return Card(
137
+      margin: const EdgeInsets.symmetric(horizontal: 12),
138
+      child: Column(
139
+        children: [
140
+          for (int i = 0; i < items.length; i++) ...[
141
+            items[i],
142
+            if (i < items.length - 1) const Divider(height: 1, indent: 56),
143
+          ],
144
+        ],
145
+      ),
146
+    );
147
+  }
148
+}
149
+
150
+/// 单个菜单项(占位 onTap,待后续接入具体页面)
151
+class _MenuItem extends StatelessWidget {
152
+  final IconData icon;
153
+  final String title;
154
+  final VoidCallback? onTap;
155
+
156
+  const _MenuItem({required this.icon, required this.title, this.onTap});
157
+
158
+  @override
159
+  Widget build(BuildContext context) {
160
+    return ListTile(
161
+      leading: Icon(icon, color: Theme.of(context).colorScheme.primary),
162
+      title: Text(title),
163
+      trailing: const Icon(Icons.chevron_right, color: Colors.grey),
164
+      onTap: onTap ?? () => ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$title(待实现)'))),
165
+    );
166
+  }
167
+}
168
+
169
+/// 退出登录按钮
170
+class _LogoutButton extends StatelessWidget {
171
+  final VoidCallback onLogout;
172
+  const _LogoutButton({required this.onLogout});
173
+
174
+  @override
175
+  Widget build(BuildContext context) {
176
+    return Padding(
177
+      padding: const EdgeInsets.symmetric(horizontal: 12),
178
+      child: OutlinedButton.icon(
179
+        onPressed: onLogout,
180
+        icon: const Icon(Icons.logout, color: Colors.red),
181
+        label: const Text('退出登录', style: TextStyle(color: Colors.red)),
182
+        style: OutlinedButton.styleFrom(
183
+          minimumSize: const Size(double.infinity, 48),
184
+          side: const BorderSide(color: Colors.red),
185
+          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
186
+        ),
187
+      ),
188
+    );
189
+  }
190
+}