Просмотр исходного кода

feat(mobile): #79 Flutter统一入口+Tab导航+用户登录

- lib/services/api_service.dart: Dio HTTP封装 + Token拦截器 + 401自动登出
- lib/services/auth_service.dart: 完善登录/登出/Token持久化/用户信息刷新
- lib/pages/login/login_page.dart: Material Design 3登录页(表单验证+密码可见+加载态)
- lib/pages/home/home_page.dart: 三Tab首页(NavigationBar+个人中心入口)
- lib/pages/home/tabs/water_supply_tab.dart: 供水管理(概览卡片+快捷操作+实时监测)
- lib/pages/home/tabs/inspection_tab.dart: 巡检管理(子Tab:待执行/进行中/已完成)
- lib/pages/home/tabs/revenue_tab.dart: 营业收费(营收概览+快捷操作+近期账单)
- lib/pages/profile/profile_page.dart: 个人中心(头像+菜单+退出登录确认)
- lib/widgets/: 通用组件(LoadingOverlay/EmptyState/ErrorRetry)
- lib/main.dart: 入口+启动初始化+主题配置+路由
bot_dev2 5 дней назад
Родитель
Сommit
6a13cc7222

+ 30
- 5
mobile/lib/main.dart Просмотреть файл

@@ -1,21 +1,46 @@
1 1
 import 'package:flutter/material.dart';
2 2
 import 'package:provider/provider.dart';
3 3
 import 'services/auth_service.dart';
4
+import 'services/api_service.dart';
4 5
 import 'pages/login/login_page.dart';
5 6
 import 'pages/home/home_page.dart';
6 7
 
7
-void main() => runApp(const WaterApp());
8
+void main() async {
9
+  WidgetsFlutterBinding.ensureInitialized();
10
+
11
+  // 初始化 AuthService 并恢复登录态
12
+  final authService = AuthService();
13
+  await authService.init();
14
+
15
+  // 初始化全局 HTTP 客户端
16
+  ApiService.instance.init(authService);
17
+
18
+  runApp(WaterApp(authService: authService));
19
+}
8 20
 
