Quellcode durchsuchen

feat(mobile): #80 供水管理移动端完整实现

- 新增 monitor_page.dart: 实时监测列表(流量/压力/液位/水质数据,类型筛选,下拉刷新)
- 新增 alert_page.dart: 报警推送列表(分级展示,已读/未读,详情弹窗,全部已读)
- 新增 dispatch_page.dart: 今日值班(班次概览/值班人员联系方式/调度指令台账)
- 新增 quality_page.dart: 水质查看(原水/出厂水/末梢水分Tab展示,达标状态指标表)
- 新增 water_service.dart: 供水相关API服务(含完整mock数据模型)
- 注册4条路由到main.dart
- 更新water_supply_tab.dart快捷操作入口
- 添加intl依赖用于日期格式化
bot_dev2 vor 5 Tagen
Ursprung
Commit
f800984a7e

+ 15
- 3
mobile/lib/main.dart Datei anzeigen

@@ -4,6 +4,10 @@ import 'services/auth_service.dart';
4 4
 import 'services/api_service.dart';
5 5
 import 'pages/login/login_page.dart';
6 6
 import 'pages/home/home_page.dart';
7
+import 'pages/water/monitor_page.dart';
8
+import 'pages/water/alert_page.dart';
9
+import 'pages/water/dispatch_page.dart';
10
+import 'pages/water/quality_page.dart';
7 11
 
8 12
 void main() async {
9 13
   WidgetsFlutterBinding.ensureInitialized();
@@ -41,9 +45,17 @@ class WaterApp extends StatelessWidget {
41 45
           brightness: Brightness.dark,
42 46
         ),
43 47
         themeMode: ThemeMode.system,
44
-        home: Consumer<AuthService>(
45
-          builder: (_, auth, __) => auth.isLoggedIn ? const HomePage() : const LoginPage(),
46
-        ),
48
+        initialRoute: '/',
49
+        routes: {
50
+          '/': (context) => Consumer<AuthService>(
51
+                builder: (_, auth, __) =>
52
+                    auth.isLoggedIn ? const HomePage() : const LoginPage(),
53
+              ),
54
+          '/water/monitor': (context) => const MonitorPage(),
55
+          '/water/alert': (context) => const AlertPage(),
56
+          '/water/dispatch': (context) => const DispatchPage(),
57
+          '/water/quality': (context) => const QualityPage(),
58
+        },
47 59
       ),
48 60
     );
49 61
   }

+ 20
- 4
mobile/lib/pages/home/tabs/water_supply_tab.dart Datei anzeigen

@@ -34,10 +34,26 @@ class WaterSupplyTab extends StatelessWidget {
34 34
             spacing: 12,
35 35
             runSpacing: 12,
36 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: () {}),
37
+              _ActionChip(
38
+                icon: Icons.sensors,
39
+                label: '实时监测',
40
+                onTap: () => Navigator.pushNamed(context, '/water/monitor'),
41
+              ),
42
+              _ActionChip(
43
+                icon: Icons.warning_amber,
44
+                label: '报警推送',
45
+                onTap: () => Navigator.pushNamed(context, '/water/alert'),
46
+              ),
47
+              _ActionChip(
48
+                icon: Icons.assignment_ind,
49
+                label: '今日值班',
50
+                onTap: () => Navigator.pushNamed(context, '/water/dispatch'),
51
+              ),
52
+              _ActionChip(
53
+                icon: Icons.science,
54
+                label: '水质查看',
55
+                onTap: () => Navigator.pushNamed(context, '/water/quality'),
56
+              ),
41 57
             ],
42 58
           ),
43 59
           const SizedBox(height: 24),

+ 506
- 0
mobile/lib/pages/water/alert_page.dart Datei anzeigen