9 21
 class WaterApp extends StatelessWidget {
10
-  const WaterApp({super.key});
22
+  final AuthService authService;
23
+
24
+  const WaterApp({super.key, required this.authService});
11 25
 
12 26
   @override
13 27
   Widget build(BuildContext context) {
14
-    return MultiProvider(
15
-      providers: [ChangeNotifierProvider(create: (_) => AuthService())],
28
+    return ChangeNotifierProvider.value(
29
+      value: authService,
16 30
       child: MaterialApp(
17 31
         title: '智慧水务',
18
-        theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
32
+        debugShowCheckedModeBanner: false,
33
+        theme: ThemeData(
34
+          colorSchemeSeed: const Color(0xFF1976D2),
35
+          useMaterial3: true,
36
+          brightness: Brightness.light,
37
+        ),
38
+        darkTheme: ThemeData(
39
+          colorSchemeSeed: const Color(0xFF1976D2),
40
+          useMaterial3: true,
41
+          brightness: Brightness.dark,
42
+        ),
43
+        themeMode: ThemeMode.system,
19 44
         home: Consumer<AuthService>(
20 45
           builder: (_, auth, __) => auth.isLoggedIn ? const HomePage() : const LoginPage(),
21 46
         ),

+ 51
- 16
mobile/lib/pages/home/home_page.dart Просмотреть файл

@@ -1,33 +1,68 @@
1 1
 import 'package:flutter/material.dart';
2 2
 import 'package:provider/provider.dart';
3 3
 import '../../services/auth_service.dart';
4
+import '../profile/profile_page.dart';
5
+import 'tabs/water_supply_tab.dart';
6
+import 'tabs/inspection_tab.dart';
7
+import 'tabs/revenue_tab.dart';
4 8
 
9
+/// 首页 —— 底部三 Tab (供水 / 巡检 / 营收) + 个人中心入口
5 10
 class HomePage extends StatefulWidget {
6 11
   const HomePage({super.key});
7
-  @override State<HomePage> createState() => _HomePageState();
12
+  @override
13
+  State<HomePage> createState() => _HomePageState();
8 14
 }
9 15
 
10 16
 class _HomePageState extends State<HomePage> {
11 17
   int _tabIndex = 0;
12 18
 
19
+  final List<Widget> _tabs = const [
20
+    WaterSupplyTab(),
21
+    InspectionTab(),
22
+    RevenueTab(),
23
+  ];
24
+
25
+  final List<String> _titles = const ['供水管理', '巡检管理', '营业收费'];
26
+
13 27
   @override
14 28
   Widget build(BuildContext context) {
15
-    final auth = context.read<AuthService>();
16 29
     return Scaffold(
17
-      appBar: AppBar(title: const Text('智慧水务'), actions: [
18
-        IconButton(icon: const Icon(Icons.logout), onPressed: () async { await auth.logout(); Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const HomePage())); })
19
-      ]),
20
-      body: IndexedStack(index: _tabIndex, children: const [
21
-        Center(child: Text('💧 供水管理')),
22
-        Center(child: Text('🔍 巡检管理')),
23
-        Center(child: Text('💰 营业收费')),
24
-      ]),
25
-      bottomNavigationBar: BottomNavigationBar(
26
-        currentIndex: _tabIndex, onTap: (i) => setState(() => _tabIndex = i),
27
-        items: const [
28
-          BottomNavigationBarItem(icon: Icon(Icons.water), label: '供水'),
29
-          BottomNavigationBarItem(icon: Icon(Icons.search), label: '巡检'),
30
-          BottomNavigationBarItem(icon: Icon(Icons.receipt_long), label: '营收'),
30
+      appBar: AppBar(
31
+        title: Text(_titles[_tabIndex]),
32
+        centerTitle: true,
33
+        actions: [
34
+          IconButton(
35
+            icon: const Icon(Icons.person_outline),
36
+            tooltip: '个人中心',
37
+            onPressed: () {
38
+              Navigator.push(
39
+                context,
40
+                MaterialPageRoute(builder: (_) => const ProfilePage()),
41
+              );
42
+            },
43
+          ),
44
+        ],
45
+      ),
46
+      body: IndexedStack(index: _tabIndex, children: _tabs),
47
+      bottomNavigationBar: NavigationBar(
48
+        selectedIndex: _tabIndex,
49
+        onDestinationSelected: (i) => setState(() => _tabIndex = i),
50
+        destinations: const [
51
+          NavigationDestination(
52
+            icon: Icon(Icons.water_drop_outlined),
53
+            selectedIcon: Icon(Icons.water_drop),
54
+            label: '供水',
55
+          ),
56
+          NavigationDestination(
57
+            icon: Icon(Icons.search),
58
+            selectedIcon: Icon(Icons.search),
59
+            label: '巡检',
60
+          ),
61
+          NavigationDestination(
62
+            icon: Icon(Icons.receipt_long_outlined),
63
+            selectedIcon: Icon(Icons.receipt_long),
64
+            label: '营收',
65
+          ),
31 66
         ],
32 67
       ),
33 68
     );

+ 198
- 0
mobile/lib/pages/home/tabs/inspection_tab.dart Просмотреть файл

@@ -0,0 +1,198 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 巡检管理 Tab
4
+class InspectionTab extends StatelessWidget {
5
+  const InspectionTab({super.key});
6
+
7
+  @override
8
+  Widget build(BuildContext context) {
9
+    final theme = Theme.of(context);
10
+
11
+    return DefaultTabController(
12
+      length: 3,
13
+      child: Column(
14
+        children: [
15
+          // ---------- 子 Tab ----------
16
+          Material(
17
+            color: theme.colorScheme.surface,
18
+            child: TabBar(
19
+              labelColor: theme.colorScheme.primary,
20
+              unselectedLabelColor: Colors.grey,
21
+              indicatorColor: theme.colorScheme.primary,
22
+              tabs: const [
23
+                Tab(text: '待执行', icon: Icon(Icons.pending_actions, size: 18)),
24
+                Tab(text: '进行中', icon: Icon(Icons.play_circle, size: 18)),
25
+                Tab(text: '已完成', icon: Icon(Icons.check_circle, size: 18)),
26
+              ],
27
+            ),
28
+          ),
29
+
30
+          // ---------- 内容 ----------
31
+          Expanded(
32
+            child: TabBarView(
33
+              children: [
34
+                _InspectionList(status: 'pending'),
35
+                _InspectionList(status: 'ongoing'),
36
+                _InspectionList(status: 'done'),
37
+              ],
38
+            ),
39
+          ),
40
+        ],
41
+      ),
42
+    );
43
+  }
44
+}
45
+
46
+class _InspectionList extends StatelessWidget {
47
+  final String status;
48
+  const _InspectionList({required this.status});
49
+
50
+  List<Map<String, dynamic>> get _mockData {
51
+    switch (status) {
52
+      case 'pending':
53
+        return [
54
+          {'id': 'XJ-2024-001', 'route': '城东片区巡检路线', 'date': '2024-06-14', 'points': 8, 'priority': '高'},
55
+          {'id': 'XJ-2024-002', 'route': '城北加压站巡检', 'date': '2024-06-14', 'points': 4, 'priority': '中'},
56
+          {'id': 'XJ-2024-003', 'route': '水厂设备日检', 'date': '2024-06-15', 'points': 12, 'priority': '高'},
57
+        ];
58
+      case 'ongoing':
59
+        return [
60
+          {'id': 'XJ-2024-004', 'route': '城西管网巡检', 'date': '2024-06-14', 'points': 6, 'progress': '3/6'},
61
+        ];
62
+      default:
63
+        return [
64
+          {'id': 'XJ-2024-005', 'route': '城南片区周检', 'date': '2024-06-13', 'points': 10, 'result': '正常'},
65
+          {'id': 'XJ-2024-006', 'route': '水厂设备周检', 'date': '2024-06-13', 'points': 12, 'result': '发现1处隐患'},
66
+        ];
67
+    }
68
+  }
69
+
70
+  @override
71
+  Widget build(BuildContext context) {
72
+    final data = _mockData;
73
+    if (data.isEmpty) {
74
+      return const Center(child: Text('暂无数据', style: TextStyle(color: Colors.grey)));
75
+    }
76
+
77
+    return ListView.builder(
78
+      padding: const EdgeInsets.all(12),
79
+      itemCount: data.length,
80
+      itemBuilder: (ctx, i) => _InspectionCard(item: data[i], status: status),
81
+    );
82
+  }
83
+}
84
+
85
+class _InspectionCard extends StatelessWidget {
86
+  final Map<String, dynamic> item;
87
+  final String status;
88
+
89
+  const _InspectionCard({required this.item, required this.status});
90
+
91
+  @override
92
+  Widget build(BuildContext context) {
93
+    return Card(
94
+      margin: const EdgeInsets.only(bottom: 12),
95
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
96
+      child: Padding(
97
+        padding: const EdgeInsets.all(16),
98
+        child: Column(
99
+          crossAxisAlignment: CrossAxisAlignment.start,
100
+          children: [
101
+            Row(
102
+              children: [
103
+                Expanded(
104
+                  child: Text(
105
+                    item['route'] as String,
106
+                    style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15),
107
+                  ),
108
+                ),
109
+                if (status == 'pending' && item.containsKey('priority'))
110
+                  Container(
111
+                    padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
112
+                    decoration: BoxDecoration(
113
+                      color: item['priority'] == '高' ? Colors.red.shade100 : Colors.orange.shade100,
114
+                      borderRadius: BorderRadius.circular(4),
115
+                    ),
116
+                    child: Text(
117
+                      '${item['priority']}优先',
118
+                      style: TextStyle(
119
+                        fontSize: 11,
120
+                        color: item['priority'] == '高' ? Colors.red.shade800 : Colors.orange.shade800,
121
+                      ),
122
+                    ),
123
+                  ),
124
+              ],
125
+            ),
126
+            const SizedBox(height: 8),
127
+            Row(
128
+              children: [
129
+                _InfoChip(icon: Icons.tag, text: item['id'] as String),
130
+                const SizedBox(width: 8),
131
+                _InfoChip(icon: Icons.calendar_today, text: item['date'] as String, size: 12),
132
+                const SizedBox(width: 8),
133
+                _InfoChip(icon: Icons.location_on, text: '${item['points']}个巡检点'),
134
+              ],
135
+            ),
136
+            if (status == 'ongoing') ...[
137
+              const SizedBox(height: 8),
138
+              LinearProgressIndicator(
139
+                value: 0.5,
140
+                backgroundColor: Colors.grey.shade200,
141
+              ),
142
+              const SizedBox(height: 4),
143
+              Text('进度: ${item['progress']}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
144
+            ],
145
+            if (status == 'done') ...[
146
+              const SizedBox(height: 8),
147
+              Text('巡检结果: ${item['result']}', style: const TextStyle(fontSize: 13, color: Colors.green)),
148
+            ],
149
+            const SizedBox(height: 12),
150
+            Row(
151
+              mainAxisAlignment: MainAxisAlignment.end,
152
+              children: [
153
+                if (status == 'pending')
154
+                  FilledButton.icon(
155
+                    onPressed: () {},
156
+                    icon: const Icon(Icons.play_arrow, size: 18),
157
+                    label: const Text('开始巡检'),
158
+                  ),
159
+                if (status == 'ongoing')
160
+                  FilledButton.icon(
161
+                    onPressed: () {},
162
+                    icon: const Icon(Icons.continue, size: 18),
163
+                    label: const Text('继续'),
164
+                  ),
165
+                if (status == 'done')
166
+                  OutlinedButton.icon(
167
+                    onPressed: () {},
168
+                    icon: const Icon(Icons.visibility, size: 18),
169
+                    label: const Text('查看详情'),
170
+                  ),
171
+              ],
172
+            ),
173
+          ],
174
+        ),
175
+      ),
176
+    );
177
+  }
178
+}
179
+
180
+class _InfoChip extends StatelessWidget {
181
+  final IconData icon;
182
+  final String text;
183
+  final double size;
184
+
185
+  const _InfoChip({required this.icon, required this.text, this.size = 14});
186
+
187
+  @override
188
+  Widget build(BuildContext context) {
189
+    return Row(
190
+      mainAxisSize: MainAxisSize.min,
191
+      children: [
192
+        Icon(icon, size: size, color: Colors.grey),
193
+        const SizedBox(width: 2),
194
+        Text(text, style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
195
+      ],
196
+    );
197
+  }
198
+}

+ 219
- 0
mobile/lib/pages/home/tabs/revenue_tab.dart Просмотреть файл

@@ -0,0 +1,219 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 营业收费 Tab
4
+class RevenueTab extends StatelessWidget {
5
+  const RevenueTab({super.key});
6
+
7
+  @override
8
+  Widget build(BuildContext context) {
9
+    final theme = Theme.of(context);
10
+    final colorScheme = theme.colorScheme;
11
+
12
+    return RefreshIndicator(
13
+      onRefresh: () async {
14
+        await Future.delayed(const Duration(seconds: 1));
15
+      },
16
+      child: ListView(
17
+        padding: const EdgeInsets.all(16),
18
+        children: [
19
+          // ---------- 本月营收概览 ----------
20
+          Card(
21
+            elevation: 0,
22
+            color: colorScheme.primaryContainer.withAlpha(80),
23
+            shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
24
+            child: Padding(
25
+              padding: const EdgeInsets.all(20),
26
+              child: Column(
27
+                crossAxisAlignment: CrossAxisAlignment.start,
28
+                children: [
29
+                  Text('本月营收', style: TextStyle(fontSize: 14, color: colorScheme.onSurfaceVariant)),
30
+                  const SizedBox(height: 8),
31
+                  Row(
32
+                    crossAxisAlignment: CrossAxisAlignment.end,
33
+                    children: [
34
+                      Text(
35
+                        '¥ 358,620',
36
+                        style: TextStyle(
37
+                          fontSize: 32,
38
+                          fontWeight: FontWeight.bold,
39
+                          color: colorScheme.primary,
40
+                        ),
41
+                      ),
42
+                      const SizedBox(width: 8),
43
+                      Padding(
44
+                        padding: const EdgeInsets.only(bottom: 6),
45
+                        child: Text(
46
+                          '较上月 ↑ 5.8%',
47
+                          style: TextStyle(fontSize: 13, color: Colors.green.shade700),
48
+                        ),
49
+                      ),
50
+                    ],
51
+                  ),
52
+                  const SizedBox(height: 16),
53
+                  Row(
54
+                    mainAxisAlignment: MainAxisAlignment.spaceAround,
55
+                    children: [
56
+                      _StatItem(label: '已收', value: '¥312,450', color: Colors.green),
57
+                      _StatItem(label: '待收', value: '¥46,170', color: Colors.orange),
58
+                      _StatItem(label: '欠费', value: '¥12,800', color: Colors.red),
59
+                    ],
60
+                  ),
61
+                ],
62
+              ),
63
+            ),
64
+          ),
65
+          const SizedBox(height: 24),
66
+
67
+          // ---------- 快捷操作 ----------
68
+          Text('快捷操作', style: theme.textTheme.titleMedium),
69
+          const SizedBox(height: 12),
70
+          Row(
71
+            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
72
+            children: [
73
+              _RevenueAction(icon: Icons.receipt, label: '抄表录入', color: Colors.blue, onTap: () {}),
74
+              _RevenueAction(icon: Icons.payment, label: '缴费记录', color: Colors.green, onTap: () {}),
75
+              _RevenueAction(icon: Icons.notifications_active, label: '催缴通知', color: Colors.orange, onTap: () {}),
76
+              _RevenueAction(icon: Icons.analytics, label: '营收报表', color: Colors.purple, onTap: () {}),
77
+            ],
78
+          ),
79
+          const SizedBox(height: 24),
80
+
81
+          // ---------- 近期账单 ----------
82
+          Text('近期账单', style: theme.textTheme.titleMedium),
83
+          const SizedBox(height: 12),
84
+          _BillTile(
85
+            customer: '张三',
86
+            address: '阳光小区3-501',
87
+            amount: '¥ 85.50',
88
+            period: '2024-05',
89
+            status: '已缴费',
90
+            statusColor: Colors.green,
91
+          ),
92
+          _BillTile(
93
+            customer: '李四',
94
+            address: '翠湖花园2-302',
95
+            amount: '¥ 120.00',
96
+            period: '2024-05',
97
+            status: '待缴费',
98
+            statusColor: Colors.orange,
99
+          ),
100
+          _BillTile(
101
+            customer: '王五',
102
+            address: '东方名城8-101',
103
+            amount: '¥ 65.20',
104
+            period: '2024-05',
105
+            status: '已缴费',
106
+            statusColor: Colors.green,
107
+          ),
108
+          _BillTile(
109
+            customer: '赵六',
110
+            address: '锦绣家园5-601',
111
+            amount: '¥ 98.80',
112
+            period: '2024-05',
113
+            status: '欠费',
114
+            statusColor: Colors.red,
115
+          ),
116
+        ],
117
+      ),
118
+    );
119
+  }
120
+}
121
+
122
+class _StatItem extends StatelessWidget {
123
+  final String label;
124
+  final String value;
125
+  final Color color;
126
+
127
+  const _StatItem({required this.label, required this.value, required this.color});
128
+
129
+  @override
130
+  Widget build(BuildContext context) {
131
+    return Column(
132
+      children: [
133
+        Text(value, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: color)),
134
+        const SizedBox(height: 4),
135
+        Text(label, style: const TextStyle(fontSize: 12, color: Colors.grey)),
136
+      ],
137
+    );
138
+  }
139
+}
140
+
141
+class _RevenueAction extends StatelessWidget {
142
+  final IconData icon;
143
+  final String label;
144
+  final Color color;
145
+  final VoidCallback onTap;
146
+
147
+  const _RevenueAction({
148
+    required this.icon,
149
+    required this.label,
150
+    required this.color,
151
+    required this.onTap,
152
+  });
153
+
154
+  @override
155
+  Widget build(BuildContext context) {
156
+    return InkWell(
157
+      onTap: onTap,
158
+      borderRadius: BorderRadius.circular(12),
159
+      child: Column(
160
+        mainAxisSize: MainAxisSize.min,
161
+        children: [
162
+          Container(
163
+            width: 48,
164
+            height: 48,
165
+            decoration: BoxDecoration(
166
+              color: color.withAlpha(30),
167
+              borderRadius: BorderRadius.circular(12),
168
+            ),
169
+            child: Icon(icon, color: color, size: 24),
170
+          ),
171
+          const SizedBox(height: 6),
172
+          Text(label, style: const TextStyle(fontSize: 12)),
173
+        ],
174
+      ),
175
+    );
176
+  }
177
+}
178
+
179
+class _BillTile extends StatelessWidget {
180
+  final String customer;
181
+  final String address;
182
+  final String amount;
183
+  final String period;
184
+  final String status;
185
+  final Color statusColor;
186
+
187
+  const _BillTile({
188
+    required this.customer,
189
+    required this.address,
190
+    required this.amount,
191
+    required this.period,
192
+    required this.status,
193
+    required this.statusColor,
194
+  });
195
+
196
+  @override
197
+  Widget build(BuildContext context) {
198
+    return Card(
199
+      margin: const EdgeInsets.only(bottom: 8),
200
+      child: ListTile(
201
+        leading: CircleAvatar(
202
+          backgroundColor: Colors.blue.shade50,
203
+          child: Text(customer[0], style: TextStyle(color: Colors.blue.shade700)),
204
+        ),
205
+        title: Text(customer),
206
+        subtitle: Text('$address | $period'),
207
+        trailing: Column(
208
+          mainAxisAlignment: MainAxisAlignment.center,
209
+          crossAxisAlignment: CrossAxisAlignment.end,
210
+          children: [
211
+            Text(amount, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15)),
212
+            const SizedBox(height: 2),
213
+            Text(status, style: TextStyle(fontSize: 12, color: statusColor)),
214
+          ],
215
+        ),
216
+      ),
217
+    );
218
+  }
219
+}

+ 214
- 0
mobile/lib/pages/home/tabs/water_supply_tab.dart Просмотреть файл

@@ -0,0 +1,214 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 供水管理 Tab
4
+class WaterSupplyTab extends StatelessWidget {
5
+  const WaterSupplyTab({super.key});
6
+
7
+  @override
8
+  Widget build(BuildContext context) {
9
+    final theme = Theme.of(context);
10
+    final colorScheme = theme.colorScheme;
11
+
12
+    return RefreshIndicator(
13
+      onRefresh: () async {
14
+        // TODO: 刷新数据
15
+        await Future.delayed(const Duration(seconds: 1));
16
+      },
17
+      child: ListView(
18
+        padding: const EdgeInsets.all(16),
19
+        children: [
20
+          // ---------- 概览卡片 ----------
21
+          _OverviewCard(
22
+            title: '今日供水量',
23
+            value: '12,580 m³',
24
+            subtitle: '较昨日 ↑ 3.2%',
25
+            icon: Icons.water_drop,
26
+            color: colorScheme.primary,
27
+          ),
28
+          const SizedBox(height: 16),
29
+
30
+          // ---------- 快捷操作 ----------
31
+          Text('快捷操作', style: theme.textTheme.titleMedium),
32
+          const SizedBox(height: 12),
33
+          Wrap(
34
+            spacing: 12,
35
+            runSpacing: 12,
36
+            children: [
37
+              _ActionChip(icon: Icons.map, label: '管网地图', onTap: () {}),
38
+              _ActionChip(icon: Icons.sensors, label: '压力监测', onTap: () {}),
39
+              _ActionChip(icon: Icons.speed, label: '流量计', onTap: () {}),
40
+              _ActionChip(icon: Icons.warning_amber, label: '告警中心', onTap: () {}),
41
+            ],
42
+          ),
43
+          const SizedBox(height: 24),
44
+
45
+          // ---------- 实时数据 ----------
46
+          Text('实时监测', style: theme.textTheme.titleMedium),
47
+          const SizedBox(height: 12),
48
+          _MonitorTile(
49
+            name: '1号泵站',
50
+            status: '运行中',
51
+            pressure: '0.35 MPa',
52
+            flow: '120 m³/h',
53
+            isOnline: true,
54
+          ),
55
+          _MonitorTile(
56
+            name: '2号泵站',
57
+            status: '运行中',
58
+            pressure: '0.32 MPa',
59
+            flow: '98 m³/h',
60
+            isOnline: true,
61
+          ),
62
+          _MonitorTile(
63
+            name: '3号泵站',
64
+            status: '维护中',
65
+            pressure: '—',
66
+            flow: '—',
67
+            isOnline: false,
68
+          ),
69
+          _MonitorTile(
70
+            name: '清水池',
71
+            status: '正常',
72
+            pressure: '—',
73
+            flow: '水位 4.2m',
74
+            isOnline: true,
75
+          ),
76
+        ],
77
+      ),
78
+    );
79
+  }
80
+}
81
+
82
+class _OverviewCard extends StatelessWidget {
83
+  final String title;
84
+  final String value;
85
+  final String subtitle;
86
+  final IconData icon;
87
+  final Color color;
88
+
89
+  const _OverviewCard({
90
+    required this.title,
91
+    required this.value,
92
+    required this.subtitle,
93
+    required this.icon,
94
+    required this.color,
95
+  });
96
+
97
+  @override
98
+  Widget build(BuildContext context) {
99
+    return Card(
100
+      elevation: 0,
101
+      color: color.withAlpha(25),
102
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
103
+      child: Padding(
104
+        padding: const EdgeInsets.all(20),
105
+        child: Row(
106
+          children: [
107
+            Container(
108
+              width: 56,
109
+              height: 56,
110
+              decoration: BoxDecoration(
111
+                color: color.withAlpha(50),
112
+                shape: BoxShape.circle,
113
+              ),
114
+              child: Icon(icon, color: color, size: 28),
115
+            ),
116
+            const SizedBox(width: 16),
117
+            Expanded(
118
+              child: Column(
119
+                crossAxisAlignment: CrossAxisAlignment.start,
120
+                children: [
121
+                  Text(title, style: const TextStyle(fontSize: 14, color: Colors.grey)),
122
+                  const SizedBox(height: 4),
123
+                  Text(value, style: TextStyle(fontSize: 24, fontWeight: FontWeight.bold, color: color)),
124
+                  const SizedBox(height: 4),
125
+                  Text(subtitle, style: const TextStyle(fontSize: 12, color: Colors.grey)),
126
+                ],
127
+              ),
128
+            ),
129
+          ],
130
+        ),
131
+      ),
132
+    );
133
+  }
134
+}
135
+
136
+class _ActionChip extends StatelessWidget {
137
+  final IconData icon;
138
+  final String label;
139
+  final VoidCallback onTap;
140
+
141
+  const _ActionChip({required this.icon, required this.label, required this.onTap});
142
+
143
+  @override
144
+  Widget build(BuildContext context) {
145
+    return InkWell(
146
+      onTap: onTap,
147
+      borderRadius: BorderRadius.circular(12),
148
+      child: Container(
149
+        width: 80,
150
+        padding: const EdgeInsets.symmetric(vertical: 12),
151
+        decoration: BoxDecoration(
152
+          color: Colors.grey.shade100,
153
+          borderRadius: BorderRadius.circular(12),
154
+        ),
155
+        child: Column(
156
+          mainAxisSize: MainAxisSize.min,
157
+          children: [
158
+            Icon(icon, color: Colors.blue.shade700),
159
+            const SizedBox(height: 6),
160
+            Text(label, style: const TextStyle(fontSize: 12)),
161
+          ],
162
+        ),
163
+      ),
164
+    );
165
+  }
166
+}
167
+
168
+class _MonitorTile extends StatelessWidget {
169
+  final String name;
170
+  final String status;
171
+  final String pressure;
172
+  final String flow;
173
+  final bool isOnline;
174
+
175
+  const _MonitorTile({
176
+    required this.name,
177
+    required this.status,
178
+    required this.pressure,
179
+    required this.flow,
180
+    required this.isOnline,
181
+  });
182
+
183
+  @override
184
+  Widget build(BuildContext context) {
185
+    return Card(
186
+      margin: const EdgeInsets.only(bottom: 8),
187
+      child: ListTile(
188
+        leading: CircleAvatar(
189
+          backgroundColor: isOnline ? Colors.green.shade100 : Colors.grey.shade200,
190
+          child: Icon(
191
+            Icons.sensors,
192
+            color: isOnline ? Colors.green : Colors.grey,
193
+          ),
194
+        ),
195
+        title: Text(name),
196
+        subtitle: Text('压力: $pressure | 流量: $flow'),
197
+        trailing: Container(
198
+          padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
199
+          decoration: BoxDecoration(
200
+            color: isOnline ? Colors.green.shade100 : Colors.orange.shade100,
201
+            borderRadius: BorderRadius.circular(8),
202
+          ),
203
+          child: Text(
204
+            status,
205
+            style: TextStyle(
206
+              fontSize: 12,
207
+              color: isOnline ? Colors.green.shade800 : Colors.orange.shade800,
208
+            ),
209
+          ),
210
+        ),
211
+      ),
212
+    );
213
+  }
214
+}

+ 157
- 18
mobile/lib/pages/login/login_page.dart Просмотреть файл

@@ -1,43 +1,182 @@
1 1
 import 'package:flutter/material.dart';
2 2
 import 'package:provider/provider.dart';
3 3
 import '../../services/auth_service.dart';
4
-import '../home/home_page.dart';
5 4
 
5
+/// 登录页 —— Material Design 3 风格
6 6
 class LoginPage extends StatefulWidget {
7 7
   const LoginPage({super.key});
8
-  @override State<LoginPage> createState() => _LoginPageState();
8
+  @override
9
+  State<LoginPage> createState() => _LoginPageState();
9 10
 }
10 11
 
11 12
 class _LoginPageState extends State<LoginPage> {
13
+  final _formKey = GlobalKey<FormState>();
12 14
   final _userCtrl = TextEditingController(text: 'admin');
13 15
   final _passCtrl = TextEditingController(text: 'admin123');
16
+  bool _obscure = true;
14 17
   bool _loading = false;
18
+  String? _errorText;
15 19
 
16 20
   Future<void> _login() async {
17
-    setState(() => _loading = true);
18
-    final ok = await context.read<AuthService>().login(_userCtrl.text, _passCtrl.text);
21
+    if (!_formKey.currentState!.validate()) return;
22
+
23
+    setState(() {
24
+      _loading = true;
25
+      _errorText = null;
26
+    });
27
+
28
+    final auth = context.read<AuthService>();
29
+    final ok = await auth.login(_userCtrl.text.trim(), _passCtrl.text);
30
+
19 31
     if (!mounted) return;
20 32
     setState(() => _loading = false);
21
-    if (ok) {
22
-      Navigator.pushReplacement(context, MaterialPageRoute(builder: (_) => const HomePage()));
23
-    } else {
24
-      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登录失败')));
33
+
34
+    if (!ok) {
35
+      setState(() => _errorText = '用户名或密码错误,请重试');
25 36
     }
37
+    // 成功时 AuthService.notifyListeners 会自动触发 MaterialApp 切换到 HomePage
38
+  }
39
+
40
+  @override
41
+  void dispose() {
42
+    _userCtrl.dispose();
43
+    _passCtrl.dispose();
44
+    super.dispose();
26 45
   }
27 46
 
28 47
   @override
29 48
   Widget build(BuildContext context) {
49
+    final theme = Theme.of(context);
50
+    final colorScheme = theme.colorScheme;
51
+
30 52
     return Scaffold(
31
-      body: Center(
32
-        child: Padding(padding: const EdgeInsets.all(32), child: Column(mainAxisSize: MainAxisSize.min, children: [
33
-          const Text('智慧水务', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.blue)),
34
-          const SizedBox(height: 32),
35
-          TextField(controller: _userCtrl, decoration: const InputDecoration(labelText: '用户名', prefixIcon: Icon(Icons.person))),
36
-          const SizedBox(height: 16),
37
-          TextField(controller: _passCtrl, obscureText: true, decoration: const InputDecoration(labelText: '密码', prefixIcon: Icon(Icons.lock))),
38
-          const SizedBox(height: 24),
39
-          SizedBox(width: double.infinity, height: 48, child: ElevatedButton(onPressed: _loading ? null : _login, child: Text(_loading ? '登录中...' : '登录'))),
40
-        ])),
53
+      body: SafeArea(
54
+        child: Center(
55
+          child: SingleChildScrollView(
56
+            padding: const EdgeInsets.symmetric(horizontal: 32),
57
+            child: Form(
58
+              key: _formKey,
59
+              child: Column(
60
+                mainAxisSize: MainAxisSize.min,
61
+                children: [
62
+                  // ---------- Logo ----------
63
+                  Container(
64
+                    width: 96,
65
+                    height: 96,
66
+                    decoration: BoxDecoration(
67
+                      color: colorScheme.primaryContainer,
68
+                      shape: BoxShape.circle,
69
+                    ),
70
+                    child: Icon(
71
+                      Icons.water_drop,
72
+                      size: 48,
73
+                      color: colorScheme.onPrimaryContainer,
74
+                    ),
75
+                  ),
76
+                  const SizedBox(height: 24),
77
+
78
+                  // ---------- 标题 ----------
79
+                  Text(
80
+                    '智慧水务管理系统',
81
+                    style: theme.textTheme.headlineSmall?.copyWith(
82
+                      fontWeight: FontWeight.bold,
83
+                      color: colorScheme.onSurface,
84
+                    ),
85
+                  ),
86
+                  const SizedBox(height: 8),
87
+                  Text(
88
+                    '请登录您的账号',
89
+                    style: theme.textTheme.bodyMedium?.copyWith(
90
+                      color: colorScheme.onSurfaceVariant,
91
+                    ),
92
+                  ),
93
+                  const SizedBox(height: 40),
94
+
95
+                  // ---------- 用户名 ----------
96
+                  TextFormField(
97
+                    controller: _userCtrl,
98
+                    textInputAction: TextInputAction.next,
99
+                    decoration: InputDecoration(
100
+                      labelText: '用户名',
101
+                      hintText: '请输入用户名',
102
+                      prefixIcon: const Icon(Icons.person_outline),
103
+                      border: OutlineInputBorder(
104
+                        borderRadius: BorderRadius.circular(12),
105
+                      ),
106
+                    ),
107
+                    validator: (v) => (v == null || v.trim().isEmpty) ? '请输入用户名' : null,
108
+                  ),
109
+                  const SizedBox(height: 16),
110
+
111
+                  // ---------- 密码 ----------
112
+                  TextFormField(
113
+                    controller: _passCtrl,
114
+                    obscureText: _obscure,
115
+                    textInputAction: TextInputAction.done,
116
+                    onFieldSubmitted: (_) => _login(),
117
+                    decoration: InputDecoration(
118
+                      labelText: '密码',
119
+                      hintText: '请输入密码',
120
+                      prefixIcon: const Icon(Icons.lock_outline),
121
+                      suffixIcon: IconButton(
122
+                        icon: Icon(_obscure ? Icons.visibility_off : Icons.visibility),
123
+                        onPressed: () => setState(() => _obscure = !_obscure),
124
+                      ),
125
+                      border: OutlineInputBorder(
126
+                        borderRadius: BorderRadius.circular(12),
127
+                      ),
128
+                    ),
129
+                    validator: (v) => (v == null || v.isEmpty) ? '请输入密码' : null,
130
+                  ),
131
+                  const SizedBox(height: 8),
132
+
133
+                  // ---------- 错误提示 ----------
134
+                  if (_errorText != null) ...[
135
+                    Align(
136
+                      alignment: Alignment.centerLeft,
137
+                      child: Text(
138
+                        _errorText!,
139
+                        style: TextStyle(color: colorScheme.error, fontSize: 13),
140
+                      ),
141
+                    ),
142
+                    const SizedBox(height: 8),
143
+                  ],
144
+
145
+                  // ---------- 登录按钮 ----------
146
+                  const SizedBox(height: 16),
147
+                  SizedBox(
148
+                    width: double.infinity,
149
+                    height: 52,
150
+                    child: FilledButton(
151
+                      onPressed: _loading ? null : _login,
152
+                      style: FilledButton.styleFrom(
153
+                        shape: RoundedRectangleBorder(
154
+                          borderRadius: BorderRadius.circular(12),
155
+                        ),
156
+                      ),
157
+                      child: _loading
158
+                          ? const SizedBox(
159
+                              width: 24,
160
+                              height: 24,
161
+                              child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white),
162
+                            )
163
+                          : const Text('登 录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
164
+                    ),
165
+                  ),
166
+                  const SizedBox(height: 24),
167
+
168
+                  // ---------- 底部版本信息 ----------
169
+                  Text(
170
+                    'v1.0.0',
171
+                    style: theme.textTheme.bodySmall?.copyWith(
172
+                      color: colorScheme.onSurfaceVariant.withAlpha(128),
173
+                    ),
174
+                  ),
175
+                ],
176
+              ),
177
+            ),
178
+          ),
179
+        ),
41 180
       ),
42 181
     );
43 182
   }

+ 176
- 0
mobile/lib/pages/profile/profile_page.dart Просмотреть файл

@@ -0,0 +1,176 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:provider/provider.dart';
3
+import '../../services/auth_service.dart';
4
+
5
+/// 个人中心
6
+class ProfilePage extends StatelessWidget {
7
+  const ProfilePage({super.key});
8
+
9
+  @override
10
+  Widget build(BuildContext context) {
11
+    final auth = context.watch<AuthService>();
12
+    final theme = Theme.of(context);
13
+    final colorScheme = theme.colorScheme;
14
+
15
+    return Scaffold(
16
+      appBar: AppBar(
17
+        title: const Text('个人中心'),
18
+        centerTitle: true,
19
+      ),
20
+      body: ListView(
21
+        children: [
22
+          // ---------- 用户头像卡片 ----------
23
+          Container(
24
+            padding: const EdgeInsets.all(24),
25
+            child: Column(
26
+              children: [
27
+                CircleAvatar(
28
+                  radius: 40,
29
+                  backgroundColor: colorScheme.primaryContainer,
30
+                  backgroundImage: auth.avatarUrl.isNotEmpty ? NetworkImage(auth.avatarUrl) : null,
31
+                  child: auth.avatarUrl.isEmpty
32
+                      ? Text(
33
+                          auth.displayName.isNotEmpty ? auth.displayName[0] : '?',
34
+                          style: TextStyle(fontSize: 32, color: colorScheme.onPrimaryContainer),
35
+                        )
36
+                      : null,
37
+                ),
38
+                const SizedBox(height: 12),
39
+                Text(
40
+                  auth.displayName,
41
+                  style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
42
+                ),
43
+                const SizedBox(height: 4),
44
+                Text(
45
+                  '@${auth.username}',
46
+                  style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.onSurfaceVariant),
47
+                ),
48
+              ],
49
+            ),
50
+          ),
51
+
52
+          const Divider(height: 1),
53
+
54
+          // ---------- 菜单列表 ----------
55
+          const SizedBox(height: 8),
56
+          _ProfileMenuItem(
57
+            icon: Icons.person_outline,
58
+            title: '个人信息',
59
+            subtitle: '修改姓名、手机号、密码等',
60
+            onTap: () {
61
+              ScaffoldMessenger.of(context).showSnackBar(
62
+                const SnackBar(content: Text('个人信息功能开发中...')),
63
+              );
64
+            },
65
+          ),
66
+          _ProfileMenuItem(
67
+            icon: Icons.notifications_outlined,
68
+            title: '消息通知',
69
+            subtitle: '推送设置、告警订阅',
70
+            onTap: () {
71
+              ScaffoldMessenger.of(context).showSnackBar(
72
+                const SnackBar(content: Text('消息通知功能开发中...')),
73
+              );
74
+            },
75
+          ),
76
+          _ProfileMenuItem(
77
+            icon: Icons.settings_outlined,
78
+            title: '系统设置',
79
+            subtitle: '主题、语言、缓存管理',
80
+            onTap: () {
81
+              ScaffoldMessenger.of(context).showSnackBar(
82
+                const SnackBar(content: Text('系统设置功能开发中...')),
83
+              );
84
+            },
85
+          ),
86
+          _ProfileMenuItem(
87
+            icon: Icons.help_outline,
88
+            title: '帮助与反馈',
89
+            subtitle: '常见问题、意见反馈',
90
+            onTap: () {
91
+              ScaffoldMessenger.of(context).showSnackBar(
92
+                const SnackBar(content: Text('帮助与反馈功能开发中...')),
93
+              );
94
+            },
95
+          ),
96
+          _ProfileMenuItem(
97
+            icon: Icons.info_outline,
98
+            title: '关于',
99
+            subtitle: '版本 v1.0.0',
100
+            onTap: () {
101
+              showAboutDialog(
102
+                context: context,
103
+                applicationName: '智慧水务管理系统',
104
+                applicationVersion: 'v1.0.0',
105
+                applicationIcon: Icon(Icons.water_drop, size: 48, color: colorScheme.primary),
106
+                children: const [
107
+                  Text('© 2024 智慧水务团队'),
108
+                ],
109
+              );
110
+            },
111
+          ),
112
+
113
+          const SizedBox(height: 24),
114
+
115
+          // ---------- 退出登录 ----------
116
+          Padding(
117
+            padding: const EdgeInsets.symmetric(horizontal: 16),
118
+            child: OutlinedButton.icon(
119
+              onPressed: () async {
120
+                final confirm = await showDialog<bool>(
121
+                  context: context,
122
+                  builder: (ctx) => AlertDialog(
123
+                    title: const Text('退出登录'),
124
+                    content: const Text('确定要退出当前账号吗?'),
125
+                    actions: [
126
+                      TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
127
+                      TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
128
+                    ],
129
+                  ),
130
+                );
131
+                if (confirm == true && context.mounted) {
132
+                  await auth.logout();
133
+                  // AuthService.notifyListeners 会自动切换到 LoginPage
134
+                }
135
+              },
136
+              icon: const Icon(Icons.logout),
137
+              label: const Text('退出登录'),
138
+              style: OutlinedButton.styleFrom(
139
+                foregroundColor: Colors.red,
140
+                side: const BorderSide(color: Colors.red),
141
+                padding: const EdgeInsets.symmetric(vertical: 14),
142
+                shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
143
+              ),
144
+            ),
145
+          ),
146
+          const SizedBox(height: 32),
147
+        ],
148
+      ),
149
+    );
150
+  }
151
+}
152
+
153
+class _ProfileMenuItem extends StatelessWidget {
154
+  final IconData icon;
155
+  final String title;
156
+  final String subtitle;
157
+  final VoidCallback onTap;
158
+
159
+  const _ProfileMenuItem({
160
+    required this.icon,
161
+    required this.title,
162
+    required this.subtitle,
163
+    required this.onTap,
164
+  });
165
+
166
+  @override
167
+  Widget build(BuildContext context) {
168
+    return ListTile(
169
+      leading: Icon(icon, color: Colors.blue.shade700),
170
+      title: Text(title),
171
+      subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
172
+      trailing: const Icon(Icons.chevron_right, color: Colors.grey),
173
+      onTap: onTap,
174
+    );
175
+  }
176
+}

+ 96
- 0
mobile/lib/services/api_service.dart Просмотреть файл

@@ -0,0 +1,96 @@
1
+import 'package:dio/dio.dart';
2
+import 'package:flutter/foundation.dart';
3
+import 'auth_service.dart';
4
+
5
+/// 全局 HTTP 客户端 —— Dio 封装 + Token 拦截器
6
+class ApiService {
7
+  ApiService._internal();
8
+  static final ApiService instance = ApiService._internal();
9
+
10
+  late final Dio dio;
11
+  AuthService? _authService;
12
+
13
+  /// 在 app 启动时调用一次,注入 AuthService 以便拦截器读取 token
14
+  void init(AuthService auth) {
15
+    _authService = auth;
16
+  }
17
+
18
+  /// 工厂方法:外部通过 ApiService.instance 使用
19
+  factory ApiService() => instance;
20
+
21
+  // --------------- Dio 初始化 ---------------
22
+
23
+  Dio _createDio() {
24
+    final d = Dio(BaseOptions(
25
+      baseUrl: 'http://10.0.2.2:8080/api',
26
+      connectTimeout: const Duration(seconds: 15),
27
+      receiveTimeout: const Duration(seconds: 15),
28
+      headers: {'Content-Type': 'application/json'},
29
+    ));
30
+
31
+    // ---------- Token 拦截器 ----------
32
+    d.interceptors.add(InterceptorsWrapper(
33
+      onRequest: (options, handler) {
34
+        final token = _authService?.token ?? '';
35
+        if (token.isNotEmpty) {
36
+          options.headers['Authorization'] = 'Bearer $token';
37
+        }
38
+        debugPrint('→ ${options.method} ${options.uri}');
39
+        handler.next(options);
40
+      },
41
+      onResponse: (response, handler) {
42
+        debugPrint('← ${response.statusCode} ${response.requestOptions.uri}');
43
+        handler.next(response);
44
+      },
45
+      onError: (error, handler) async {
46
+        debugPrint('✗ ${error.message} ${error.requestOptions.uri}');
47
+
48
+        // 401 → Token 过期,自动清除登录态
49
+        if (error.response?.statusCode == 401) {
50
+          await _authService?.logout();
51
+        }
52
+
53
+        handler.next(error);
54
+      },
55
+    ));
56
+
57
+    // ---------- 日志拦截器(仅 debug) ----------
58
+    if (kDebugMode) {
59
+      d.interceptors.add(LogInterceptor(
60
+        requestBody: true,
61
+        responseBody: true,
62
+        logPrint: (obj) => debugPrint(obj.toString()),
63
+      ));
64
+    }
65
+
66
+    return d;
67
+  }
68
+
69
+  /// 懒加载 Dio 实例
70
+  Dio get client {
71
+    dio = _createDio();
72
+    return dio;
73
+  }
74
+
75
+  // --------------- 便捷方法 ---------------
76
+
77
+  Future<Response> get(String path, {Map<String, dynamic>? queryParameters}) {
78
+    return client.get(path, queryParameters: queryParameters);
79
+  }
80
+
81
+  Future<Response> post(String path, {dynamic data, Map<String, dynamic>? queryParameters}) {
82
+    return client.post(path, data: data, queryParameters: queryParameters);
83
+  }
84
+
85
+  Future<Response> put(String path, {dynamic data, Map<String, dynamic>? queryParameters}) {
86
+    return client.put(path, data: data, queryParameters: queryParameters);
87
+  }
88
+
89
+  Future<Response> delete(String path, {dynamic data, Map<String, dynamic>? queryParameters}) {
90
+    return client.delete(path, data: data, queryParameters: queryParameters);
91
+  }
92
+
93
+  Future<Response> patch(String path, {dynamic data, Map<String, dynamic>? queryParameters}) {
94
+    return client.patch(path, data: data, queryParameters: queryParameters);
95
+  }
96
+}

+ 93
- 7
mobile/lib/services/auth_service.dart Просмотреть файл

@@ -2,32 +2,118 @@ import 'package:flutter/foundation.dart';
2 2
 import 'package:dio/dio.dart';
3 3
 import 'package:shared_preferences/shared_preferences.dart';
4 4
 
5
+/// 用户认证服务 —— 登录 / 登出 / Token 持久化
5 6
 class AuthService extends ChangeNotifier {
7
+  // --------------- 状态 ---------------
6 8
   String _token = '';
9
+  String _username = '';
10
+  String _displayName = '';
11
+  String _avatarUrl = '';
12
+  bool _isLoading = false;
13
+
14
+  String get token => _token;
15
+  String get username => _username;
16
+  String get displayName => _displayName.isNotEmpty ? _displayName : _username;
17
+  String get avatarUrl => _avatarUrl;
7 18
   bool get isLoggedIn => _token.isNotEmpty;
19
+  bool get isLoading => _isLoading;
20
+
21
+  // --------------- Dio (内部使用,不依赖 ApiService 避免循环) ---------------
22
+  final Dio _dio = Dio(BaseOptions(
23
+    baseUrl: 'http://10.0.2.2:8080/api',
24
+    connectTimeout: const Duration(seconds: 15),
25
+    receiveTimeout: const Duration(seconds: 15),
26
+  ));
8 27
 
9
-  final Dio _dio = Dio(BaseOptions(baseUrl: 'http://10.0.2.2:8080/api/base'));
28
+  // --------------- 初始化(app 启动时恢复 session) ---------------
29
+  Future<void> init() async {
30
+    final prefs = await SharedPreferences.getInstance();
31
+    _token = prefs.getString('token') ?? '';
32
+    _username = prefs.getString('username') ?? '';
33
+    _displayName = prefs.getString('displayName') ?? '';
34
+    _avatarUrl = prefs.getString('avatarUrl') ?? '';
35
+    notifyListeners();
36
+  }
10 37
 
38
+  // --------------- 登录 ---------------
11 39
   Future<bool> login(String username, String password) async {
40
+    _isLoading = true;
41
+    notifyListeners();
42
+
12 43
     try {
13
-      final res = await _dio.post('/auth/login', data: {'username': username, 'password': password});
14
-      if (res.data['code'] == 200) {
15
-        _token = res.data['data'];
16
-        final prefs = await SharedPreferences.getInstance();
17
-        await prefs.setString('token', _token);
44
+      final res = await _dio.post('/base/auth/login', data: {
45
+        'username': username,
46
+        'password': password,
47
+      });
48
+
49
+      // 兼容两种响应格式
50
+      final data = res.data;
51
+      if (data is Map && (data['code'] == 200 || data['code'] == 0)) {
52
+        final payload = data['data'] ?? data;
53
+        _token = payload is String ? payload : (payload['token'] ?? payload['accessToken'] ?? '').toString();
54
+        _username = payload is Map ? (payload['username'] ?? username).toString() : username;
55
+        _displayName = payload is Map ? (payload['displayName'] ?? payload['nickName'] ?? '').toString() : '';
56
+        _avatarUrl = payload is Map ? (payload['avatar'] ?? '').toString() : '';
57
+
58
+        await _persist();
59
+        _isLoading = false;
18 60
         notifyListeners();
19 61
         return true;
20 62
       }
21 63
     } catch (e) {
22
-      debugPrint('Login failed: $e');
64
+      debugPrint('Login error: $e');
23 65
     }
66
+
67
+    _isLoading = false;
68
+    notifyListeners();
24 69
     return false;
25 70
   }
26 71
 
72
+  // --------------- 登出 ---------------
27 73
   Future<void> logout() async {
28 74
     _token = '';
75
+    _username = '';
76
+    _displayName = '';
77
+    _avatarUrl = '';
78
+
29 79
     final prefs = await SharedPreferences.getInstance();
30 80
     await prefs.remove('token');
81
+    await prefs.remove('username');
82
+    await prefs.remove('displayName');
83
+    await prefs.remove('avatarUrl');
84
+
31 85
     notifyListeners();
32 86
   }
87
+
88
+  // --------------- 刷新用户信息 ---------------
89
+  Future<void> refreshProfile() async {
90
+    if (_token.isEmpty) return;
91
+    try {
92
+      final res = await _dio.get(
93
+        '/base/user/info',
94
+        options: Options(headers: {'Authorization': 'Bearer $_token'}),
95
+      );
96
+      final data = res.data;
97
+      if (data is Map && (data['code'] == 200 || data['code'] == 0)) {
98
+        final payload = data['data'] ?? data;
99
+        if (payload is Map) {
100
+          _displayName = (payload['displayName'] ?? payload['nickName'] ?? _displayName).toString();
101
+          _avatarUrl = (payload['avatar'] ?? _avatarUrl).toString();
102
+          await _persist();
103
+          notifyListeners();
104
+        }
105
+      }
106
+    } catch (e) {
107
+      debugPrint('refreshProfile error: $e');
108
+    }
109
+  }
110
+
111
+  // --------------- 持久化 ---------------
112
+  Future<void> _persist() async {
113
+    final prefs = await SharedPreferences.getInstance();
114
+    await prefs.setString('token', _token);
115
+    await prefs.setString('username', _username);
116
+    await prefs.setString('displayName', _displayName);
117
+    await prefs.setString('avatarUrl', _avatarUrl);
118
+  }
33 119
 }

+ 42
- 0
mobile/lib/widgets/empty_state.dart Просмотреть файл

@@ -0,0 +1,42 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 空状态占位组件
4
+class EmptyState extends StatelessWidget {
5
+  final IconData icon;
6
+  final String title;
7
+  final String? subtitle;
8
+  final Widget? action;
9
+
10
+  const EmptyState({
11
+    super.key,
12
+    this.icon = Icons.inbox_outlined,
13
+    required this.title,
14
+    this.subtitle,
15
+    this.action,
16
+  });
17
+
18
+  @override
19
+  Widget build(BuildContext context) {
20
+    return Center(
21
+      child: Padding(
22
+        padding: const EdgeInsets.all(32),
23
+        child: Column(
24
+          mainAxisSize: MainAxisSize.min,
25
+          children: [
26
+            Icon(icon, size: 64, color: Colors.grey.shade400),
27
+            const SizedBox(height: 16),
28
+            Text(title, style: TextStyle(fontSize: 16, color: Colors.grey.shade700)),
29
+            if (subtitle != null) ...[
30
+              const SizedBox(height: 8),
31
+              Text(subtitle!, style: TextStyle(fontSize: 13, color: Colors.grey.shade500)),
32
+            ],
33
+            if (action != null) ...[
34
+              const SizedBox(height: 24),
35
+              action!,
36
+            ],
37
+          ],
38
+        ),
39
+      ),
40
+    );
41
+  }
42
+}

+ 36
- 0
mobile/lib/widgets/error_retry.dart Просмотреть файл

@@ -0,0 +1,36 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 错误重试组件
4
+class ErrorRetry extends StatelessWidget {
5
+  final String message;
6
+  final VoidCallback onRetry;
7
+
8
+  const ErrorRetry({
9
+    super.key,
10
+    required this.message,
11
+    required this.onRetry,
12
+  });
13
+
14
+  @override
15
+  Widget build(BuildContext context) {
16
+    return Center(
17
+      child: Padding(
18
+        padding: const EdgeInsets.all(32),
19
+        child: Column(
20
+          mainAxisSize: MainAxisSize.min,
21
+          children: [
22
+            Icon(Icons.error_outline, size: 64, color: Colors.red.shade300),
23
+            const SizedBox(height: 16),
24
+            Text(message, style: TextStyle(fontSize: 15, color: Colors.grey.shade700)),
25
+            const SizedBox(height: 16),
26
+            FilledButton.icon(
27
+              onPressed: onRetry,
28
+              icon: const Icon(Icons.refresh),
29
+              label: const Text('重试'),
30
+            ),
31
+          ],
32
+        ),
33
+      ),
34
+    );
35
+  }
36
+}

+ 45
- 0
mobile/lib/widgets/loading_overlay.dart Просмотреть файл

@@ -0,0 +1,45 @@
1
+import 'package:flutter/material.dart';
2
+
3
+/// 全局加载遮罩组件
4
+class LoadingOverlay extends StatelessWidget {
5
+  final bool isLoading;
6
+  final Widget child;
7
+  final String? message;
8
+
9
+  const LoadingOverlay({
10
+    super.key,
11
+    required this.isLoading,
12
+    required this.child,
13
+    this.message,
14
+  });
15
+
16
+  @override
17
+  Widget build(BuildContext context) {
18
+    return Stack(
19
+      children: [
20
+        child,
21
+        if (isLoading)
22
+          Container(
23
+            color: Colors.black.withAlpha(80),
24
+            child: Center(
25
+              child: Card(
26
+                child: Padding(
27
+                  padding: const EdgeInsets.all(24),
28
+                  child: Column(
29
+                    mainAxisSize: MainAxisSize.min,
30
+                    children: [
31
+                      const CircularProgressIndicator(),
32
+                      if (message != null) ...[
33
+                        const SizedBox(height: 16),
34
+                        Text(message!),
35
+                      ],
36
+                    ],
37
+                  ),
38
+                ),
39
+              ),
40
+            ),
41
+          ),
42
+      ],
43
+    );
44
+  }
45
+}