@@ -0,0 +1,506 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:intl/intl.dart';
3
+import '../../services/water_service.dart';
4
+
5
+/// 报警推送列表页面
6
+class AlertPage extends StatefulWidget {
7
+  const AlertPage({super.key});
8
+  @override
9
+  State<AlertPage> createState() => _AlertPageState();
10
+}
11
+
12
+class _AlertPageState extends State<AlertPage> {
13
+  final WaterService _service = WaterService.instance;
14
+  List<AlertItem> _alerts = [];
15
+  bool _isLoading = true;
16
+  String? _error;
17
+  bool _unreadOnly = false;
18
+
19
+  @override
20
+  void initState() {
21
+    super.initState();
22
+    _loadData();
23
+  }
24
+
25
+  Future<void> _loadData() async {
26
+    setState(() {
27
+      _isLoading = true;
28
+      _error = null;
29
+    });
30
+    try {
31
+      final data = await _service.getAlertList(unreadOnly: _unreadOnly);
32
+      if (mounted) {
33
+        setState(() {
34
+          _alerts = data;
35
+          _isLoading = false;
36
+        });
37
+      }
38
+    } catch (e) {
39
+      if (mounted) {
40
+        setState(() {
41
+          _error = e.toString();
42
+          _isLoading = false;
43
+        });
44
+      }
45
+    }
46
+  }
47
+
48
+  int get _unreadCount => _alerts.where((a) => !a.isRead).length;
49
+
50
+  Future<void> _markRead(AlertItem alert) async {
51
+    if (alert.isRead) return;
52
+    await _service.markAlertRead(alert.id);
53
+    if (mounted) {
54
+      setState(() => alert.isRead = true);
55
+    }
56
+  }
57
+
58
+  Future<void> _markAllRead() async {
59
+    await _service.markAllAlertsRead();
60
+    if (mounted) {
61
+      setState(() {
62
+        for (final a in _alerts) {
63
+          a.isRead = true;
64
+        }
65
+      });
66
+      ScaffoldMessenger.of(context).showSnackBar(
67
+        const SnackBar(content: Text('已全部标记为已读')),
68
+      );
69
+    }
70
+  }
71
+
72
+  @override
73
+  Widget build(BuildContext context) {
74
+    final theme = Theme.of(context);
75
+    return Scaffold(
76
+      appBar: AppBar(
77
+        title: const Text('报警推送'),
78
+        centerTitle: true,
79
+        actions: [
80
+          if (_unreadCount > 0)
81
+            TextButton(
82
+              onPressed: _markAllRead,
83
+              child: Text('全部已读 ($_unreadCount)'),
84
+            ),
85
+          IconButton(
86
+            icon: const Icon(Icons.refresh),
87
+            onPressed: _isLoading ? null : _loadData,
88
+          ),
89
+        ],
90
+      ),
91
+      body: Column(
92
+        children: [
93
+          // 筛选栏
94
+          _buildFilterBar(theme),
95
+          // 列表
96
+          Expanded(child: _buildBody(theme)),
97
+        ],
98
+      ),
99
+    );
100
+  }
101
+
102
+  Widget _buildFilterBar(ThemeData theme) {
103
+    return Container(
104
+      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
105
+      child: Row(
106
+        children: [
107
+          _FilterTab(label: '全部', selected: !_unreadOnly, onTap: () {
108
+            if (_unreadOnly) {
109
+              setState(() => _unreadOnly = false);
110
+              _loadData();
111
+            }
112
+          }),
113
+          const SizedBox(width: 12),
114
+          _FilterTab(
115
+            label: '未读',
116
+            selected: _unreadOnly,
117
+            badge: _unreadCount,
118
+            onTap: () {
119
+              if (!_unreadOnly) {
120
+                setState(() => _unreadOnly = true);
121
+                _loadData();
122
+              }
123
+            },
124
+          ),
125
+        ],
126
+      ),
127
+    );
128
+  }
129
+
130
+  Widget _buildBody(ThemeData theme) {
131
+    if (_isLoading) {
132
+      return const Center(child: CircularProgressIndicator());
133
+    }
134
+    if (_error != null) {
135
+      return Center(
136
+        child: Column(
137
+          mainAxisSize: MainAxisSize.min,
138
+          children: [
139
+            Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
140
+            const SizedBox(height: 16),
141
+            Text('加载失败', style: theme.textTheme.titleMedium),
142
+            const SizedBox(height: 16),
143
+            FilledButton.icon(
144
+              onPressed: _loadData,
145
+              icon: const Icon(Icons.refresh),
146
+              label: const Text('重试'),
147
+            ),
148
+          ],
149
+        ),
150
+      );
151
+    }
152
+    if (_alerts.isEmpty) {
153
+      return Center(
154
+        child: Column(
155
+          mainAxisSize: MainAxisSize.min,
156
+          children: [
157
+            Icon(Icons.notifications_none, size: 48, color: Colors.grey.shade400),
158
+            const SizedBox(height: 16),
159
+            Text(
160
+              _unreadOnly ? '没有未读报警' : '暂无报警信息',
161
+              style: theme.textTheme.titleMedium,
162
+            ),
163
+          ],
164
+        ),
165
+      );
166
+    }
167
+
168
+    return RefreshIndicator(
169
+      onRefresh: _loadData,
170
+      child: ListView.builder(
171
+        padding: const EdgeInsets.all(12),
172
+        itemCount: _alerts.length,
173
+        itemBuilder: (context, index) {
174
+          final alert = _alerts[index];
175
+          return _AlertCard(
176
+            alert: alert,
177
+            onTap: () => _showAlertDetail(context, alert),
178
+          );
179
+        },
180
+      ),
181
+    );
182
+  }
183
+
184
+  void _showAlertDetail(BuildContext context, AlertItem alert) {
185
+    _markRead(alert);
186
+    showModalBottomSheet(
187
+      context: context,
188
+      isScrollControlled: true,
189
+      shape: const RoundedRectangleBorder(
190
+        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
191
+      ),
192
+      builder: (context) => _AlertDetailSheet(alert: alert),
193
+    );
194
+  }
195
+}
196
+
197
+/// 报警卡片
198
+class _AlertCard extends StatelessWidget {
199
+  final AlertItem alert;
200
+  final VoidCallback onTap;
201
+
202
+  const _AlertCard({required this.alert, required this.onTap});
203
+
204
+  @override
205
+  Widget build(BuildContext context) {
206
+    final theme = Theme.of(context);
207
+    final levelColor = Color(alert.level.color);
208
+    final dateFormat = DateFormat('MM-dd HH:mm');
209
+
210
+    return Card(
211
+      margin: const EdgeInsets.only(bottom: 8),
212
+      elevation: 0,
213
+      shape: RoundedRectangleBorder(
214
+        borderRadius: BorderRadius.circular(12),
215
+        side: BorderSide(
216
+          color: alert.isRead ? Colors.grey.shade200 : levelColor.withAlpha(100),
217
+        ),
218
+      ),
219
+      color: alert.isRead ? null : levelColor.withAlpha(8),
220
+      child: InkWell(
221
+        onTap: onTap,
222
+        borderRadius: BorderRadius.circular(12),
223
+        child: Padding(
224
+          padding: const EdgeInsets.all(14),
225
+          child: Row(
226
+            crossAxisAlignment: CrossAxisAlignment.start,
227
+            children: [
228
+              // 级别图标
229
+              Container(
230
+                width: 40,
231
+                height: 40,
232
+                decoration: BoxDecoration(
233
+                  color: levelColor.withAlpha(30),
234
+                  shape: BoxShape.circle,
235
+                ),
236
+                child: Icon(
237
+                  alert.level == AlertLevel.critical
238
+                      ? Icons.error
239
+                      : alert.level == AlertLevel.warning
240
+                          ? Icons.warning_amber
241
+                          : Icons.info_outline,
242
+                  color: levelColor,
243
+                  size: 22,
244
+                ),
245
+              ),
246
+              const SizedBox(width: 12),
247
+              // 内容
248
+              Expanded(
249
+                child: Column(
250
+                  crossAxisAlignment: CrossAxisAlignment.start,
251
+                  children: [
252
+                    Row(
253
+                      children: [
254
+                        Container(
255
+                          padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
256
+                          decoration: BoxDecoration(
257
+                            color: levelColor.withAlpha(30),
258
+                            borderRadius: BorderRadius.circular(4),
259
+                          ),
260
+                          child: Text(
261
+                            alert.level.label,
262
+                            style: TextStyle(fontSize: 10, color: levelColor, fontWeight: FontWeight.w600),
263
+                          ),
264
+                        ),
265
+                        const SizedBox(width: 8),
266
+                        Expanded(
267
+                          child: Text(
268
+                            alert.title,
269
+                            style: theme.textTheme.titleSmall?.copyWith(
270
+                              fontWeight: alert.isRead ? FontWeight.normal : FontWeight.w600,
271
+                            ),
272
+                            maxLines: 1,
273
+                            overflow: TextOverflow.ellipsis,
274
+                          ),
275
+                        ),
276
+                      ],
277
+                    ),
278
+                    const SizedBox(height: 6),
279
+                    Text(
280
+                      alert.content,
281
+                      style: TextStyle(
282
+                        fontSize: 12,
283
+                        color: Colors.grey.shade600,
284
+                      ),
285
+                      maxLines: 2,
286
+                      overflow: TextOverflow.ellipsis,
287
+                    ),
288
+                    const SizedBox(height: 8),
289
+                    Row(
290
+                      children: [
291
+                        Icon(Icons.location_on_outlined, size: 12, color: Colors.grey.shade500),
292
+                        const SizedBox(width: 2),
293
+                        Text(alert.location, style: TextStyle(fontSize: 11, color: Colors.grey.shade500)),
294
+                        const SizedBox(width: 12),
295
+                        Icon(Icons.access_time, size: 12, color: Colors.grey.shade500),
296
+                        const SizedBox(width: 2),
297
+                        Text(
298
+                          dateFormat.format(alert.createTime),
299
+                          style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
300
+                        ),
301
+                      ],
302
+                    ),
303
+                  ],
304
+                ),
305
+              ),
306
+              // 未读标记
307
+              if (!alert.isRead)
308
+                Container(
309
+                  width: 8,
310
+                  height: 8,
311
+                  margin: const EdgeInsets.only(top: 4),
312
+                  decoration: BoxDecoration(
313
+                    shape: BoxShape.circle,
314
+                    color: levelColor,
315
+                  ),
316
+                ),
317
+            ],
318
+          ),
319
+        ),
320
+      ),
321
+    );
322
+  }
323
+}
324
+
325
+/// 报警详情底部弹窗
326
+class _AlertDetailSheet extends StatelessWidget {
327
+  final AlertItem alert;
328
+
329
+  const _AlertDetailSheet({required this.alert});
330
+
331
+  @override
332
+  Widget build(BuildContext context) {
333
+    final theme = Theme.of(context);
334
+    final levelColor = Color(alert.level.color);
335
+    final dateFormat = DateFormat('yyyy-MM-dd HH:mm:ss');
336
+
337
+    return DraggableScrollableSheet(
338
+      initialChildSize: 0.65,
339
+      minChildSize: 0.4,
340
+      maxChildSize: 0.85,
341
+      expand: false,
342
+      builder: (context, scrollController) {
343
+        return SingleChildScrollView(
344
+          controller: scrollController,
345
+          padding: const EdgeInsets.all(20),
346
+          child: Column(
347
+            crossAxisAlignment: CrossAxisAlignment.start,
348
+            children: [
349
+              // 拖拽指示器
350
+              Center(
351
+                child: Container(
352
+                  width: 40,
353
+                  height: 4,
354
+                  decoration: BoxDecoration(
355
+                    color: Colors.grey.shade300,
356
+                    borderRadius: BorderRadius.circular(2),
357
+                  ),
358
+                ),
359
+              ),
360
+              const SizedBox(height: 20),
361
+              // 级别标签
362
+              Container(
363
+                padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
364
+                decoration: BoxDecoration(
365
+                  color: levelColor.withAlpha(30),
366
+                  borderRadius: BorderRadius.circular(6),
367
+                ),
368
+                child: Text(
369
+                  '${alert.level.label}报警',
370
+                  style: TextStyle(color: levelColor, fontWeight: FontWeight.w600),
371
+                ),
372
+              ),
373
+              const SizedBox(height: 12),
374
+              // 标题
375
+              Text(
376
+                alert.title,
377
+                style: theme.textTheme.titleLarge,
378
+              ),
379
+              const SizedBox(height: 16),
380
+              // 详情信息
381
+              _DetailRow(icon: Icons.location_on, label: '位置', value: alert.location),
382
+              _DetailRow(icon: Icons.devices, label: '设备', value: alert.deviceName),
383
+              _DetailRow(icon: Icons.access_time, label: '时间', value: dateFormat.format(alert.createTime)),
384
+              _DetailRow(icon: Icons.person, label: '处理人', value: alert.handlerName),
385
+              const Divider(height: 24),
386
+              // 内容
387
+              Text('详细描述', style: theme.textTheme.titleSmall),
388
+              const SizedBox(height: 8),
389
+              Text(
390
+                alert.content,
391
+                style: theme.textTheme.bodyMedium?.copyWith(height: 1.6),
392
+              ),
393
+              const SizedBox(height: 24),
394
+              // 操作按钮
395
+              Row(
396
+                children: [
397
+                  Expanded(
398
+                    child: OutlinedButton.icon(
399
+                      onPressed: () => Navigator.pop(context),
400
+                      icon: const Icon(Icons.phone),
401
+                      label: const Text('联系处理人'),
402
+                    ),
403
+                  ),
404
+                  const SizedBox(width: 12),
405
+                  Expanded(
406
+                    child: FilledButton.icon(
407
+                      onPressed: () {
408
+                        Navigator.pop(context);
409
+                        ScaffoldMessenger.of(context).showSnackBar(
410
+                          const SnackBar(content: Text('已派单处理')),
411
+                        );
412
+                      },
413
+                      icon: const Icon(Icons.assignment),
414
+                      label: const Text('派单处理'),
415
+                    ),
416
+                  ),
417
+                ],
418
+              ),
419
+              const SizedBox(height: 20),
420
+            ],
421
+          ),
422
+        );
423
+      },
424
+    );
425
+  }
426
+}
427
+
428
+class _DetailRow extends StatelessWidget {
429
+  final IconData icon;
430
+  final String label;
431
+  final String value;
432
+
433
+  const _DetailRow({required this.icon, required this.label, required this.value});
434
+
435
+  @override
436
+  Widget build(BuildContext context) {
437
+    return Padding(
438
+      padding: const EdgeInsets.only(bottom: 10),
439
+      child: Row(
440
+        children: [
441
+          Icon(icon, size: 16, color: Colors.grey.shade600),
442
+          const SizedBox(width: 8),
443
+          SizedBox(
444
+            width: 60,
445
+            child: Text(label, style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
446
+          ),
447
+          Expanded(
448
+            child: Text(value, style: const TextStyle(fontSize: 13)),
449
+          ),
450
+        ],
451
+      ),
452
+    );
453
+  }
454
+}
455
+
456
+class _FilterTab extends StatelessWidget {
457
+  final String label;
458
+  final bool selected;
459
+  final VoidCallback onTap;
460
+  final int? badge;
461
+
462
+  const _FilterTab({required this.label, required this.selected, required this.onTap, this.badge});
463
+
464
+  @override
465
+  Widget build(BuildContext context) {
466
+    final theme = Theme.of(context);
467
+    return InkWell(
468
+      onTap: onTap,
469
+      borderRadius: BorderRadius.circular(8),
470
+      child: Container(
471
+        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
472
+        decoration: BoxDecoration(
473
+          color: selected ? theme.colorScheme.primaryContainer : Colors.transparent,
474
+          borderRadius: BorderRadius.circular(8),
475
+        ),
476
+        child: Row(
477
+          mainAxisSize: MainAxisSize.min,
478
+          children: [
479
+            Text(
480
+              label,
481
+              style: TextStyle(
482
+                fontSize: 13,
483
+                color: selected ? theme.colorScheme.primary : Colors.grey.shade700,
484
+                fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
485
+              ),
486
+            ),
487
+            if (badge != null && badge! > 0) ...[
488
+              const SizedBox(width: 4),
489
+              Container(
490
+                padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1),
491
+                decoration: BoxDecoration(
492
+                  color: Colors.red,
493
+                  borderRadius: BorderRadius.circular(8),
494
+                ),
495
+                child: Text(
496
+                  '$badge',
497
+                  style: const TextStyle(fontSize: 10, color: Colors.white),
498
+                ),
499
+              ),
500
+            ],
501
+          ],
502
+        ),
503
+      ),
504
+    );
505
+  }
506
+}

+ 431
- 0
mobile/lib/pages/water/dispatch_page.dart Datei anzeigen

@@ -0,0 +1,431 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:intl/intl.dart';
3
+import '../../services/water_service.dart';
4
+
5
+/// 今日值班页面
6
+class DispatchPage extends StatefulWidget {
7
+  const DispatchPage({super.key});
8
+  @override
9
+  State<DispatchPage> createState() => _DispatchPageState();
10
+}
11
+
12
+class _DispatchPageState extends State<DispatchPage> {
13
+  final WaterService _service = WaterService.instance;
14
+  DutyInfo? _dutyInfo;
15
+  bool _isLoading = true;
16
+  String? _error;
17
+
18
+  @override
19
+  void initState() {
20
+    super.initState();
21
+    _loadData();
22
+  }
23
+
24
+  Future<void> _loadData() async {
25
+    setState(() {
26
+      _isLoading = true;
27
+      _error = null;
28
+    });
29
+    try {
30
+      final data = await _service.getTodayDuty();
31
+      if (mounted) {
32
+        setState(() {
33
+          _dutyInfo = data;
34
+          _isLoading = false;
35
+        });
36
+      }
37
+    } catch (e) {
38
+      if (mounted) {
39
+        setState(() {
40
+          _error = e.toString();
41
+          _isLoading = false;
42
+        });
43
+      }
44
+    }
45
+  }
46
+
47
+  @override
48
+  Widget build(BuildContext context) {
49
+    final theme = Theme.of(context);
50
+    return Scaffold(
51
+      appBar: AppBar(
52
+        title: const Text('今日值班'),
53
+        centerTitle: true,
54
+        actions: [
55
+          IconButton(
56
+            icon: const Icon(Icons.refresh),
57
+            onPressed: _isLoading ? null : _loadData,
58
+          ),
59
+        ],
60
+      ),
61
+      body: _buildBody(theme),
62
+    );
63
+  }
64
+
65
+  Widget _buildBody(ThemeData theme) {
66
+    if (_isLoading) {
67
+      return const Center(child: CircularProgressIndicator());
68
+    }
69
+    if (_error != null) {
70
+      return Center(
71
+        child: Column(
72
+          mainAxisSize: MainAxisSize.min,
73
+          children: [
74
+            Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
75
+            const SizedBox(height: 16),
76
+            Text('加载失败', style: theme.textTheme.titleMedium),
77
+            const SizedBox(height: 16),
78
+            FilledButton.icon(
79
+              onPressed: _loadData,
80
+              icon: const Icon(Icons.refresh),
81
+              label: const Text('重试'),
82
+            ),
83
+          ],
84
+        ),
85
+      );
86
+    }
87
+
88
+    final info = _dutyInfo!;
89
+    final dateFormat = DateFormat('yyyy年MM月dd日');
90
+
91
+    return RefreshIndicator(
92
+      onRefresh: _loadData,
93
+      child: ListView(
94
+        padding: const EdgeInsets.all(16),
95
+        children: [
96
+          // 班次概览卡片
97
+          _ShiftOverviewCard(
98
+            date: dateFormat.format(info.date),
99
+            shiftName: info.shiftName,
100
+            shiftTime: info.shiftTime,
101
+            memberCount: info.members.length + 1,
102
+            instructionCount: info.instructions.length,
103
+          ),
104
+          const SizedBox(height: 20),
105
+
106
+          // 值班长
107
+          _SectionHeader(title: '值班长', icon: Icons.star),
108
+          const SizedBox(height: 8),
109
+          _PersonCard(
110
+            person: info.leader,
111
+            isLeader: true,
112
+          ),
113
+          const SizedBox(height: 20),
114
+
115
+          // 值班人员
116
+          _SectionHeader(title: '值班人员', icon: Icons.people),
117
+          const SizedBox(height: 8),
118
+          ...info.members.map((member) => Padding(
119
+                padding: const EdgeInsets.only(bottom: 8),
120
+                child: _PersonCard(person: member),
121
+              )),
122
+          const SizedBox(height: 20),
123
+
124
+          // 调度指令台账
125
+          _SectionHeader(
126
+            title: '调度指令台账',
127
+            icon: Icons.assignment,
128
+            trailing: Text(
129
+              '${info.instructions.length} 条',
130
+              style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
131
+            ),
132
+          ),
133
+          const SizedBox(height: 8),
134
+          ...info.instructions.map((instruction) => Padding(
135
+                padding: const EdgeInsets.only(bottom: 8),
136
+                child: _InstructionCard(instruction: instruction),
137
+              )),
138
+          const SizedBox(height: 16),
139
+        ],
140
+      ),
141
+    );
142
+  }
143
+}
144
+
145
+/// 班次概览卡片
146
+class _ShiftOverviewCard extends StatelessWidget {
147
+  final String date;
148
+  final String shiftName;
149
+  final String shiftTime;
150
+  final int memberCount;
151
+  final int instructionCount;
152
+
153
+  const _ShiftOverviewCard({
154
+    required this.date,
155
+    required this.shiftName,
156
+    required this.shiftTime,
157
+    required this.memberCount,
158
+    required this.instructionCount,
159
+  });
160
+
161
+  @override
162
+  Widget build(BuildContext context) {
163
+    final theme = Theme.of(context);
164
+    final primary = theme.colorScheme.primary;
165
+
166
+    return Card(
167
+      elevation: 0,
168
+      color: theme.colorScheme.primaryContainer.withAlpha(80),
169
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
170
+      child: Padding(
171
+        padding: const EdgeInsets.all(20),
172
+        child: Column(
173
+          children: [
174
+            Row(
175
+              mainAxisAlignment: MainAxisAlignment.center,
176
+              children: [
177
+                Icon(Icons.calendar_today, size: 18, color: primary),
178
+                const SizedBox(width: 8),
179
+                Text(
180
+                  date,
181
+                  style: TextStyle(
182
+                    fontSize: 16,
183
+                    fontWeight: FontWeight.w600,
184
+                    color: primary,
185
+                  ),
186
+                ),
187
+                const SizedBox(width: 12),
188
+                Container(
189
+                  padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 3),
190
+                  decoration: BoxDecoration(
191
+                    color: primary.withAlpha(30),
192
+                    borderRadius: BorderRadius.circular(12),
193
+                  ),
194
+                  child: Text(
195
+                    shiftName,
196
+                    style: TextStyle(fontSize: 12, color: primary, fontWeight: FontWeight.w600),
197
+                  ),
198
+                ),
199
+              ],
200
+            ),
201
+            const SizedBox(height: 12),
202
+            Row(
203
+              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
204
+              children: [
205
+                _InfoColumn(icon: Icons.schedule, label: '值班时间', value: shiftTime),
206
+                Container(width: 1, height: 40, color: Colors.grey.shade300),
207
+                _InfoColumn(icon: Icons.people, label: '值班人数', value: '$memberCount 人'),
208
+                Container(width: 1, height: 40, color: Colors.grey.shade300),
209
+                _InfoColumn(icon: Icons.assignment, label: '调度指令', value: '$instructionCount 条'),
210
+              ],
211
+            ),
212
+          ],
213
+        ),
214
+      ),
215
+    );
216
+  }
217
+}
218
+
219
+class _InfoColumn extends StatelessWidget {
220
+  final IconData icon;
221
+  final String label;
222
+  final String value;
223
+
224
+  const _InfoColumn({required this.icon, required this.label, required this.value});
225
+
226
+  @override
227
+  Widget build(BuildContext context) {
228
+    return Column(
229
+      children: [
230
+        Icon(icon, size: 20, color: Colors.grey.shade600),
231
+        const SizedBox(height: 4),
232
+        Text(label, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
233
+        const SizedBox(height: 2),
234
+        Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600)),
235
+      ],
236
+    );
237
+  }
238
+}
239
+
240
+/// 段落标题
241
+class _SectionHeader extends StatelessWidget {
242
+  final String title;
243
+  final IconData icon;
244
+  final Widget? trailing;
245
+
246
+  const _SectionHeader({required this.title, required this.icon, this.trailing});
247
+
248
+  @override
249
+  Widget build(BuildContext context) {
250
+    final theme = Theme.of(context);
251
+    return Row(
252
+      children: [
253
+        Icon(icon, size: 20, color: theme.colorScheme.primary),
254
+        const SizedBox(width: 8),
255
+        Text(title, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
256
+        const Spacer(),
257
+        if (trailing != null) trailing!,
258
+      ],
259
+    );
260
+  }
261
+}
262
+
263
+/// 值班人员卡片
264
+class _PersonCard extends StatelessWidget {
265
+  final DutyPerson person;
266
+  final bool isLeader;
267
+
268
+  const _PersonCard({required this.person, this.isLeader = false});
269
+
270
+  @override
271
+  Widget build(BuildContext context) {
272
+    final theme = Theme.of(context);
273
+
274
+    return Card(
275
+      elevation: 0,
276
+      shape: RoundedRectangleBorder(
277
+        borderRadius: BorderRadius.circular(12),
278
+        side: BorderSide(
279
+          color: isLeader ? theme.colorScheme.primary.withAlpha(80) : Colors.grey.shade200,
280
+        ),
281
+      ),
282
+      child: Padding(
283
+        padding: const EdgeInsets.all(14),
284
+        child: Row(
285
+          children: [
286
+            CircleAvatar(
287
+              radius: 22,
288
+              backgroundColor: isLeader
289
+                  ? theme.colorScheme.primary.withAlpha(30)
290
+                  : Colors.grey.shade100,
291
+              child: Text(
292
+                person.name.substring(0, 1),
293
+                style: TextStyle(
294
+                  fontSize: 16,
295
+                  fontWeight: FontWeight.w600,
296
+                  color: isLeader ? theme.colorScheme.primary : Colors.grey.shade700,
297
+                ),
298
+              ),
299
+            ),
300
+            const SizedBox(width: 12),
301
+            Expanded(
302
+              child: Column(
303
+                crossAxisAlignment: CrossAxisAlignment.start,
304
+                children: [
305
+                  Row(
306
+                    children: [
307
+                      Text(
308
+                        person.name,
309
+                        style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
310
+                      ),
311
+                      if (isLeader) ...[
312
+                        const SizedBox(width: 6),
313
+                        Container(
314
+                          padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
315
+                          decoration: BoxDecoration(
316
+                            color: Colors.amber.shade100,
317
+                            borderRadius: BorderRadius.circular(4),
318
+                          ),
319
+                          child: Text(
320
+                            '值班长',
321
+                            style: TextStyle(fontSize: 10, color: Colors.amber.shade800),
322
+                          ),
323
+                        ),
324
+                      ],
325
+                    ],
326
+                  ),
327
+                  const SizedBox(height: 4),
328
+                  Text(
329
+                    person.role,
330
+                    style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
331
+                  ),
332
+                ],
333
+              ),
334
+            ),
335
+            // 拨打电话按钮
336
+            Container(
337
+              decoration: BoxDecoration(
338
+                color: Colors.green.shade50,
339
+                borderRadius: BorderRadius.circular(8),
340
+              ),
341
+              child: IconButton(
342
+                icon: Icon(Icons.phone, size: 20, color: Colors.green.shade700),
343
+                onPressed: () {
344
+                  ScaffoldMessenger.of(context).showSnackBar(
345
+                    SnackBar(
346
+                      content: Text('拨打电话: ${person.phone}'),
347
+                      duration: const Duration(seconds: 2),
348
+                    ),
349
+                  );
350
+                },
351
+              ),
352
+            ),
353
+          ],
354
+        ),
355
+      ),
356
+    );
357
+  }
358
+}
359
+
360
+/// 调度指令卡片
361
+class _InstructionCard extends StatelessWidget {
362
+  final DutyInstruction instruction;
363
+
364
+  const _InstructionCard({required this.instruction});
365
+
366
+  @override
367
+  Widget build(BuildContext context) {
368
+    final theme = Theme.of(context);
369
+    final statusColor = Color(instruction.status.color);
370
+
371
+    return Card(
372
+      elevation: 0,
373
+      shape: RoundedRectangleBorder(
374
+        borderRadius: BorderRadius.circular(12),
375
+        side: BorderSide(color: Colors.grey.shade200),
376
+      ),
377
+      child: Padding(
378
+        padding: const EdgeInsets.all(14),
379
+        child: Column(
380
+          crossAxisAlignment: CrossAxisAlignment.start,
381
+          children: [
382
+            Row(
383
+              children: [
384
+                Expanded(
385
+                  child: Text(
386
+                    instruction.title,
387
+                    style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
388
+                  ),
389
+                ),
390
+                Container(
391
+                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
392
+                  decoration: BoxDecoration(
393
+                    color: statusColor.withAlpha(30),
394
+                    borderRadius: BorderRadius.circular(6),
395
+                  ),
396
+                  child: Text(
397
+                    instruction.status.label,
398
+                    style: TextStyle(fontSize: 11, color: statusColor, fontWeight: FontWeight.w500),
399
+                  ),
400
+                ),
401
+              ],
402
+            ),
403
+            const SizedBox(height: 8),
404
+            Text(
405
+              instruction.content,
406
+              style: TextStyle(fontSize: 13, color: Colors.grey.shade700, height: 1.5),
407
+            ),
408
+            const SizedBox(height: 10),
409
+            Row(
410
+              children: [
411
+                Icon(Icons.access_time, size: 12, color: Colors.grey.shade500),
412
+                const SizedBox(width: 4),
413
+                Text(
414
+                  instruction.time,
415
+                  style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
416
+                ),
417
+                const SizedBox(width: 16),
418
+                Icon(Icons.person_outline, size: 12, color: Colors.grey.shade500),
419
+                const SizedBox(width: 4),
420
+                Text(
421
+                  instruction.issuer,
422
+                  style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
423
+                ),
424
+              ],
425
+            ),
426
+          ],
427
+        ),
428
+      ),
429
+    );
430
+  }
431
+}

+ 389
- 0
mobile/lib/pages/water/monitor_page.dart Datei anzeigen

@@ -0,0 +1,389 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:intl/intl.dart';
3
+import '../../services/water_service.dart';
4
+
5
+/// 实时监测列表页面
6
+class MonitorPage extends StatefulWidget {
7
+  const MonitorPage({super.key});
8
+  @override
9
+  State<MonitorPage> createState() => _MonitorPageState();
10
+}
11
+
12
+class _MonitorPageState extends State<MonitorPage> {
13
+  final WaterService _service = WaterService.instance;
14
+  List<MonitorItem> _items = [];
15
+  bool _isLoading = true;
16
+  String? _error;
17
+  MonitorType? _filterType;
18
+
19
+  @override
20
+  void initState() {
21
+    super.initState();
22
+    _loadData();
23
+  }
24
+
25
+  Future<void> _loadData() async {
26
+    setState(() {
27
+      _isLoading = true;
28
+      _error = null;
29
+    });
30
+    try {
31
+      final data = await _service.getMonitorList();
32
+      if (mounted) {
33
+        setState(() {
34
+          _items = data;
35
+          _isLoading = false;
36
+        });
37
+      }
38
+    } catch (e) {
39
+      if (mounted) {
40
+        setState(() {
41
+          _error = e.toString();
42
+          _isLoading = false;
43
+        });
44
+      }
45
+    }
46
+  }
47
+
48
+  List<MonitorItem> get _filteredItems {
49
+    if (_filterType == null) return _items;
50
+    return _items.where((item) => item.type == _filterType).toList();
51
+  }
52
+
53
+  int _countByStatus(DeviceStatus status) {
54
+    return _items.where((item) => item.status == status).length;
55
+  }
56
+
57
+  @override
58
+  Widget build(BuildContext context) {
59
+    final theme = Theme.of(context);
60
+    return Scaffold(
61
+      appBar: AppBar(
62
+        title: const Text('实时监测'),
63
+        centerTitle: true,
64
+        actions: [
65
+          IconButton(
66
+            icon: const Icon(Icons.refresh),
67
+            onPressed: _isLoading ? null : _loadData,
68
+          ),
69
+        ],
70
+      ),
71
+      body: Column(
72
+        children: [
73
+          // 状态概览栏
74
+          if (!_isLoading && _error == null) _buildStatusBar(theme),
75
+          // 类型筛选
76
+          if (!_isLoading && _error == null) _buildFilterBar(theme),
77
+          // 列表内容
78
+          Expanded(
79
+            child: _buildBody(theme),
80
+          ),
81
+        ],
82
+      ),
83
+    );
84
+  }
85
+
86
+  Widget _buildStatusBar(ThemeData theme) {
87
+    final online = _countByStatus(DeviceStatus.online);
88
+    final offline = _countByStatus(DeviceStatus.offline);
89
+    final warning = _countByStatus(DeviceStatus.warning);
90
+
91
+    return Container(
92
+      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
93
+      color: theme.colorScheme.surfaceContainerHighest.withAlpha(100),
94
+      child: Row(
95
+        children: [
96
+          _StatusBadge(label: '在线', count: online, color: Colors.green),
97
+          const SizedBox(width: 16),
98
+          _StatusBadge(label: '离线', count: offline, color: Colors.grey),
99
+          const SizedBox(width: 16),
100
+          _StatusBadge(label: '告警', count: warning, color: Colors.orange),
101
+          const Spacer(),
102
+          Text(
103
+            '共 ${_items.length} 个监测点',
104
+            style: theme.textTheme.bodySmall,
105
+          ),
106
+        ],
107
+      ),
108
+    );
109
+  }
110
+
111
+  Widget _buildFilterBar(ThemeData theme) {
112
+    return SingleChildScrollView(
113
+      scrollDirection: Axis.horizontal,
114
+      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
115
+      child: Row(
116
+        children: [
117
+          _FilterChip(
118
+            label: '全部',
119
+            selected: _filterType == null,
120
+            onTap: () => setState(() => _filterType = null),
121
+          ),
122
+          const SizedBox(width: 8),
123
+          ...MonitorType.values.map((type) {
124
+            return Padding(
125
+              padding: const EdgeInsets.only(right: 8),
126
+              child: _FilterChip(
127
+                label: type.label,
128
+                selected: _filterType == type,
129
+                onTap: () => setState(() => _filterType = type),
130
+              ),
131
+            );
132
+          }),
133
+        ],
134
+      ),
135
+    );
136
+  }
137
+
138
+  Widget _buildBody(ThemeData theme) {
139
+    if (_isLoading) {
140
+      return const Center(child: CircularProgressIndicator());
141
+    }
142
+    if (_error != null) {
143
+      return Center(
144
+        child: Column(
145
+          mainAxisSize: MainAxisSize.min,
146
+          children: [
147
+            Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
148
+            const SizedBox(height: 16),
149
+            Text('加载失败', style: theme.textTheme.titleMedium),
150
+            const SizedBox(height: 8),
151
+            Text(_error!, style: theme.textTheme.bodySmall),
152
+            const SizedBox(height: 16),
153
+            FilledButton.icon(
154
+              onPressed: _loadData,
155
+              icon: const Icon(Icons.refresh),
156
+              label: const Text('重试'),
157
+            ),
158
+          ],
159
+        ),
160
+      );
161
+    }
162
+
163
+    final items = _filteredItems;
164
+    if (items.isEmpty) {
165
+      return Center(
166
+        child: Column(
167
+          mainAxisSize: MainAxisSize.min,
168
+          children: [
169
+            Icon(Icons.sensors_off, size: 48, color: Colors.grey.shade400),
170
+            const SizedBox(height: 16),
171
+            Text('暂无监测数据', style: theme.textTheme.titleMedium),
172
+          ],
173
+        ),
174
+      );
175
+    }
176
+
177
+    return RefreshIndicator(
178
+      onRefresh: _loadData,
179
+      child: ListView.builder(
180
+        padding: const EdgeInsets.all(12),
181
+        itemCount: items.length,
182
+        itemBuilder: (context, index) => _MonitorCard(item: items[index]),
183
+      ),
184
+    );
185
+  }
186
+}
187
+
188
+/// 监测点卡片
189
+class _MonitorCard extends StatelessWidget {
190
+  final MonitorItem item;
191
+  const _MonitorCard({required this.item});
192
+
193
+  @override
194
+  Widget build(BuildContext context) {
195
+    final theme = Theme.of(context);
196
+    final statusColor = Color(item.status.color);
197
+    final timeFormat = DateFormat('HH:mm:ss');
198
+
199
+    return Card(
200
+      margin: const EdgeInsets.only(bottom: 10),
201
+      elevation: 0,
202
+      shape: RoundedRectangleBorder(
203
+        borderRadius: BorderRadius.circular(12),
204
+        side: BorderSide(color: theme.colorScheme.outlineVariant.withAlpha(100)),
205
+      ),
206
+      child: Padding(
207
+        padding: const EdgeInsets.all(16),
208
+        child: Column(
209
+          crossAxisAlignment: CrossAxisAlignment.start,
210
+          children: [
211
+            // 标题行
212
+            Row(
213
+              children: [
214
+                Container(
215
+                  width: 8,
216
+                  height: 8,
217
+                  decoration: BoxDecoration(
218
+                    shape: BoxShape.circle,
219
+                    color: statusColor,
220
+                  ),
221
+                ),
222
+                const SizedBox(width: 8),
223
+                Expanded(
224
+                  child: Text(
225
+                    item.name,
226
+                    style: theme.textTheme.titleMedium?.copyWith(
227
+                      fontWeight: FontWeight.w600,
228
+                    ),
229
+                  ),
230
+                ),
231
+                Container(
232
+                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
233
+                  decoration: BoxDecoration(
234
+                    color: statusColor.withAlpha(30),
235
+                    borderRadius: BorderRadius.circular(4),
236
+                  ),
237
+                  child: Text(
238
+                    item.type.label,
239
+                    style: TextStyle(fontSize: 11, color: statusColor),
240
+                  ),
241
+                ),
242
+                const SizedBox(width: 8),
243
+                Container(
244
+                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
245
+                  decoration: BoxDecoration(
246
+                    color: statusColor.withAlpha(30),
247
+                    borderRadius: BorderRadius.circular(4),
248
+                  ),
249
+                  child: Text(
250
+                    item.statusLabel,
251
+                    style: TextStyle(fontSize: 11, color: statusColor),
252
+                  ),
253
+                ),
254
+              ],
255
+            ),
256
+            const SizedBox(height: 12),
257
+            // 数据行
258
+            if (item.status != DeviceStatus.offline) ...[
259
+              Wrap(
260
+                spacing: 16,
261
+                runSpacing: 8,
262
+                children: [
263
+                  if (item.flow != null)
264
+                    _DataChip(icon: Icons.water_drop, label: '流量', value: '${item.flow!.toStringAsFixed(1)} m³/h'),
265
+                  if (item.pressure != null)
266
+                    _DataChip(icon: Icons.speed, label: '压力', value: '${item.pressure!.toStringAsFixed(2)} MPa'),
267
+                  if (item.level != null)
268
+                    _DataChip(icon: Icons.straighten, label: '液位', value: '${item.level!.toStringAsFixed(1)} m'),
269
+                  if (item.ph != null)
270
+                    _DataChip(icon: Icons.science, label: 'pH', value: item.ph!.toStringAsFixed(1)),
271
+                  if (item.turbidity != null)
272
+                    _DataChip(icon: Icons.blur_on, label: '浊度', value: '${item.turbidity!.toStringAsFixed(1)} NTU'),
273
+                  if (item.chlorine != null)
274
+                    _DataChip(icon: Icons.bubble_chart, label: '余氯', value: '${item.chlorine!.toStringAsFixed(2)} mg/L'),
275
+                ],
276
+              ),
277
+              const SizedBox(height: 8),
278
+            ] else
279
+              Padding(
280
+                padding: const EdgeInsets.symmetric(vertical: 8),
281
+                child: Text(
282
+                  '设备离线,暂无数据',
283
+                  style: TextStyle(color: Colors.grey.shade500, fontSize: 13),
284
+                ),
285
+              ),
286
+            // 更新时间
287
+            Row(
288
+              mainAxisAlignment: MainAxisAlignment.end,
289
+              children: [
290
+                Icon(Icons.access_time, size: 12, color: Colors.grey.shade500),
291
+                const SizedBox(width: 4),
292
+                Text(
293
+                  '更新于 ${timeFormat.format(item.updateTime)}',
294
+                  style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
295
+                ),
296
+              ],
297
+            ),
298
+          ],
299
+        ),
300
+      ),
301
+    );
302
+  }
303
+}
304
+
305
+class _DataChip extends StatelessWidget {
306
+  final IconData icon;
307
+  final String label;
308
+  final String value;
309
+
310
+  const _DataChip({required this.icon, required this.label, required this.value});
311
+
312
+  @override
313
+  Widget build(BuildContext context) {
314
+    return Row(
315
+      mainAxisSize: MainAxisSize.min,
316
+      children: [
317
+        Icon(icon, size: 14, color: Colors.grey.shade600),
318
+        const SizedBox(width: 4),
319
+        Text(
320
+          '$label: ',
321
+          style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
322
+        ),
323
+        Text(
324
+          value,
325
+          style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600),
326
+        ),
327
+      ],
328
+    );
329
+  }
330
+}
331
+
332
+class _StatusBadge extends StatelessWidget {
333
+  final String label;
334
+  final int count;
335
+  final Color color;
336
+
337
+  const _StatusBadge({required this.label, required this.count, required this.color});
338
+
339
+  @override
340
+  Widget build(BuildContext context) {
341
+    return Row(
342
+      mainAxisSize: MainAxisSize.min,
343
+      children: [
344
+        Container(
345
+          width: 8,
346
+          height: 8,
347
+          decoration: BoxDecoration(shape: BoxShape.circle, color: color),
348
+        ),
349
+        const SizedBox(width: 4),
350
+        Text('$label $count', style: const TextStyle(fontSize: 13)),
351
+      ],
352
+    );
353
+  }
354
+}
355
+
356
+class _FilterChip extends StatelessWidget {
357
+  final String label;
358
+  final bool selected;
359
+  final VoidCallback onTap;
360
+
361
+  const _FilterChip({required this.label, required this.selected, required this.onTap});
362
+
363
+  @override
364
+  Widget build(BuildContext context) {
365
+    final theme = Theme.of(context);
366
+    return InkWell(
367
+      onTap: onTap,
368
+      borderRadius: BorderRadius.circular(16),
369
+      child: Container(
370
+        padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
371
+        decoration: BoxDecoration(
372
+          color: selected ? theme.colorScheme.primaryContainer : Colors.grey.shade100,
373
+          borderRadius: BorderRadius.circular(16),
374
+          border: selected
375
+              ? Border.all(color: theme.colorScheme.primary, width: 1)
376
+              : null,
377
+        ),
378
+        child: Text(
379
+          label,
380
+          style: TextStyle(
381
+            fontSize: 13,
382
+            color: selected ? theme.colorScheme.primary : Colors.grey.shade700,
383
+            fontWeight: selected ? FontWeight.w600 : FontWeight.normal,
384
+          ),
385
+        ),
386
+      ),
387
+    );
388
+  }
389
+}

+ 330
- 0
mobile/lib/pages/water/quality_page.dart Datei anzeigen

@@ -0,0 +1,330 @@
1
+import 'package:flutter/material.dart';
2
+import 'package:intl/intl.dart';
3
+import '../../services/water_service.dart';
4
+
5
+/// 水质查看页面
6
+class QualityPage extends StatefulWidget {
7
+  const QualityPage({super.key});
8
+  @override
9
+  State<QualityPage> createState() => _QualityPageState();
10
+}
11
+
12
+class _QualityPageState extends State<QualityPage> with SingleTickerProviderStateMixin {
13
+  final WaterService _service = WaterService.instance;
14
+  List<QualitySample> _samples = [];
15
+  bool _isLoading = true;
16
+  String? _error;
17
+  late TabController _tabController;
18
+
19
+  static const _categories = [
20
+    QualityCategory.rawWater,
21
+    QualityCategory.factoryWater,
22
+    QualityCategory.endWater,
23
+  ];
24
+
25
+  @override
26
+  void initState() {
27
+    super.initState();
28
+    _tabController = TabController(length: _categories.length, vsync: this);
29
+    _tabController.addListener(() {
30
+      if (!_tabController.indexIsChanging) {
31
+        setState(() {}); // trigger rebuild for filtered data
32
+      }
33
+    });
34
+    _loadData();
35
+  }
36
+
37
+  @override
38
+  void dispose() {
39
+    _tabController.dispose();
40
+    super.dispose();
41
+  }
42
+
43
+  Future<void> _loadData() async {
44
+    setState(() {
45
+      _isLoading = true;
46
+      _error = null;
47
+    });
48
+    try {
49
+      final data = await _service.getQualityData();
50
+      if (mounted) {
51
+        setState(() {
52
+          _samples = data;
53
+          _isLoading = false;
54
+        });
55
+      }
56
+    } catch (e) {
57
+      if (mounted) {
58
+        setState(() {
59
+          _error = e.toString();
60
+          _isLoading = false;
61
+        });
62
+      }
63
+    }
64
+  }
65
+
66
+  List<QualitySample> get _currentSamples {
67
+    final category = _categories[_tabController.index];
68
+    return _samples.where((s) => s.category == category).toList();
69
+  }
70
+
71
+  @override
72
+  Widget build(BuildContext context) {
73
+    final theme = Theme.of(context);
74
+    return Scaffold(
75
+      appBar: AppBar(
76
+        title: const Text('水质查看'),
77
+        centerTitle: true,
78
+        actions: [
79
+          IconButton(
80
+            icon: const Icon(Icons.refresh),
81
+            onPressed: _isLoading ? null : _loadData,
82
+          ),
83
+        ],
84
+        bottom: TabBar(
85
+          controller: _tabController,
86
+          tabs: _categories.map((c) => Tab(text: c.label)).toList(),
87
+        ),
88
+      ),
89
+      body: _buildBody(theme),
90
+    );
91
+  }
92
+
93
+  Widget _buildBody(ThemeData theme) {
94
+    if (_isLoading) {
95
+      return const Center(child: CircularProgressIndicator());
96
+    }
97
+    if (_error != null) {
98
+      return Center(
99
+        child: Column(
100
+          mainAxisSize: MainAxisSize.min,
101
+          children: [
102
+            Icon(Icons.error_outline, size: 48, color: theme.colorScheme.error),
103
+            const SizedBox(height: 16),
104
+            Text('加载失败', style: theme.textTheme.titleMedium),
105
+            const SizedBox(height: 16),
106
+            FilledButton.icon(
107
+              onPressed: _loadData,
108
+              icon: const Icon(Icons.refresh),
109
+              label: const Text('重试'),
110
+            ),
111
+          ],
112
+        ),
113
+      );
114
+    }
115
+
116
+    final samples = _currentSamples;
117
+    if (samples.isEmpty) {
118
+      return Center(
119
+        child: Column(
120
+          mainAxisSize: MainAxisSize.min,
121
+          children: [
122
+            Icon(Icons.science_outlined, size: 48, color: Colors.grey.shade400),
123
+            const SizedBox(height: 16),
124
+            Text('暂无水质数据', style: theme.textTheme.titleMedium),
125
+          ],
126
+        ),
127
+      );
128
+    }
129
+
130
+    return RefreshIndicator(
131
+      onRefresh: _loadData,
132
+      child: ListView.builder(
133
+        padding: const EdgeInsets.all(12),
134
+        itemCount: samples.length,
135
+        itemBuilder: (context, index) => _QualityCard(sample: samples[index]),
136
+      ),
137
+    );
138
+  }
139
+}
140
+
141
+/// 水质数据卡片
142
+class _QualityCard extends StatelessWidget {
143
+  final QualitySample sample;
144
+
145
+  const _QualityCard({required this.sample});
146
+
147
+  @override
148
+  Widget build(BuildContext context) {
149
+    final theme = Theme.of(context);
150
+    final dateFormat = DateFormat('MM-dd HH:mm');
151
+    final indicators = sample.getIndicators();
152
+    final allCompliant = sample.isCompliant;
153
+    final statusColor = allCompliant ? Colors.green : Colors.red;
154
+
155
+    return Card(
156
+      margin: const EdgeInsets.only(bottom: 12),
157
+      elevation: 0,
158
+      shape: RoundedRectangleBorder(
159
+        borderRadius: BorderRadius.circular(12),
160
+        side: BorderSide(
161
+          color: allCompliant ? Colors.grey.shade200 : Colors.red.withAlpha(100),
162
+        ),
163
+      ),
164
+      child: Padding(
165
+        padding: const EdgeInsets.all(16),
166
+        child: Column(
167
+          crossAxisAlignment: CrossAxisAlignment.start,
168
+          children: [
169
+            // 标题行
170
+            Row(
171
+              children: [
172
+                Icon(
173
+                  allCompliant ? Icons.check_circle : Icons.error,
174
+                  size: 20,
175
+                  color: statusColor,
176
+                ),
177
+                const SizedBox(width: 8),
178
+                Expanded(
179
+                  child: Text(
180
+                    sample.source,
181
+                    style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600),
182
+                  ),
183
+                ),
184
+                Container(
185
+                  padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
186
+                  decoration: BoxDecoration(
187
+                    color: statusColor.withAlpha(25),
188
+                    borderRadius: BorderRadius.circular(6),
189
+                  ),
190
+                  child: Text(
191
+                    allCompliant ? '达标' : '不达标',
192
+                    style: TextStyle(
193
+                      fontSize: 12,
194
+                      color: statusColor,
195
+                      fontWeight: FontWeight.w600,
196
+                    ),
197
+                  ),
198
+                ),
199
+              ],
200
+            ),
201
+            const SizedBox(height: 6),
202
+            // 采样时间
203
+            Row(
204
+              children: [
205
+                Icon(Icons.access_time, size: 12, color: Colors.grey.shade500),
206
+                const SizedBox(width: 4),
207
+                Text(
208
+                  '采样时间: ${dateFormat.format(sample.sampleTime)}',
209
+                  style: TextStyle(fontSize: 11, color: Colors.grey.shade500),
210
+                ),
211
+              ],
212
+            ),
213
+            const SizedBox(height: 14),
214
+            // 指标表格
215
+            Container(
216
+              decoration: BoxDecoration(
217
+                color: Colors.grey.shade50,
218
+                borderRadius: BorderRadius.circular(8),
219
+              ),
220
+              child: Column(
221
+                children: [
222
+                  // 表头
223
+                  Container(
224
+                    padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
225
+                    decoration: BoxDecoration(
226
+                      color: Colors.grey.shade100,
227
+                      borderRadius: const BorderRadius.vertical(top: Radius.circular(8)),
228
+                    ),
229
+                    child: Row(
230
+                      children: [
231
+                        _TableHeaderCell(text: '检测指标', flex: 2),
232
+                        _TableHeaderCell(text: '检测值', flex: 2),
233
+                        _TableHeaderCell(text: '标准值', flex: 2),
234
+                        _TableHeaderCell(text: '结果', flex: 1),
235
+                      ],
236
+                    ),
237
+                  ),
238
+                  // 数据行
239
+                  ...indicators.map((indicator) => Container(
240
+                        padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
241
+                        decoration: BoxDecoration(
242
+                          border: Border(
243
+                            bottom: BorderSide(color: Colors.grey.shade200, width: 0.5),
244
+                          ),
245
+                        ),
246
+                        child: Row(
247
+                          children: [
248
+                            Expanded(
249
+                              flex: 2,
250
+                              child: Text(
251
+                                indicator.name,
252
+                                style: const TextStyle(fontSize: 12),
253
+                              ),
254
+                            ),
255
+                            Expanded(
256
+                              flex: 2,
257
+                              child: Text(
258
+                                indicator.value,
259
+                                style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
260
+                              ),
261
+                            ),
262
+                            Expanded(
263
+                              flex: 2,
264
+                              child: Text(
265
+                                indicator.standard,
266
+                                style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
267
+                              ),
268
+                            ),
269
+                            Expanded(
270
+                              flex: 1,
271
+                              child: Icon(
272
+                                indicator.isCompliant ? Icons.check : Icons.close,
273
+                                size: 16,
274
+                                color: indicator.isCompliant ? Colors.green : Colors.red,
275
+                              ),
276
+                            ),
277
+                          ],
278
+                        ),
279
+                      )),
280
+                ],
281
+              ),
282
+            ),
283
+            // 不达标提示
284
+            if (!allCompliant) ...[
285
+              const SizedBox(height: 12),
286
+              Container(
287
+                padding: const EdgeInsets.all(10),
288
+                decoration: BoxDecoration(
289
+                  color: Colors.red.shade50,
290
+                  borderRadius: BorderRadius.circular(8),
291
+                  border: Border.all(color: Colors.red.shade200),
292
+                ),
293
+                child: Row(
294
+                  children: [
295
+                    Icon(Icons.warning, size: 16, color: Colors.red.shade700),
296
+                    const SizedBox(width: 8),
297
+                    Expanded(
298
+                      child: Text(
299
+                        '部分指标超标,请关注并及时处理',
300
+                        style: TextStyle(fontSize: 12, color: Colors.red.shade700),
301
+                      ),
302
+                    ),
303
+                  ],
304
+                ),
305
+              ),
306
+            ],
307
+          ],
308
+        ),
309
+      ),
310
+    );
311
+  }
312
+}
313
+
314
+class _TableHeaderCell extends StatelessWidget {
315
+  final String text;
316
+  final int flex;
317
+
318
+  const _TableHeaderCell({required this.text, required this.flex});
319
+
320
+  @override
321
+  Widget build(BuildContext context) {
322
+    return Expanded(
323
+      flex: flex,
324
+      child: Text(
325
+        text,
326
+        style: TextStyle(fontSize: 11, color: Colors.grey.shade700, fontWeight: FontWeight.w600),
327
+      ),
328
+    );
329
+  }
330
+}

+ 571
- 0
mobile/lib/services/water_service.dart Datei anzeigen

@@ -0,0 +1,571 @@
1
+/// 供水管理相关 API 调用服务
2
+/// 当前使用 mock 数据,后端 API 就绪后替换为真实请求
3
+class WaterService {
4
+  WaterService._internal();
5
+  static final WaterService instance = WaterService._internal();
6
+  factory WaterService() => instance;
7
+
8
+  // ==================== Mock 数据 ====================
9
+
10
+  /// 实时监测数据列表
11
+  Future<List<MonitorItem>> getMonitorList() async {
12
+    await Future.delayed(const Duration(milliseconds: 800));
13
+    return [
14
+      MonitorItem(
15
+        id: 'M001',
16
+        name: '1号泵站',
17
+        type: MonitorType.pumpStation,
18
+        flow: 120.5,
19
+        pressure: 0.35,
20
+        level: null,
21
+        ph: 7.2,
22
+        turbidity: 0.8,
23
+        chlorine: 0.5,
24
+        status: DeviceStatus.online,
25
+        updateTime: DateTime.now().subtract(const Duration(minutes: 2)),
26
+      ),
27
+      MonitorItem(
28
+        id: 'M002',
29
+        name: '2号泵站',
30
+        type: MonitorType.pumpStation,
31
+        flow: 98.3,
32
+        pressure: 0.32,
33
+        level: null,
34
+        ph: 7.1,
35
+        turbidity: 0.6,
36
+        chlorine: 0.45,
37
+        status: DeviceStatus.online,
38
+        updateTime: DateTime.now().subtract(const Duration(minutes: 1)),
39
+      ),
40
+      MonitorItem(
41
+        id: 'M003',
42
+        name: '3号泵站',
43
+        type: MonitorType.pumpStation,
44
+        flow: 0,
45
+        pressure: 0,
46
+        level: null,
47
+        ph: 0,
48
+        turbidity: 0,
49
+        chlorine: 0,
50
+        status: DeviceStatus.offline,
51
+        updateTime: DateTime.now().subtract(const Duration(hours: 2)),
52
+      ),
53
+      MonitorItem(
54
+        id: 'M004',
55
+        name: '清水池',
56
+        type: MonitorType.reservoir,
57
+        flow: null,
58
+        pressure: null,
59
+        level: 4.2,
60
+        ph: 7.3,
61
+        turbidity: 0.3,
62
+        chlorine: 0.4,
63
+        status: DeviceStatus.online,
64
+        updateTime: DateTime.now().subtract(const Duration(minutes: 5)),
65
+      ),
66
+      MonitorItem(
67
+        id: 'M005',
68
+        name: 'DN300主干管',
69
+        type: MonitorType.pipeline,
70
+        flow: 285.6,
71
+        pressure: 0.28,
72
+        level: null,
73
+        ph: null,
74
+        turbidity: null,
75
+        chlorine: null,
76
+        status: DeviceStatus.online,
77
+        updateTime: DateTime.now().subtract(const Duration(minutes: 3)),
78
+      ),
79
+      MonitorItem(
80
+        id: 'M006',
81
+        name: 'DN200分支管-A区',
82
+        type: MonitorType.pipeline,
83
+        flow: 86.2,
84
+        pressure: 0.22,
85
+        level: null,
86
+        ph: null,
87
+        turbidity: null,
88
+        chlorine: null,
89
+        status: DeviceStatus.warning,
90
+        updateTime: DateTime.now().subtract(const Duration(minutes: 1)),
91
+      ),
92
+      MonitorItem(
93
+        id: 'M007',
94
+        name: '末梢水质监测点-1',
95
+        type: MonitorType.quality,
96
+        flow: null,
97
+        pressure: 0.15,
98
+        level: null,
99
+        ph: 7.0,
100
+        turbidity: 0.5,
101
+        chlorine: 0.3,
102
+        status: DeviceStatus.online,
103
+        updateTime: DateTime.now().subtract(const Duration(minutes: 10)),
104
+      ),
105
+      MonitorItem(
106
+        id: 'M008',
107
+        name: '加药间',
108
+        type: MonitorType.dosing,
109
+        flow: null,
110
+        pressure: null,
111
+        level: 2.8,
112
+        ph: null,
113
+        turbidity: null,
114
+        chlorine: null,
115
+        status: DeviceStatus.online,
116
+        updateTime: DateTime.now().subtract(const Duration(minutes: 8)),
117
+      ),
118
+    ];
119
+  }
120
+
121
+  /// 报警列表
122
+  Future<List<AlertItem>> getAlertList({bool unreadOnly = false}) async {
123
+    await Future.delayed(const Duration(milliseconds: 600));
124
+    final alerts = [
125
+      AlertItem(
126
+        id: 'A001',
127
+        level: AlertLevel.critical,
128
+        title: '3号泵站设备离线',
129
+        content: '3号泵站PLC控制器通信中断,已持续2小时,请尽快安排人员现场检查。',
130
+        location: '3号泵站',
131
+        deviceName: '3号泵站PLC',
132
+        isRead: false,
133
+        createTime: DateTime.now().subtract(const Duration(hours: 2)),
134
+        handlerName: '待处理',
135
+      ),
136
+      AlertItem(
137
+        id: 'A002',
138
+        level: AlertLevel.warning,
139
+        title: 'DN200分支管压力偏低',
140
+        content: 'A区DN200分支管压力0.22MPa,低于下限0.25MPa,可能存在泄漏或用水量激增。',
141
+        location: 'A区DN200分支管',
142
+        deviceName: '压力传感器-P205',
143
+        isRead: false,
144
+        createTime: DateTime.now().subtract(const Duration(hours: 1)),
145
+        handlerName: '待处理',
146
+      ),
147
+      AlertItem(
148
+        id: 'A003',
149
+        level: AlertLevel.warning,
150
+        title: '清水池液位接近上限',
151
+        content: '清水池液位4.2m,接近上限4.5m,建议减少进水量或增加供水。',
152
+        location: '清水池',
153
+        deviceName: '液位传感器-L001',
154
+        isRead: false,
155
+        createTime: DateTime.now().subtract(const Duration(minutes: 30)),
156
+        handlerName: '待处理',
157
+      ),
158
+      AlertItem(
159
+        id: 'A004',
160
+        level: AlertLevel.info,
161
+        title: '1号泵站例行维护提醒',
162
+        content: '1号泵站运行时间已达5000小时,建议安排例行保养。',
163
+        location: '1号泵站',
164
+        deviceName: '1号泵站主控',
165
+        isRead: true,
166
+        createTime: DateTime.now().subtract(const Duration(hours: 5)),
167
+        handlerName: '王工',
168
+      ),
169
+      AlertItem(
170
+        id: 'A005',
171
+        level: AlertLevel.critical,
172
+        title: '出厂水余氯超标',
173
+        content: '出厂水余氯0.8mg/L,超过国标上限0.8mg/L,请立即调整加药量。',
174
+        location: '水厂出水口',
175
+        deviceName: '水质分析仪-WQ001',
176
+        isRead: true,
177
+        createTime: DateTime.now().subtract(const Duration(hours: 8)),
178
+        handlerName: '李工',
179
+      ),
180
+      AlertItem(
181
+        id: 'A006',
182
+        level: AlertLevel.info,
183
+        title: '2号泵站变频器固件更新',
184
+        content: '2号泵站变频器有新固件版本可用(v2.3.1),建议在下次停机时更新。',
185
+        location: '2号泵站',
186
+        deviceName: '2号变频器',
187
+        isRead: true,
188
+        createTime: DateTime.now().subtract(const Duration(days: 1)),
189
+        handlerName: '系统',
190
+      ),
191
+    ];
192
+    if (unreadOnly) {
193
+      return alerts.where((a) => !a.isRead).toList();
194
+    }
195
+    return alerts;
196
+  }
197
+
198
+  /// 标记报警为已读
199
+  Future<void> markAlertRead(String alertId) async {
200
+    await Future.delayed(const Duration(milliseconds: 300));
201
+  }
202
+
203
+  /// 标记所有报警为已读
204
+  Future<void> markAllAlertsRead() async {
205
+    await Future.delayed(const Duration(milliseconds: 500));
206
+  }
207
+
208
+  /// 今日值班信息
209
+  Future<DutyInfo> getTodayDuty() async {
210
+    await Future.delayed(const Duration(milliseconds: 700));
211
+    return DutyInfo(
212
+      date: DateTime.now(),
213
+      shiftName: '白班',
214
+      shiftTime: '08:00 - 20:00',
215
+      leader: DutyPerson(name: '张建国', phone: '13800138001', role: '值班长'),
216
+      members: [
217
+        DutyPerson(name: '李明', phone: '13800138002', role: '泵站巡检'),
218
+        DutyPerson(name: '王芳', phone: '13800138003', role: '水质监测'),
219
+        DutyPerson(name: '赵强', phone: '13800138004', role: '管网维护'),
220
+        DutyPerson(name: '刘伟', phone: '13800138005', role: '应急抢修'),
221
+      ],
222
+      instructions: [
223
+        DutyInstruction(
224
+          id: 'DI001',
225
+          title: '清水池液位监控',
226
+          content: '密切关注清水池液位,若超过4.5m立即报告并减少进水量。',
227
+          time: '08:30',
228
+          issuer: '调度中心-陈主任',
229
+          status: InstructionStatus.active,
230
+        ),
231
+        DutyInstruction(
232
+          id: 'DI002',
233
+          title: '3号泵站故障排查',
234
+          content: '3号泵站PLC离线,安排刘伟前往现场检查,预计14:00前恢复。',
235
+          time: '09:15',
236
+          issuer: '调度中心-陈主任',
237
+          status: InstructionStatus.inProgress,
238
+        ),
239
+        DutyInstruction(
240
+          id: 'DI003',
241
+          title: 'A区压力监测',
242
+          content: 'A区DN200分支管压力偏低,赵强每30分钟上报一次压力数据。',
243
+          time: '10:00',
244
+          issuer: '调度中心-陈主任',
245
+          status: InstructionStatus.active,
246
+        ),
247
+        DutyInstruction(
248
+          id: 'DI004',
249
+          title: '出厂水余氯检测',
250
+          content: '每小时对出厂水进行一次余氯检测,确保在0.3-0.8mg/L范围内。',
251
+          time: '08:00',
252
+          issuer: '水厂-周厂长',
253
+          status: InstructionStatus.completed,
254
+        ),
255
+      ],
256
+    );
257
+  }
258
+
259
+  /// 水质数据
260
+  Future<List<QualitySample>> getQualityData() async {
261
+    await Future.delayed(const Duration(milliseconds: 900));
262
+    return [
263
+      QualitySample(
264
+        id: 'Q001',
265
+        category: QualityCategory.rawWater,
266
+        source: '水源地取水口',
267
+        sampleTime: DateTime.now().subtract(const Duration(hours: 1)),
268
+        ph: 7.5,
269
+        turbidity: 12.3,
270
+        color: 15,
271
+        odor: '无异味',
272
+        ammonia: 0.3,
273
+        permanganate: 3.2,
274
+        totalColiform: 280,
275
+        isCompliant: true,
276
+      ),
277
+      QualitySample(
278
+        id: 'Q002',
279
+        category: QualityCategory.factoryWater,
280
+        source: '水厂出水口',
281
+        sampleTime: DateTime.now().subtract(const Duration(hours: 1)),
282
+        ph: 7.2,
283
+        turbidity: 0.3,
284
+        color: 2,
285
+        odor: '无异味',
286
+        ammonia: 0.05,
287
+        permanganate: 1.8,
288
+        totalColiform: 0,
289
+        isCompliant: true,
290
+      ),
291
+      QualitySample(
292
+        id: 'Q003',
293
+        category: QualityCategory.factoryWater,
294
+        source: '清水池出水',
295
+        sampleTime: DateTime.now().subtract(const Duration(hours: 2)),
296
+        ph: 7.1,
297
+        turbidity: 0.4,
298
+        color: 3,
299
+        odor: '无异味',
300
+        ammonia: 0.08,
301
+        permanganate: 2.0,
302
+        totalColiform: 0,
303
+        isCompliant: true,
304
+      ),
305
+      QualitySample(
306
+        id: 'Q004',
307
+        category: QualityCategory.endWater,
308
+        source: 'A区末梢-学校',
309
+        sampleTime: DateTime.now().subtract(const Duration(hours: 3)),
310
+        ph: 7.0,
311
+        turbidity: 0.6,
312
+        color: 4,
313
+        odor: '无异味',
314
+        ammonia: 0.1,
315
+        permanganate: 2.5,
316
+        totalColiform: 0,
317
+        isCompliant: true,
318
+      ),
319
+      QualitySample(
320
+        id: 'Q005',
321
+        category: QualityCategory.endWater,
322
+        source: 'B区末梢-医院',
323
+        sampleTime: DateTime.now().subtract(const Duration(hours: 3)),
324
+        ph: 6.8,
325
+        turbidity: 1.2,
326
+        color: 6,
327
+        odor: '轻微氯味',
328
+        ammonia: 0.15,
329
+        permanganate: 3.0,
330
+        totalColiform: 2,
331
+        isCompliant: false,
332
+      ),
333
+      QualitySample(
334
+        id: 'Q006',
335
+        category: QualityCategory.endWater,
336
+        source: 'C区末梢-居民区',
337
+        sampleTime: DateTime.now().subtract(const Duration(hours: 4)),
338
+        ph: 7.1,
339
+        turbidity: 0.5,
340
+        color: 3,
341
+        odor: '无异味',
342
+        ammonia: 0.08,
343
+        permanganate: 2.2,
344
+        totalColiform: 0,
345
+        isCompliant: true,
346
+      ),
347
+    ];
348
+  }
349
+}
350
+
351
+// ==================== 数据模型 ====================
352
+
353
+/// 监测类型
354
+enum MonitorType {
355
+  pumpStation('泵站'),
356
+  reservoir('水池/水箱'),
357
+  pipeline('管网'),
358
+  quality('水质监测点'),
359
+  dosing('加药间');
360
+
361
+  final String label;
362
+  const MonitorType(this.label);
363
+}
364
+
365
+/// 设备状态
366
+enum DeviceStatus {
367
+  online('在线', Color(0xFF4CAF50)),
368
+  offline('离线', Color(0xFF9E9E9E)),
369
+  warning('告警', Color(0xFFFF9800)),
370
+  error('故障', Color(0xFFF44336));
371
+
372
+  final String label;
373
+  final int color;
374
+  const DeviceStatus(this.label, this.color);
375
+}
376
+
377
+/// 监测数据项
378
+class MonitorItem {
379
+  final String id;
380
+  final String name;
381
+  final MonitorType type;
382
+  final double? flow; // 流量 m³/h
383
+  final double? pressure; // 压力 MPa
384
+  final double? level; // 液位 m
385
+  final double? ph;
386
+  final double? turbidity; // 浊度 NTU
387
+  final double? chlorine; // 余氯 mg/L
388
+  final DeviceStatus status;
389
+  final DateTime updateTime;
390
+
391
+  const MonitorItem({
392
+    required this.id,
393
+    required this.name,
394
+    required this.type,
395
+    this.flow,
396
+    this.pressure,
397
+    this.level,
398
+    this.ph,
399
+    this.turbidity,
400
+    this.chlorine,
401
+    required this.status,
402
+    required this.updateTime,
403
+  });
404
+
405
+  String get statusLabel => status.label;
406
+}
407
+
408
+/// 报警级别
409
+enum AlertLevel {
410
+  critical('严重', Color(0xFFF44336)),
411
+  warning('警告', Color(0xFFFF9800)),
412
+  info('提示', Color(0xFF2196F3));
413
+
414
+  final String label;
415
+  final int color;
416
+  const AlertLevel(this.label, this.color);
417
+}
418
+
419
+/// 报警项
420
+class AlertItem {
421
+  final String id;
422
+  final AlertLevel level;
423
+  final String title;
424
+  final String content;
425
+  final String location;
426
+  final String deviceName;
427
+  bool isRead;
428
+  final DateTime createTime;
429
+  final String handlerName;
430
+
431
+  AlertItem({
432
+    required this.id,
433
+    required this.level,
434
+    required this.title,
435
+    required this.content,
436
+    required this.location,
437
+    required this.deviceName,
438
+    required this.isRead,
439
+    required this.createTime,
440
+    required this.handlerName,
441
+  });
442
+}
443
+
444
+/// 值班人员
445
+class DutyPerson {
446
+  final String name;
447
+  final String phone;
448
+  final String role;
449
+
450
+  const DutyPerson({
451
+    required this.name,
452
+    required this.phone,
453
+    required this.role,
454
+  });
455
+}
456
+
457
+/// 指令状态
458
+enum InstructionStatus {
459
+  active('待执行', Color(0xFFFF9800)),
460
+  inProgress('执行中', Color(0xFF2196F3)),
461
+  completed('已完成', Color(0xFF4CAF50));
462
+
463
+  final String label;
464
+  final int color;
465
+  const InstructionStatus(this.label, this.color);
466
+}
467
+
468
+/// 调度指令
469
+class DutyInstruction {
470
+  final String id;
471
+  final String title;
472
+  final String content;
473
+  final String time;
474
+  final String issuer;
475
+  final InstructionStatus status;
476
+
477
+  const DutyInstruction({
478
+    required this.id,
479
+    required this.title,
480
+    required this.content,
481
+    required this.time,
482
+    required this.issuer,
483
+    required this.status,
484
+  });
485
+}
486
+
487
+/// 今日值班信息
488
+class DutyInfo {
489
+  final DateTime date;
490
+  final String shiftName;
491
+  final String shiftTime;
492
+  final DutyPerson leader;
493
+  final List<DutyPerson> members;
494
+  final List<DutyInstruction> instructions;
495
+
496
+  const DutyInfo({
497
+    required this.date,
498
+    required this.shiftName,
499
+    required this.shiftTime,
500
+    required this.leader,
501
+    required this.members,
502
+    required this.instructions,
503
+  });
504
+}
505
+
506
+/// 水质类别
507
+enum QualityCategory {
508
+  rawWater('原水'),
509
+  factoryWater('出厂水'),
510
+  endWater('末梢水');
511
+
512
+  final String label;
513
+  const QualityCategory(this.label);
514
+}
515
+
516
+/// 水质采样数据
517
+class QualitySample {
518
+  final String id;
519
+  final QualityCategory category;
520
+  final String source;
521
+  final DateTime sampleTime;
522
+  final double ph;
523
+  final double turbidity; // NTU
524
+  final int color; // 色度
525
+  final String odor; // 嗅味
526
+  final double ammonia; // 氨氮 mg/L
527
+  final double permanganate; // 高锰酸盐指数 mg/L
528
+  final int totalColiform; // 总大肠菌群 CFU/100mL
529
+  final bool isCompliant;
530
+
531
+  const QualitySample({
532
+    required this.id,
533
+    required this.category,
534
+    required this.source,
535
+    required this.sampleTime,
536
+    required this.ph,
537
+    required this.turbidity,
538
+    required this.color,
539
+    required this.odor,
540
+    required this.ammonia,
541
+    required this.permanganate,
542
+    required this.totalColiform,
543
+    required this.isCompliant,
544
+  });
545
+
546
+  /// 获取各指标达标情况
547
+  List<QualityIndicator> getIndicators() {
548
+    return [
549
+      QualityIndicator('pH', ph.toStringAsFixed(1), '6.5-8.5', ph >= 6.5 && ph <= 8.5),
550
+      QualityIndicator('浊度', '${turbidity.toStringAsFixed(1)} NTU', '≤1 NTU', turbidity <= 1.0),
551
+      QualityIndicator('色度', '$color 度', '≤15 度', color <= 15),
552
+      QualityIndicator('嗅味', odor, '无异味', odor == '无异味'),
553
+      QualityIndicator('氨氮', '${ammonia.toStringAsFixed(2)} mg/L', '≤0.5 mg/L', ammonia <= 0.5),
554
+      QualityIndicator('高锰酸盐', '${permanganate.toStringAsFixed(1)} mg/L', '≤3 mg/L',
555
+          category == QualityCategory.rawWater ? true : permanganate <= 3.0),
556
+      QualityIndicator('总大肠菌群', '$totalColiform CFU/100mL',
557
+          category == QualityCategory.rawWater ? '≤1000' : '不得检出',
558
+          category == QualityCategory.rawWater ? totalColiform <= 1000 : totalColiform == 0),
559
+    ];
560
+  }
561
+}
562
+
563
+/// 水质指标
564
+class QualityIndicator {
565
+  final String name;
566
+  final String value;
567
+  final String standard;
568
+  final bool isCompliant;
569
+
570
+  const QualityIndicator(this.name, this.value, this.standard, this.isCompliant);
571
+}

+ 1
- 0
mobile/pubspec.yaml Datei anzeigen

@@ -16,6 +16,7 @@ dependencies:
16 16
   image_picker: ^1.0.0
17 17
   permission_handler: ^11.0.0
18 18
   flutter_local_notifications: ^17.0.0
19
+  intl: ^0.19.0
19 20
 
20 21
 dev_dependencies:
21 22
   flutter_test: { sdk: flutter }