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

feat(mobile): #81 巡检+营收移动端完整实现

bot_dev2 5 дней назад
Родитель
Сommit
65066c518f

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

@@ -8,6 +8,12 @@ import 'pages/water/monitor_page.dart';
8 8
 import 'pages/water/alert_page.dart';
9 9
 import 'pages/water/dispatch_page.dart';
10 10
 import 'pages/water/quality_page.dart';
11
+import 'pages/patrol/task_list_page.dart';
12
+import 'pages/patrol/task_detail_page.dart';
13
+import 'pages/patrol/track_page.dart';
14
+import 'pages/revenue/meter_reading_page.dart';
15
+import 'pages/revenue/billing_page.dart';
16
+import 'pages/revenue/payment_page.dart';
11 17
 
12 18
 void main() async {
13 19
   WidgetsFlutterBinding.ensureInitialized();
@@ -55,6 +61,12 @@ class WaterApp extends StatelessWidget {
55 61
           '/water/alert': (context) => const AlertPage(),
56 62
           '/water/dispatch': (context) => const DispatchPage(),
57 63
           '/water/quality': (context) => const QualityPage(),
64
+          // 巡检管理
65
+          '/patrol/tasks': (context) => const TaskListPage(),
66
+          // 营收管理
67
+          '/revenue/meter-reading': (context) => const MeterReadingPage(),
68
+          '/revenue/billing': (context) => const BillingPage(),
69
+          '/revenue/payment': (context) => const PaymentPage(),
58 70
         },
59 71
       ),
60 72
     );

+ 715
- 0
mobile/lib/pages/patrol/task_detail_page.dart Просмотреть файл

@@ -0,0 +1,715 @@
1
+import 'package:flutter/material.dart';
2
+import '../../services/patrol_service.dart';
3
+import 'track_page.dart';
4
+
5
+/// 巡检任务详情页面
6
+class TaskDetailPage extends StatefulWidget {
7
+  final String taskId;
8
+  const TaskDetailPage({super.key, required this.taskId});
9
+
10
+  @override
11
+  State<TaskDetailPage> createState() => _TaskDetailPageState();
12
+}
13
+
14
+class _TaskDetailPageState extends State<TaskDetailPage> {
15
+  final PatrolService _service = PatrolService.instance;
16
+  PatrolTask? _task;
17
+  bool _loading = true;
18
+  bool _submitting = false;
19
+  int _currentPointIndex = 0;
20
+
21
+  @override
22
+  void initState() {
23
+    super.initState();
24
+    _loadTask();
25
+  }
26
+
27
+  Future<void> _loadTask() async {
28
+    setState(() => _loading = true);
29
+    final task = await _service.getTaskDetail(widget.taskId);
30
+    if (mounted) {
31
+      setState(() {
32
+        _task = task;
33
+        _loading = false;
34
+        // 找到第一个未完成的巡检点
35
+        if (task != null && task.status == PatrolTaskStatus.ongoing) {
36
+          for (int i = 0; i < task.points.length; i++) {
37
+            if (task.points[i].status == null) {
38
+              _currentPointIndex = i;
39
+              break;
40
+            }
41
+          }
42
+        }
43
+      });
44
+    }
45
+  }
46
+
47
+  @override
48
+  Widget build(BuildContext context) {
49
+    if (_loading) {
50
+      return Scaffold(
51
+        appBar: AppBar(title: const Text('任务详情')),
52
+        body: const Center(child: CircularProgressIndicator()),
53
+      );
54
+    }
55
+
56
+    if (_task == null) {
57
+      return Scaffold(
58
+        appBar: AppBar(title: const Text('任务详情')),
59
+        body: const Center(child: Text('任务不存在')),
60
+      );
61
+    }
62
+
63
+    return Scaffold(
64
+      appBar: AppBar(
65
+        title: Text(_task!.routeName),
66
+        actions: [
67
+          IconButton(
68
+            icon: const Icon(Icons.map),
69
+            tooltip: 'GPS轨迹',
70
+            onPressed: () => _openTrackPage(),
71
+          ),
72
+        ],
73
+      ),
74
+      body: SingleChildScrollView(
75
+        padding: const EdgeInsets.all(16),
76
+        child: Column(
77
+          crossAxisAlignment: CrossAxisAlignment.start,
78
+          children: [
79
+            _buildInfoCard(),
80
+            const SizedBox(height: 16),
81
+            _buildProgressCard(),
82
+            const SizedBox(height: 16),
83
+            _buildPointsSection(),
84
+            if (_task!.status == PatrolTaskStatus.ongoing) ...[
85
+              const SizedBox(height: 16),
86
+              _buildReportSection(),
87
+            ],
88
+          ],
89
+        ),
90
+      ),
91
+    );
92
+  }
93
+
94
+  Widget _buildInfoCard() {
95
+    return Card(
96
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
97
+      child: Padding(
98
+        padding: const EdgeInsets.all(16),
99
+        child: Column(
100
+          crossAxisAlignment: CrossAxisAlignment.start,
101
+          children: [
102
+            Row(
103
+              children: [
104
+                const Icon(Icons.info_outline, size: 20, color: Colors.blue),
105
+                const SizedBox(width: 8),
106
+                Text('任务信息',
107
+                    style: Theme.of(context).textTheme.titleMedium),
108
+              ],
109
+            ),
110
+            const Divider(height: 20),
111
+            _InfoRow(label: '任务编号', value: _task!.id),
112
+            _InfoRow(label: '巡检路线', value: _task!.routeName),
113
+            _InfoRow(label: '计划日期', value: _task!.dateStr),
114
+            _InfoRow(label: '执行人', value: _task!.assignee),
115
+            _InfoRow(label: '巡检点数', value: '${_task!.totalPoints}个'),
116
+            _InfoRow(label: '任务描述', value: _task!.description),
117
+            if (_task!.status == PatrolTaskStatus.completed) ...[
118
+              if (_task!.result != null)
119
+                _InfoRow(
120
+                  label: '巡检结果',
121
+                  value: _task!.result!.label,
122
+                  valueColor: _task!.result == PatrolResult.normal
123
+                      ? Colors.green
124
+                      : Colors.orange,
125
+                ),
126
+              if (_task!.issues != null)
127
+                _InfoRow(label: '发现问题', value: _task!.issues!),
128
+              if (_task!.reportTime != null)
129
+                _InfoRow(
130
+                  label: '上报时间',
131
+                  value:
132
+                      '${_task!.reportTime!.hour.toString().padLeft(2, '0')}:'
133
+                      '${_task!.reportTime!.minute.toString().padLeft(2, '0')}',
134
+                ),
135
+            ],
136
+          ],
137
+        ),
138
+      ),
139
+    );
140
+  }
141
+
142
+  Widget _buildProgressCard() {
143
+    return Card(
144
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
145
+      child: Padding(
146
+        padding: const EdgeInsets.all(16),
147
+        child: Column(
148
+          crossAxisAlignment: CrossAxisAlignment.start,
149
+          children: [
150
+            Row(
151
+              children: [
152
+                Icon(
153
+                  _task!.status == PatrolTaskStatus.completed
154
+                      ? Icons.check_circle
155
+                      : _task!.status == PatrolTaskStatus.ongoing
156
+                          ? Icons.play_circle
157
+                          : Icons.schedule,
158
+                  size: 20,
159
+                  color: _task!.status == PatrolTaskStatus.completed
160
+                      ? Colors.green
161
+                      : _task!.status == PatrolTaskStatus.ongoing
162
+                          ? Colors.blue
163
+                          : Colors.orange,
164
+                ),
165
+                const SizedBox(width: 8),
166
+                Text('巡检进度',
167
+                    style: Theme.of(context).textTheme.titleMedium),
168
+              ],
169
+            ),
170
+            const SizedBox(height: 12),
171
+            Row(
172
+              children: [
173
+                Expanded(
174
+                  child: ClipRRect(
175
+                    borderRadius: BorderRadius.circular(4),
176
+                    child: LinearProgressIndicator(
177
+                      value: _task!.progress,
178
+                      backgroundColor: Colors.grey.shade200,
179
+                      minHeight: 8,
180
+                    ),
181
+                  ),
182
+                ),
183
+                const SizedBox(width: 12),
184
+                Text(
185
+                  '${(_task!.progress * 100).toInt()}%',
186
+                  style: const TextStyle(
187
+                      fontWeight: FontWeight.w600, fontSize: 16),
188
+                ),
189
+              ],
190
+            ),
191
+            const SizedBox(height: 8),
192
+            Text(
193
+              '已完成 ${_task!.completedPoints} / ${_task!.totalPoints} 个巡检点',
194
+              style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
195
+            ),
196
+          ],
197
+        ),
198
+      ),
199
+    );
200
+  }
201
+
202
+  Widget _buildPointsSection() {
203
+    return Card(
204
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
205
+      child: Padding(
206
+        padding: const EdgeInsets.all(16),
207
+        child: Column(
208
+          crossAxisAlignment: CrossAxisAlignment.start,
209
+          children: [
210
+            Row(
211
+              children: [
212
+                const Icon(Icons.location_on, size: 20, color: Colors.red),
213
+                const SizedBox(width: 8),
214
+                Text('巡检点列表',
215
+                    style: Theme.of(context).textTheme.titleMedium),
216
+              ],
217
+            ),
218
+            const SizedBox(height: 12),
219
+            ...List.generate(_task!.points.length, (i) {
220
+              final point = _task!.points[i];
221
+              final isCompleted = point.status != null;
222
+              final isCurrent =
223
+                  _task!.status == PatrolTaskStatus.ongoing &&
224
+                      i == _currentPointIndex;
225
+
226
+              return _PointTile(
227
+                index: i + 1,
228
+                point: point,
229
+                isCompleted: isCompleted,
230
+                isCurrent: isCurrent,
231
+                onTap: _task!.status == PatrolTaskStatus.ongoing
232
+                    ? () => _selectPoint(i)
233
+                    : null,
234
+              );
235
+            }),
236
+          ],
237
+        ),
238
+      ),
239
+    );
240
+  }
241
+
242
+  Widget _buildReportSection() {
243
+    final currentPoint =
244
+        _currentPointIndex < _task!.points.length
245
+            ? _task!.points[_currentPointIndex]
246
+            : null;
247
+
248
+    if (currentPoint == null || currentPoint.status != null) {
249
+      return Card(
250
+        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
251
+        child: Padding(
252
+          padding: const EdgeInsets.all(24),
253
+          child: Column(
254
+            children: [
255
+              const Icon(Icons.check_circle, size: 48, color: Colors.green),
256
+              const SizedBox(height: 12),
257
+              const Text('所有巡检点已完成上报',
258
+                  style: TextStyle(fontSize: 16)),
259
+              const SizedBox(height: 16),
260
+              FilledButton.icon(
261
+                onPressed: _submitting ? null : _completeTask,
262
+                icon: _submitting
263
+                    ? const SizedBox(
264
+                        width: 16,
265
+                        height: 16,
266
+                        child: CircularProgressIndicator(
267
+                            strokeWidth: 2, color: Colors.white))
268
+                    : const Icon(Icons.send),
269
+                label: Text(_submitting ? '提交中...' : '完成巡检任务'),
270
+              ),
271
+            ],
272
+          ),
273
+        ),
274
+      );
275
+    }
276
+
277
+    return _PointReportForm(
278
+      point: currentPoint,
279
+      pointIndex: _currentPointIndex + 1,
280
+      submitting: _submitting,
281
+      onSubmit: (status, remark, photos) =>
282
+          _submitPointReport(currentPoint, status, remark, photos),
283
+    );
284
+  }
285
+
286
+  void _selectPoint(int index) {
287
+    setState(() => _currentPointIndex = index);
288
+  }
289
+
290
+  Future<void> _submitPointReport(
291
+    PatrolPoint point,
292
+    PatrolPointStatus status,
293
+    String? remark,
294
+    List<String>? photos,
295
+  ) async {
296
+    setState(() => _submitting = true);
297
+    final success = await _service.submitPointReport(
298
+      taskId: _task!.id,
299
+      pointId: point.id,
300
+      status: status,
301
+      remark: remark,
302
+      photoPaths: photos,
303
+    );
304
+    if (mounted) {
305
+      setState(() {
306
+        _submitting = false;
307
+        if (success) {
308
+          point.status = status;
309
+          point.remark = remark;
310
+          point.photos = photos;
311
+          // 移到下一个未完成的点
312
+          for (int i = 0; i < _task!.points.length; i++) {
313
+            if (_task!.points[i].status == null) {
314
+              _currentPointIndex = i;
315
+              break;
316
+            }
317
+          }
318
+        }
319
+      });
320
+      if (success) {
321
+        ScaffoldMessenger.of(context).showSnackBar(
322
+          SnackBar(
323
+            content: Text('${point.name} 上报成功'),
324
+            backgroundColor: Colors.green,
325
+          ),
326
+        );
327
+      }
328
+    }
329
+  }
330
+
331
+  Future<void> _completeTask() async {
332
+    final confirmed = await showDialog<bool>(
333
+      context: context,
334
+      builder: (ctx) => AlertDialog(
335
+        title: const Text('确认完成'),
336
+        content: const Text('确定要完成本次巡检任务吗?'),
337
+        actions: [
338
+          TextButton(
339
+            onPressed: () => Navigator.pop(ctx, false),
340
+            child: const Text('取消'),
341
+          ),
342
+          FilledButton(
343
+            onPressed: () => Navigator.pop(ctx, true),
344
+            child: const Text('确认完成'),
345
+          ),
346
+        ],
347
+      ),
348
+    );
349
+    if (confirmed != true) return;
350
+
351
+    setState(() => _submitting = true);
352
+    await _service.completeTask(
353
+      taskId: _task!.id,
354
+      result: PatrolResult.normal,
355
+      summary: '巡检完成,所有点位正常',
356
+    );
357
+    if (mounted) {
358
+      setState(() => _submitting = false);
359
+      ScaffoldMessenger.of(context).showSnackBar(
360
+        const SnackBar(
361
+          content: Text('巡检任务已完成'),
362
+          backgroundColor: Colors.green,
363
+        ),
364
+      );
365
+      Navigator.pop(context);
366
+    }
367
+  }
368
+
369
+  void _openTrackPage() {
370
+    Navigator.push(
371
+      context,
372
+      MaterialPageRoute(
373
+        builder: (_) => TrackPage(taskId: _task!.id, taskName: _task!.routeName),
374
+      ),
375
+    );
376
+  }
377
+}
378
+
379
+class _InfoRow extends StatelessWidget {
380
+  final String label;
381
+  final String value;
382
+  final Color? valueColor;
383
+
384
+  const _InfoRow({required this.label, required this.value, this.valueColor});
385
+
386
+  @override
387
+  Widget build(BuildContext context) {
388
+    return Padding(
389
+      padding: const EdgeInsets.symmetric(vertical: 4),
390
+      child: Row(
391
+        crossAxisAlignment: CrossAxisAlignment.start,
392
+        children: [
393
+          SizedBox(
394
+            width: 70,
395
+            child: Text(
396
+              label,
397
+              style: TextStyle(fontSize: 13, color: Colors.grey.shade600),
398
+            ),
399
+          ),
400
+          Expanded(
401
+            child: Text(
402
+              value,
403
+              style: TextStyle(
404
+                fontSize: 13,
405
+                color: valueColor ?? Colors.black87,
406
+                fontWeight:
407
+                    valueColor != null ? FontWeight.w500 : FontWeight.normal,
408
+              ),
409
+            ),
410
+          ),
411
+        ],
412
+      ),
413
+    );
414
+  }
415
+}
416
+
417
+class _PointTile extends StatelessWidget {
418
+  final int index;
419
+  final PatrolPoint point;
420
+  final bool isCompleted;
421
+  final bool isCurrent;
422
+  final VoidCallback? onTap;
423
+
424
+  const _PointTile({
425
+    required this.index,
426
+    required this.point,
427
+    required this.isCompleted,
428
+    required this.isCurrent,
429
+    this.onTap,
430
+  });
431
+
432
+  @override
433
+  Widget build(BuildContext context) {
434
+    Color bgColor;
435
+    IconData icon;
436
+    if (isCompleted) {
437
+      bgColor = point.status == PatrolPointStatus.normal
438
+          ? Colors.green.shade50
439
+          : Colors.orange.shade50;
440
+      icon = point.status == PatrolPointStatus.normal
441
+          ? Icons.check_circle
442
+          : Icons.warning;
443
+    } else if (isCurrent) {
444
+      bgColor = Colors.blue.shade50;
445
+      icon = Icons.radio_button_checked;
446
+    } else {
447
+      bgColor = Colors.grey.shade50;
448
+      icon = Icons.radio_button_unchecked;
449
+    }
450
+
451
+    return Container(
452
+      margin: const EdgeInsets.only(bottom: 8),
453
+      decoration: BoxDecoration(
454
+        color: bgColor,
455
+        borderRadius: BorderRadius.circular(8),
456
+        border: isCurrent
457
+            ? Border.all(color: Colors.blue, width: 1.5)
458
+            : null,
459
+      ),
460
+      child: ListTile(
461
+        onTap: onTap,
462
+        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
463
+        leading: CircleAvatar(
464
+          backgroundColor: isCompleted
465
+              ? (point.status == PatrolPointStatus.normal
466
+                  ? Colors.green
467
+                  : Colors.orange)
468
+              : isCurrent
469
+                  ? Colors.blue
470
+                  : Colors.grey.shade300,
471
+          child: isCompleted
472
+              ? Icon(icon, color: Colors.white, size: 20)
473
+              : Text(
474
+                  '$index',
475
+                  style: TextStyle(
476
+                    color: isCurrent ? Colors.white : Colors.grey.shade700,
477
+                    fontWeight: FontWeight.w600,
478
+                  ),
479
+                ),
480
+        ),
481
+        title: Text(
482
+          point.name,
483
+          style: TextStyle(
484
+            fontWeight: isCurrent ? FontWeight.w600 : FontWeight.normal,
485
+            fontSize: 14,
486
+          ),
487
+        ),
488
+        subtitle: Text(
489
+          isCompleted && point.remark != null
490
+              ? point.remark!
491
+              : point.type.label,
492
+          style: TextStyle(
493
+            fontSize: 12,
494
+            color: Colors.grey.shade600,
495
+          ),
496
+        ),
497
+        trailing: isCurrent
498
+            ? const Icon(Icons.arrow_forward_ios, size: 16, color: Colors.blue)
499
+            : null,
500
+      ),
501
+    );
502
+  }
503
+}
504
+
505
+class _PointReportForm extends StatefulWidget {
506
+  final PatrolPoint point;
507
+  final int pointIndex;
508
+  final bool submitting;
509
+  final Future<void> Function(
510
+      PatrolPointStatus status, String? remark, List<String>? photos) onSubmit;
511
+
512
+  const _PointReportForm({
513
+    required this.point,
514
+    required this.pointIndex,
515
+    required this.submitting,
516
+    required this.onSubmit,
517
+  });
518
+
519
+  @override
520
+  State<_PointReportForm> createState() => _PointReportFormState();
521
+}
522
+
523
+class _PointReportFormState extends State<_PointReportForm> {
524
+  PatrolPointStatus _selectedStatus = PatrolPointStatus.normal;
525
+  final _remarkController = TextEditingController();
526
+  final List<String> _photos = [];
527
+
528
+  @override
529
+  void dispose() {
530
+    _remarkController.dispose();
531
+    super.dispose();
532
+  }
533
+
534
+  @override
535
+  Widget build(BuildContext context) {
536
+    return Card(
537
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
538
+      color: Colors.blue.shade50,
539
+      child: Padding(
540
+        padding: const EdgeInsets.all(16),
541
+        child: Column(
542
+          crossAxisAlignment: CrossAxisAlignment.start,
543
+          children: [
544
+            Row(
545
+              children: [
546
+                const Icon(Icons.edit_note, size: 20, color: Colors.blue),
547
+                const SizedBox(width: 8),
548
+                Expanded(
549
+                  child: Text(
550
+                    '上报: ${widget.point.name} (${widget.pointIndex})',
551
+                    style: Theme.of(context)
552
+                        .textTheme
553
+                        .titleMedium
554
+                        ?.copyWith(color: Colors.blue.shade800),
555
+                  ),
556
+                ),
557
+              ],
558
+            ),
559
+            const SizedBox(height: 16),
560
+            // 状态选择
561
+            const Text('巡检状态', style: TextStyle(fontWeight: FontWeight.w500)),
562
+            const SizedBox(height: 8),
563
+            Row(
564
+              children: PatrolPointStatus.values.map((s) {
565
+                final isSelected = _selectedStatus == s;
566
+                Color color;
567
+                switch (s) {
568
+                  case PatrolPointStatus.normal:
569
+                    color = Colors.green;
570
+                    break;
571
+                  case PatrolPointStatus.abnormal:
572
+                    color = Colors.red;
573
+                    break;
574
+                  case PatrolPointStatus.maintenance:
575
+                    color = Colors.orange;
576
+                    break;
577
+                }
578
+                return Expanded(
579
+                  child: Padding(
580
+                    padding: const EdgeInsets.only(right: 8),
581
+                    child: ChoiceChip(
582
+                      label: Text(s.label),
583
+                      selected: isSelected,
584
+                      selectedColor: color.withAlpha(40),
585
+                      labelStyle: TextStyle(
586
+                        color: isSelected ? color : Colors.grey,
587
+                      ),
588
+                      side: BorderSide(
589
+                        color: isSelected ? color : Colors.grey.shade300,
590
+                      ),
591
+                      onSelected: (_) => setState(() => _selectedStatus = s),
592
+                    ),
593
+                  ),
594
+                );
595
+              }).toList(),
596
+            ),
597
+            const SizedBox(height: 16),
598
+            // 备注
599
+            const Text('备注', style: TextStyle(fontWeight: FontWeight.w500)),
600
+            const SizedBox(height: 8),
601
+            TextField(
602
+              controller: _remarkController,
603
+              maxLines: 3,
604
+              decoration: InputDecoration(
605
+                hintText: '请输入巡检备注(可选)',
606
+                border: OutlineInputBorder(
607
+                  borderRadius: BorderRadius.circular(8),
608
+                ),
609
+                filled: true,
610
+                fillColor: Colors.white,
611
+              ),
612
+            ),
613
+            const SizedBox(height: 16),
614
+            // 拍照上传
615
+            Row(
616
+              children: [
617
+                const Text('拍照记录',
618
+                    style: TextStyle(fontWeight: FontWeight.w500)),
619
+                const SizedBox(width: 8),
620
+                Text(
621
+                  '(${_photos.length}/6)',
622
+                  style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
623
+                ),
624
+              ],
625
+            ),
626
+            const SizedBox(height: 8),
627
+            Wrap(
628
+              spacing: 8,
629
+              runSpacing: 8,
630
+              children: [
631
+                ..._photos.asMap().entries.map(
632
+                      (entry) => Stack(
633
+                        children: [
634
+                          Container(
635
+                            width: 72,
636
+                            height: 72,
637
+                            decoration: BoxDecoration(
638
+                              color: Colors.grey.shade300,
639
+                              borderRadius: BorderRadius.circular(8),
640
+                            ),
641
+                            child: const Icon(Icons.image,
642
+                                color: Colors.grey, size: 32),
643
+                          ),
644
+                          Positioned(
645
+                            top: -4,
646
+                            right: -4,
647
+                            child: GestureDetector(
648
+                              onTap: () =>
649
+                                  setState(() => _photos.removeAt(entry.key)),
650
+                              child: Container(
651
+                                width: 20,
652
+                                height: 20,
653
+                                decoration: const BoxDecoration(
654
+                                  color: Colors.red,
655
+                                  shape: BoxShape.circle,
656
+                                ),
657
+                                child: const Icon(Icons.close,
658
+                                    size: 12, color: Colors.white),
659
+                              ),
660
+                            ),
661
+                          ),
662
+                        ],
663
+                      ),
664
+                    ),
665
+                if (_photos.length < 6)
666
+                  GestureDetector(
667
+                    onTap: () => setState(
668
+                        () => _photos.add('photo_${_photos.length + 1}')),
669
+                    child: Container(
670
+                      width: 72,
671
+                      height: 72,
672
+                      decoration: BoxDecoration(
673
+                        border: Border.all(color: Colors.blue, width: 1.5),
674
+                        borderRadius: BorderRadius.circular(8),
675
+                      ),
676
+                      child: const Icon(Icons.add_a_photo,
677
+                          color: Colors.blue, size: 24),
678
+                    ),
679
+                  ),
680
+              ],
681
+            ),
682
+            const SizedBox(height: 16),
683
+            // 提交按钮
684
+            SizedBox(
685
+              width: double.infinity,
686
+              child: FilledButton.icon(
687
+                onPressed: widget.submitting
688
+                    ? null
689
+                    : () async {
690
+                        await widget.onSubmit(
691
+                          _selectedStatus,
692
+                          _remarkController.text.isEmpty
693
+                              ? null
694
+                              : _remarkController.text,
695
+                          _photos.isEmpty ? null : _photos,
696
+                        );
697
+                        _remarkController.clear();
698
+                        setState(() => _photos.clear());
699
+                      },
700
+                icon: widget.submitting
701
+                    ? const SizedBox(
702
+                        width: 16,
703
+                        height: 16,
704
+                        child: CircularProgressIndicator(
705
+                            strokeWidth: 2, color: Colors.white))
706
+                    : const Icon(Icons.send, size: 18),
707
+                label: Text(widget.submitting ? '提交中...' : '提交上报'),
708
+              ),
709
+            ),
710
+          ],
711
+        ),
712
+      ),
713
+    );
714
+  }
715
+}

+ 367
- 0
mobile/lib/pages/patrol/task_list_page.dart Просмотреть файл

@@ -0,0 +1,367 @@
1
+import 'package:flutter/material.dart';
2
+import '../../services/patrol_service.dart';
3
+import 'task_detail_page.dart';
4
+
5
+/// 巡检任务列表页面
6
+class TaskListPage extends StatefulWidget {
7
+  const TaskListPage({super.key});
8
+
9
+  @override
10
+  State<TaskListPage> createState() => _TaskListPageState();
11
+}
12
+
13
+class _TaskListPageState extends State<TaskListPage>
14
+    with SingleTickerProviderStateMixin {
15
+  late TabController _tabController;
16
+  final PatrolService _service = PatrolService.instance;
17
+
18
+  List<PatrolTask> _pendingTasks = [];
19
+  List<PatrolTask> _ongoingTasks = [];
20
+  List<PatrolTask> _completedTasks = [];
21
+  bool _loading = true;
22
+
23
+  @override
24
+  void initState() {
25
+    super.initState();
26
+    _tabController = TabController(length: 3, vsync: this);
27
+    _loadData();
28
+  }
29
+
30
+  @override
31
+  void dispose() {
32
+    _tabController.dispose();
33
+    super.dispose();
34
+  }
35
+
36
+  Future<void> _loadData() async {
37
+    setState(() => _loading = true);
38
+    try {
39
+      final pending = await _service.getTaskList(status: PatrolTaskStatus.pending);
40
+      final ongoing = await _service.getTaskList(status: PatrolTaskStatus.ongoing);
41
+      final completed = await _service.getTaskList(status: PatrolTaskStatus.completed);
42
+      if (mounted) {
43
+        setState(() {
44
+          _pendingTasks = pending;
45
+          _ongoingTasks = ongoing;
46
+          _completedTasks = completed;
47
+          _loading = false;
48
+        });
49
+      }
50
+    } catch (e) {
51
+      if (mounted) setState(() => _loading = false);
52
+    }
53
+  }
54
+
55
+  @override
56
+  Widget build(BuildContext context) {
57
+    final theme = Theme.of(context);
58
+
59
+    return Scaffold(
60
+      appBar: AppBar(
61
+        title: const Text('巡检任务'),
62
+        bottom: TabBar(
63
+          controller: _tabController,
64
+          labelColor: theme.colorScheme.primary,
65
+          unselectedLabelColor: Colors.grey,
66
+          indicatorColor: theme.colorScheme.primary,
67
+          tabs: [
68
+            Tab(
69
+              child: Row(
70
+                mainAxisSize: MainAxisSize.min,
71
+                children: [
72
+                  const Icon(Icons.pending_actions, size: 18),
73
+                  const SizedBox(width: 4),
74
+                  Text('待执行 (${_pendingTasks.length})'),
75
+                ],
76
+              ),
77
+            ),
78
+            Tab(
79
+              child: Row(
80
+                mainAxisSize: MainAxisSize.min,
81
+                children: [
82
+                  const Icon(Icons.play_circle, size: 18),
83
+                  const SizedBox(width: 4),
84
+                  Text('进行中 (${_ongoingTasks.length})'),
85
+                ],
86
+              ),
87
+            ),
88
+            Tab(
89
+              child: Row(
90
+                mainAxisSize: MainAxisSize.min,
91
+                children: [
92
+                  const Icon(Icons.check_circle, size: 18),
93
+                  const SizedBox(width: 4),
94
+                  Text('已完成 (${_completedTasks.length})'),
95
+                ],
96
+              ),
97
+            ),
98
+          ],
99
+        ),
100
+      ),
101
+      body: _loading
102
+          ? const Center(child: CircularProgressIndicator())
103
+          : RefreshIndicator(
104
+              onRefresh: _loadData,
105
+              child: TabBarView(
106
+                controller: _tabController,
107
+                children: [
108
+                  _TaskListView(
109
+                    tasks: _pendingTasks,
110
+                    status: PatrolTaskStatus.pending,
111
+                    onTaskTap: _openTask,
112
+                  ),
113
+                  _TaskListView(
114
+                    tasks: _ongoingTasks,
115
+                    status: PatrolTaskStatus.ongoing,
116
+                    onTaskTap: _openTask,
117
+                  ),
118
+                  _TaskListView(
119
+                    tasks: _completedTasks,
120
+                    status: PatrolTaskStatus.completed,
121
+                    onTaskTap: _openTask,
122
+                  ),
123
+                ],
124
+              ),
125
+            ),
126
+    );
127
+  }
128
+
129
+  void _openTask(PatrolTask task) {
130
+    Navigator.push(
131
+      context,
132
+      MaterialPageRoute(
133
+        builder: (_) => TaskDetailPage(taskId: task.id),
134
+      ),
135
+    );
136
+  }
137
+}
138
+
139
+class _TaskListView extends StatelessWidget {
140
+  final List<PatrolTask> tasks;
141
+  final PatrolTaskStatus status;
142
+  final ValueChanged<PatrolTask> onTaskTap;
143
+
144
+  const _TaskListView({
145
+    required this.tasks,
146
+    required this.status,
147
+    required this.onTaskTap,
148
+  });
149
+
150
+  @override
151
+  Widget build(BuildContext context) {
152
+    if (tasks.isEmpty) {
153
+      return ListView(
154
+        children: [
155
+          SizedBox(
156
+            height: MediaQuery.of(context).size.height * 0.6,
157
+            child: Center(
158
+              child: Column(
159
+                mainAxisSize: MainAxisSize.min,
160
+                children: [
161
+                  Icon(Icons.inbox, size: 64, color: Colors.grey.shade300),
162
+                  const SizedBox(height: 16),
163
+                  Text('暂无${status.label}任务',
164
+                      style: const TextStyle(color: Colors.grey, fontSize: 16)),
165
+                ],
166
+              ),
167
+            ),
168
+          ),
169
+        ],
170
+      );
171
+    }
172
+
173
+    return ListView.builder(
174
+      padding: const EdgeInsets.all(12),
175
+      itemCount: tasks.length,
176
+      itemBuilder: (ctx, i) => _TaskCard(
177
+        task: tasks[i],
178
+        onTap: () => onTaskTap(tasks[i]),
179
+      ),
180
+    );
181
+  }
182
+}
183
+
184
+class _TaskCard extends StatelessWidget {
185
+  final PatrolTask task;
186
+  final VoidCallback onTap;
187
+
188
+  const _TaskCard({required this.task, required this.onTap});
189
+
190
+  @override
191
+  Widget build(BuildContext context) {
192
+    final theme = Theme.of(context);
193
+    final priorityColor = Color(task.priority.color);
194
+
195
+    return Card(
196
+      margin: const EdgeInsets.only(bottom: 12),
197
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
198
+      child: InkWell(
199
+        onTap: onTap,
200
+        borderRadius: BorderRadius.circular(12),
201
+        child: Padding(
202
+          padding: const EdgeInsets.all(16),
203
+          child: Column(
204
+            crossAxisAlignment: CrossAxisAlignment.start,
205
+            children: [
206
+              // 标题行
207
+              Row(
208
+                children: [
209
+                  Expanded(
210
+                    child: Text(
211
+                      task.routeName,
212
+                      style: const TextStyle(
213
+                        fontWeight: FontWeight.w600,
214
+                        fontSize: 15,
215
+                      ),
216
+                    ),
217
+                  ),
218
+                  Container(
219
+                    padding: const EdgeInsets.symmetric(
220
+                        horizontal: 8, vertical: 2),
221
+                    decoration: BoxDecoration(
222
+                      color: priorityColor.withAlpha(30),
223
+                      borderRadius: BorderRadius.circular(4),
224
+                    ),
225
+                    child: Text(
226
+                      '${task.priority.label}优先',
227
+                      style: TextStyle(
228
+                        fontSize: 11,
229
+                        color: priorityColor,
230
+                      ),
231
+                    ),
232
+                  ),
233
+                ],
234
+              ),
235
+              const SizedBox(height: 8),
236
+              // 描述
237
+              Text(
238
+                task.description,
239
+                style: TextStyle(
240
+                  fontSize: 13,
241
+                  color: Colors.grey.shade600,
242
+                ),
243
+                maxLines: 2,
244
+                overflow: TextOverflow.ellipsis,
245
+              ),
246
+              const SizedBox(height: 12),
247
+              // 信息行
248
+              Wrap(
249
+                spacing: 12,
250
+                runSpacing: 6,
251
+                children: [
252
+                  _InfoChip(
253
+                      icon: Icons.tag, text: task.id),
254
+                  _InfoChip(
255
+                      icon: Icons.calendar_today, text: task.dateStr, size: 12),
256
+                  _InfoChip(
257
+                      icon: Icons.location_on,
258
+                      text: '${task.totalPoints}个巡检点'),
259
+                  _InfoChip(
260
+                      icon: Icons.person, text: task.assignee),
261
+                ],
262
+              ),
263
+              // 进度(进行中状态)
264
+              if (task.status == PatrolTaskStatus.ongoing) ...[
265
+                const SizedBox(height: 12),
266
+                ClipRRect(
267
+                  borderRadius: BorderRadius.circular(4),
268
+                  child: LinearProgressIndicator(
269
+                    value: task.progress,
270
+                    backgroundColor: Colors.grey.shade200,
271
+                    minHeight: 6,
272
+                  ),
273
+                ),
274
+                const SizedBox(height: 4),
275
+                Text(
276
+                  '进度: ${task.completedPoints}/${task.totalPoints}',
277
+                  style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
278
+                ),
279
+              ],
280
+              // 巡检结果(已完成状态)
281
+              if (task.status == PatrolTaskStatus.completed &&
282
+                  task.result != null) ...[
283
+                const SizedBox(height: 8),
284
+                Row(
285
+                  children: [
286
+                    Icon(
287
+                      task.result == PatrolResult.normal
288
+                          ? Icons.check_circle
289
+                          : Icons.warning,
290
+                      size: 16,
291
+                      color: task.result == PatrolResult.normal
292
+                          ? Colors.green
293
+                          : Colors.orange,
294
+                    ),
295
+                    const SizedBox(width: 4),
296
+                    Text(
297
+                      '巡检结果: ${task.result!.label}',
298
+                      style: TextStyle(
299
+                        fontSize: 13,
300
+                        color: task.result == PatrolResult.normal
301
+                            ? Colors.green
302
+                            : Colors.orange,
303
+                      ),
304
+                    ),
305
+                  ],
306
+                ),
307
+                if (task.issues != null) ...[
308
+                  const SizedBox(height: 4),
309
+                  Text(
310
+                    '问题: ${task.issues!}',
311
+                    style: TextStyle(fontSize: 12, color: Colors.grey.shade600),
312
+                  ),
313
+                ],
314
+              ],
315
+              const SizedBox(height: 12),
316
+              // 操作按钮
317
+              Row(
318
+                mainAxisAlignment: MainAxisAlignment.end,
319
+                children: [
320
+                  if (task.status == PatrolTaskStatus.pending)
321
+                    FilledButton.icon(
322
+                      onPressed: onTap,
323
+                      icon: const Icon(Icons.play_arrow, size: 18),
324
+                      label: const Text('开始巡检'),
325
+                    ),
326
+                  if (task.status == PatrolTaskStatus.ongoing)
327
+                    FilledButton.icon(
328
+                      onPressed: onTap,
329
+                      icon: const Icon(Icons.continue, size: 18),
330
+                      label: const Text('继续'),
331
+                    ),
332
+                  if (task.status == PatrolTaskStatus.completed)
333
+                    OutlinedButton.icon(
334
+                      onPressed: onTap,
335
+                      icon: const Icon(Icons.visibility, size: 18),
336
+                      label: const Text('查看详情'),
337
+                    ),
338
+                ],
339
+              ),
340
+            ],
341
+          ),
342
+        ),
343
+      ),
344
+    );
345
+  }
346
+}
347
+
348
+class _InfoChip extends StatelessWidget {
349
+  final IconData icon;
350
+  final String text;
351
+  final double size;
352
+
353
+  const _InfoChip({required this.icon, required this.text, this.size = 14});
354
+
355
+  @override
356
+  Widget build(BuildContext context) {
357
+    return Row(
358
+      mainAxisSize: MainAxisSize.min,
359
+      children: [
360
+        Icon(icon, size: size, color: Colors.grey),
361
+        const SizedBox(width: 2),
362
+        Text(text,
363
+            style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
364
+      ],
365
+    );
366
+  }
367
+}

+ 512
- 0
mobile/lib/pages/patrol/track_page.dart Просмотреть файл

@@ -0,0 +1,512 @@
1
+import 'package:flutter/material.dart';
2
+import '../../services/patrol_service.dart';
3
+
4
+/// GPS 轨迹记录页面
5
+class TrackPage extends StatefulWidget {
6
+  final String taskId;
7
+  final String taskName;
8
+
9
+  const TrackPage({super.key, required this.taskId, required this.taskName});
10
+
11
+  @override
12
+  State<TrackPage> createState() => _TrackPageState();
13
+}
14
+
15
+class _TrackPageState extends State<TrackPage> {
16
+  final PatrolService _service = PatrolService.instance;
17
+  List<TrackPoint> _trackPoints = [];
18
+  bool _loading = true;
19
+  bool _recording = false;
20
+
21
+  @override
22
+  void initState() {
23
+    super.initState();
24
+    _loadTrackPoints();
25
+  }
26
+
27
+  Future<void> _loadTrackPoints() async {
28
+    setState(() => _loading = true);
29
+    final points = await _service.getTrackPoints(widget.taskId);
30
+    if (mounted) {
31
+      setState(() {
32
+        _trackPoints = points;
33
+        _loading = false;
34
+      });
35
+    }
36
+  }
37
+
38
+  @override
39
+  Widget build(BuildContext context) {
40
+    return Scaffold(
41
+      appBar: AppBar(
42
+        title: Text('GPS轨迹 - ${widget.taskName}'),
43
+        actions: [
44
+          IconButton(
45
+            icon: Icon(
46
+              _recording ? Icons.stop : Icons.play_arrow,
47
+              color: _recording ? Colors.red : null,
48
+            ),
49
+            tooltip: _recording ? '停止记录' : '开始记录',
50
+            onPressed: _toggleRecording,
51
+          ),
52
+        ],
53
+      ),
54
+      body: _loading
55
+          ? const Center(child: CircularProgressIndicator())
56
+          : Column(
57
+              children: [
58
+                _buildStatsBar(),
59
+                Expanded(child: _buildTrackMap()),
60
+                _buildPointList(),
61
+              ],
62
+            ),
63
+    );
64
+  }
65
+
66
+  Widget _buildStatsBar() {
67
+    final totalDistance = _calculateDistance();
68
+    final duration = _trackPoints.isNotEmpty
69
+        ? _trackPoints.last.timestamp.difference(_trackPoints.first.timestamp)
70
+        : Duration.zero;
71
+
72
+    return Container(
73
+      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
74
+      color: Theme.of(context).colorScheme.surfaceContainerHighest,
75
+      child: Row(
76
+        mainAxisAlignment: MainAxisAlignment.spaceAround,
77
+        children: [
78
+          _StatItem(
79
+            icon: Icons.straighten,
80
+            label: '总距离',
81
+            value: '${totalDistance.toStringAsFixed(1)} km',
82
+          ),
83
+          _StatItem(
84
+            icon: Icons.access_time,
85
+            label: '用时',
86
+            value:
87
+                '${duration.inHours}h ${duration.inMinutes.remainder(60)}min',
88
+          ),
89
+          _StatItem(
90
+            icon: Icons.location_on,
91
+            label: '轨迹点',
92
+            value: '${_trackPoints.length}',
93
+          ),
94
+          _StatItem(
95
+            icon: Icons.speed,
96
+            label: '平均速度',
97
+            value: duration.inMinutes > 0
98
+                ? '${(totalDistance / (duration.inMinutes / 60)).toStringAsFixed(1)} km/h'
99
+                : '-',
100
+          ),
101
+        ],
102
+      ),
103
+    );
104
+  }
105
+
106
+  Widget _buildTrackMap() {
107
+    // 使用简化的轨迹可视化(实际项目可集成高德/百度地图)
108
+    return Card(
109
+      margin: const EdgeInsets.all(12),
110
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
111
+      child: Column(
112
+        children: [
113
+          Padding(
114
+            padding: const EdgeInsets.all(12),
115
+            child: Row(
116
+              children: [
117
+                const Icon(Icons.map, size: 20, color: Colors.blue),
118
+                const SizedBox(width: 8),
119
+                Text('轨迹地图', style: Theme.of(context).textTheme.titleMedium),
120
+                const Spacer(),
121
+                if (_recording)
122
+                  Container(
123
+                    padding: const EdgeInsets.symmetric(
124
+                        horizontal: 8, vertical: 2),
125
+                    decoration: BoxDecoration(
126
+                      color: Colors.red.shade100,
127
+                      borderRadius: BorderRadius.circular(4),
128
+                    ),
129
+                    child: const Row(
130
+                      mainAxisSize: MainAxisSize.min,
131
+                      children: [
132
+                        Icon(Icons.fiber_manual_record,
133
+                            size: 8, color: Colors.red),
134
+                        SizedBox(width: 4),
135
+                        Text('记录中',
136
+                            style: TextStyle(fontSize: 11, color: Colors.red)),
137
+                      ],
138
+                    ),
139
+                  ),
140
+              ],
141
+            ),
142
+          ),
143
+          Expanded(
144
+            child: Container(
145
+              margin: const EdgeInsets.fromLTRB(12, 0, 12, 12),
146
+              decoration: BoxDecoration(
147
+                color: Colors.grey.shade100,
148
+                borderRadius: BorderRadius.circular(8),
149
+              ),
150
+              child: CustomPaint(
151
+                size: Size.infinite,
152
+                painter: _TrackPainter(
153
+                  points: _trackPoints,
154
+                  color: Theme.of(context).colorScheme.primary,
155
+                ),
156
+                child: Stack(
157
+                  children: [
158
+                    // 起点标记
159
+                    if (_trackPoints.isNotEmpty)
160
+                      Positioned(
161
+                        left: 12,
162
+                        top: 12,
163
+                        child: Container(
164
+                          padding: const EdgeInsets.symmetric(
165
+                              horizontal: 8, vertical: 4),
166
+                          decoration: BoxDecoration(
167
+                            color: Colors.green,
168
+                            borderRadius: BorderRadius.circular(12),
169
+                          ),
170
+                          child: const Text(
171
+                            '起点',
172
+                            style: TextStyle(
173
+                                color: Colors.white,
174
+                                fontSize: 11,
175
+                                fontWeight: FontWeight.w600),
176
+                          ),
177
+                        ),
178
+                      ),
179
+                    // 终点标记
180
+                    if (_trackPoints.isNotEmpty)
181
+                      Positioned(
182
+                        right: 12,
183
+                        bottom: 12,
184
+                        child: Container(
185
+                          padding: const EdgeInsets.symmetric(
186
+                              horizontal: 8, vertical: 4),
187
+                          decoration: BoxDecoration(
188
+                            color: Colors.red,
189
+                            borderRadius: BorderRadius.circular(12),
190
+                          ),
191
+                          child: const Text(
192
+                            '终点',
193
+                            style: TextStyle(
194
+                                color: Colors.white,
195
+                                fontSize: 11,
196
+                                fontWeight: FontWeight.w600),
197
+                          ),
198
+                        ),
199
+                      ),
200
+                  ],
201
+                ),
202
+              ),
203
+            ),
204
+          ),
205
+        ],
206
+      ),
207
+    );
208
+  }
209
+
210
+  Widget _buildPointList() {
211
+    return Container(
212
+      height: 200,
213
+      decoration: BoxDecoration(
214
+        color: Theme.of(context).colorScheme.surface,
215
+        border: Border(
216
+          top: BorderSide(color: Colors.grey.shade300),
217
+        ),
218
+      ),
219
+      child: Column(
220
+        crossAxisAlignment: CrossAxisAlignment.start,
221
+        children: [
222
+          Padding(
223
+            padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
224
+            child: Text(
225
+              '轨迹点详情',
226
+              style: Theme.of(context).textTheme.titleSmall,
227
+            ),
228
+          ),
229
+          Expanded(
230
+            child: ListView.builder(
231
+              padding: const EdgeInsets.symmetric(horizontal: 12),
232
+              itemCount: _trackPoints.length,
233
+              itemBuilder: (ctx, i) {
234
+                final point = _trackPoints[i];
235
+                return _TrackPointTile(point: point, index: i + 1);
236
+              },
237
+            ),
238
+          ),
239
+        ],
240
+      ),
241
+    );
242
+  }
243
+
244
+  double _calculateDistance() {
245
+    if (_trackPoints.length < 2) return 0;
246
+    double total = 0;
247
+    for (int i = 1; i < _trackPoints.length; i++) {
248
+      total += _haversine(
249
+        _trackPoints[i - 1].lat,
250
+        _trackPoints[i - 1].lng,
251
+        _trackPoints[i].lat,
252
+        _trackPoints[i].lng,
253
+      );
254
+    }
255
+    return total;
256
+  }
257
+
258
+  double _haversine(double lat1, double lng1, double lat2, double lng2) {
259
+    const r = 6371.0; // Earth radius in km
260
+    final dLat = (lat2 - lat1) * 3.14159265 / 180;
261
+    final dLng = (lng2 - lng1) * 3.14159265 / 180;
262
+    final a = (dLat / 2).sin() * (dLat / 2).sin() +
263
+        (lat1 * 3.14159265 / 180).cos() *
264
+            (lat2 * 3.14159265 / 180).cos() *
265
+            (dLng / 2).sin() *
266
+            (dLng / 2).sin();
267
+    return 2 * r * a.sqrt().asin();
268
+  }
269
+
270
+  void _toggleRecording() {
271
+    setState(() => _recording = !_recording);
272
+    ScaffoldMessenger.of(context).showSnackBar(
273
+      SnackBar(
274
+        content: Text(_recording ? 'GPS轨迹记录已开始' : 'GPS轨迹记录已停止'),
275
+        duration: const Duration(seconds: 2),
276
+      ),
277
+    );
278
+  }
279
+}
280
+
281
+// 扩展方法用于三角函数计算
282
+extension _MathExt on double {
283
+  double sin() => _sin(this);
284
+  double cos() => _cos(this);
285
+  double sqrt() => _sqrt(this);
286
+  double asin() => _asin(this);
287
+
288
+  static double _sin(double x) {
289
+    // Taylor approximation
290
+    double result = x;
291
+    double term = x;
292
+    for (int i = 1; i <= 10; i++) {
293
+      term *= -x * x / ((2 * i + 1) * (2 * i + 2));
294
+      result += term / (2 * i + 1);
295
+    }
296
+    return result;
297
+  }
298
+
299
+  static double _cos(double x) {
300
+    double result = 1;
301
+    double term = 1;
302
+    for (int i = 1; i <= 10; i++) {
303
+      term *= -x * x / ((2 * i - 1) * (2 * i));
304
+      result += term;
305
+    }
306
+    return result;
307
+  }
308
+
309
+  static double _sqrt(double x) {
310
+    if (x < 0) return double.nan;
311
+    double guess = x / 2;
312
+    for (int i = 0; i < 20; i++) {
313
+      guess = (guess + x / guess) / 2;
314
+    }
315
+    return guess;
316
+  }
317
+
318
+  static double _asin(double x) {
319
+    if (x < -1 || x > 1) return double.nan;
320
+    // Use Taylor series: asin(x) ≈ x + x³/6 + 3x⁵/40 + ...
321
+    // Better approximation using atan
322
+    return _atan(x / _sqrt(1 - x * x));
323
+  }
324
+
325
+  static double _atan(double x) {
326
+    // atan approximation
327
+    if (x.abs() > 1) {
328
+      return 3.14159265 / 2 * (x > 0 ? 1 : -1) - _atanSmall(1 / x);
329
+    }
330
+    return _atanSmall(x);
331
+  }
332
+
333
+  static double _atanSmall(double x) {
334
+    // Polynomial approximation for |x| <= 1
335
+    final x2 = x * x;
336
+    return x * (1 - x2 / 3 + x2 * x2 / 5 - x2 * x2 * x2 / 7 +
337
+        x2 * x2 * x2 * x2 / 9);
338
+  }
339
+}
340
+
341
+class _TrackPainter extends CustomPainter {
342
+  final List<TrackPoint> points;
343
+  final Color color;
344
+
345
+  _TrackPainter({required this.points, required this.color});
346
+
347
+  @override
348
+  void paint(Canvas canvas, Size size) {
349
+    if (points.length < 2) return;
350
+
351
+    // Find bounds
352
+    double minLat = points.first.lat;
353
+    double maxLat = points.first.lat;
354
+    double minLng = points.first.lng;
355
+    double maxLng = points.first.lng;
356
+
357
+    for (final p in points) {
358
+      if (p.lat < minLat) minLat = p.lat;
359
+      if (p.lat > maxLat) maxLat = p.lat;
360
+      if (p.lng < minLng) minLng = p.lng;
361
+      if (p.lng > maxLng) maxLng = p.lng;
362
+    }
363
+
364
+    final latRange = maxLat - minLat;
365
+    final lngRange = maxLng - minLng;
366
+    final padding = 20.0;
367
+    final drawWidth = size.width - padding * 2;
368
+    final drawHeight = size.height - padding * 2;
369
+
370
+    Offset toScreen(TrackPoint p) {
371
+      final x = lngRange > 0
372
+          ? padding + (p.lng - minLng) / lngRange * drawWidth
373
+          : size.width / 2;
374
+      final y = latRange > 0
375
+          ? padding + (1 - (p.lat - minLat) / latRange) * drawHeight
376
+          : size.height / 2;
377
+      return Offset(x, y);
378
+    }
379
+
380
+    // Draw track line
381
+    final paint = Paint()
382
+      ..color = color
383
+      ..strokeWidth = 3
384
+      ..style = PaintingStyle.stroke
385
+      ..strokeCap = StrokeCap.round;
386
+
387
+    final path = Path();
388
+    final first = toScreen(points.first);
389
+    path.moveTo(first.dx, first.dy);
390
+    for (int i = 1; i < points.length; i++) {
391
+      final p = toScreen(points[i]);
392
+      path.lineTo(p.dx, p.dy);
393
+    }
394
+    canvas.drawPath(path, paint);
395
+
396
+    // Draw points
397
+    final pointPaint = Paint()..style = PaintingStyle.fill;
398
+    for (int i = 0; i < points.length; i++) {
399
+      final p = toScreen(points[i]);
400
+      pointPaint.color = i == 0
401
+          ? Colors.green
402
+          : i == points.length - 1
403
+              ? Colors.red
404
+              : color;
405
+      canvas.drawCircle(p, i == 0 || i == points.length - 1 ? 6 : 4, pointPaint);
406
+      canvas.drawCircle(
407
+          p, i == 0 || i == points.length - 1 ? 8 : 5,
408
+          Paint()
409
+            ..color = Colors.white
410
+            ..style = PaintingStyle.stroke
411
+            ..strokeWidth = 1.5);
412
+    }
413
+  }
414
+
415
+  @override
416
+  bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
417
+}
418
+
419
+class _StatItem extends StatelessWidget {
420
+  final IconData icon;
421
+  final String label;
422
+  final String value;
423
+
424
+  const _StatItem({
425
+    required this.icon,
426
+    required this.label,
427
+    required this.value,
428
+  });
429
+
430
+  @override
431
+  Widget build(BuildContext context) {
432
+    return Column(
433
+      mainAxisSize: MainAxisSize.min,
434
+      children: [
435
+        Icon(icon, size: 18, color: Theme.of(context).colorScheme.primary),
436
+        const SizedBox(height: 4),
437
+        Text(value,
438
+            style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 13)),
439
+        const SizedBox(height: 2),
440
+        Text(label, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
441
+      ],
442
+    );
443
+  }
444
+}
445
+
446
+class _TrackPointTile extends StatelessWidget {
447
+  final TrackPoint point;
448
+  final int index;
449
+
450
+  const _TrackPointTile({required this.point, required this.index});
451
+
452
+  @override
453
+  Widget build(BuildContext context) {
454
+    return Container(
455
+      padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 8),
456
+      margin: const EdgeInsets.only(bottom: 4),
457
+      decoration: BoxDecoration(
458
+        color: index == 1
459
+            ? Colors.green.shade50
460
+            : Colors.grey.shade50,
461
+        borderRadius: BorderRadius.circular(6),
462
+      ),
463
+      child: Row(
464
+        children: [
465
+          CircleAvatar(
466
+            radius: 14,
467
+            backgroundColor:
468
+                index == 1 ? Colors.green : Colors.grey.shade300,
469
+            child: Text(
470
+              '$index',
471
+              style: TextStyle(
472
+                fontSize: 11,
473
+                color: index == 1 ? Colors.white : Colors.grey.shade700,
474
+              ),
475
+            ),
476
+          ),
477
+          const SizedBox(width: 8),
478
+          Expanded(
479
+            child: Column(
480
+              crossAxisAlignment: CrossAxisAlignment.start,
481
+              children: [
482
+                Text(
483
+                  point.address,
484
+                  style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
485
+                ),
486
+                Text(
487
+                  '${point.lat.toStringAsFixed(4)}, ${point.lng.toStringAsFixed(4)} | 海拔 ${point.altitude.toStringAsFixed(1)}m',
488
+                  style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
489
+                ),
490
+              ],
491
+            ),
492
+          ),
493
+          Column(
494
+            crossAxisAlignment: CrossAxisAlignment.end,
495
+            children: [
496
+              Text(
497
+                '${point.timestamp.hour.toString().padLeft(2, '0')}:'
498
+                '${point.timestamp.minute.toString().padLeft(2, '0')}',
499
+                style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500),
500
+              ),
501
+              if (point.speed > 0)
502
+                Text(
503
+                  '${point.speed.toStringAsFixed(1)} km/h',
504
+                  style: TextStyle(fontSize: 11, color: Colors.grey.shade600),
505
+                ),
506
+            ],
507
+          ),
508
+        ],
509
+      ),
510
+    );
511
+  }
512
+}

+ 489
- 0
mobile/lib/pages/revenue/billing_page.dart Просмотреть файл

@@ -0,0 +1,489 @@
1
+import 'package:flutter/material.dart';
2
+import '../../services/revenue_service.dart';
3
+
4
+/// 账单查询页面
5
+class BillingPage extends StatefulWidget {
6
+  const BillingPage({super.key});
7
+
8
+  @override
9
+  State<BillingPage> createState() => _BillingPageState();
10
+}
11
+
12
+class _BillingPageState extends State<BillingPage> {
13
+  final RevenueService _service = RevenueService.instance;
14
+  List<BillItem> _bills = [];
15
+  bool _loading = true;
16
+  BillStatus? _filterStatus;
17
+  String _currentPeriod = '2024-05';
18
+
19
+  @override
20
+  void initState() {
21
+    super.initState();
22
+    _loadBills();
23
+  }
24
+
25
+  Future<void> _loadBills() async {
26
+    setState(() => _loading = true);
27
+    final bills = await _service.getBillList(
28
+      period: _currentPeriod,
29
+      status: _filterStatus,
30
+    );
31
+    if (mounted) {
32
+      setState(() {
33
+        _bills = bills;
34
+        _loading = false;
35
+      });
36
+    }
37
+  }
38
+
39
+  @override
40
+  Widget build(BuildContext context) {
41
+    return Scaffold(
42
+      appBar: AppBar(
43
+        title: const Text('账单查询'),
44
+        actions: [
45
+          PopupMenuButton<String>(
46
+            icon: const Icon(Icons.calendar_month),
47
+            tooltip: '选择月份',
48
+            onSelected: (period) {
49
+              setState(() => _currentPeriod = period);
50
+              _loadBills();
51
+            },
52
+            itemBuilder: (_) => [
53
+              const PopupMenuItem(value: '2024-05', child: Text('2024年5月')),
54
+              const PopupMenuItem(value: '2024-04', child: Text('2024年4月')),
55
+              const PopupMenuItem(value: '2024-03', child: Text('2024年3月')),
56
+            ],
57
+          ),
58
+        ],
59
+      ),
60
+      body: Column(
61
+        children: [
62
+          _buildSummaryBar(),
63
+          _buildFilterChips(),
64
+          Expanded(
65
+            child: _loading
66
+                ? const Center(child: CircularProgressIndicator())
67
+                : _bills.isEmpty
68
+                    ? Center(
69
+                        child: Column(
70
+                          mainAxisSize: MainAxisSize.min,
71
+                          children: [
72
+                            Icon(Icons.receipt_long,
73
+                                size: 64, color: Colors.grey.shade300),
74
+                            const SizedBox(height: 16),
75
+                            const Text('暂无账单数据',
76
+                                style: TextStyle(color: Colors.grey)),
77
+                          ],
78
+                        ),
79
+                      )
80
+                    : RefreshIndicator(
81
+                        onRefresh: _loadBills,
82
+                        child: ListView.builder(
83
+                          padding: const EdgeInsets.all(12),
84
+                          itemCount: _bills.length,
85
+                          itemBuilder: (ctx, i) => _BillCard(
86
+                            bill: _bills[i],
87
+                            onTap: () => _showBillDetail(_bills[i]),
88
+                          ),
89
+                        ),
90
+                      ),
91
+          ),
92
+        ],
93
+      ),
94
+    );
95
+  }
96
+
97
+  Widget _buildSummaryBar() {
98
+    final totalAmount =
99
+        _bills.fold<double>(0, (sum, b) => sum + b.amount);
100
+    final paidAmount = _bills
101
+        .where((b) => b.status == BillStatus.paid)
102
+        .fold<double>(0, (sum, b) => sum + b.amount);
103
+    final unpaidAmount = _bills
104
+        .where((b) => b.status != BillStatus.paid)
105
+        .fold<double>(0, (sum, b) => sum + b.amount);
106
+
107
+    return Container(
108
+      padding: const EdgeInsets.all(16),
109
+      color: Theme.of(context).colorScheme.primaryContainer.withAlpha(60),
110
+      child: Row(
111
+        mainAxisAlignment: MainAxisAlignment.spaceAround,
112
+        children: [
113
+          _SummaryItem(
114
+              label: '总金额',
115
+              value: '¥${totalAmount.toStringAsFixed(2)}',
116
+              color: Colors.blue),
117
+          _SummaryItem(
118
+              label: '已收',
119
+              value: '¥${paidAmount.toStringAsFixed(2)}',
120
+              color: Colors.green),
121
+          _SummaryItem(
122
+              label: '未收',
123
+              value: '¥${unpaidAmount.toStringAsFixed(2)}',
124
+              color: Colors.orange),
125
+          _SummaryItem(
126
+              label: '笔数', value: '${_bills.length}', color: Colors.grey),
127
+        ],
128
+      ),
129
+    );
130
+  }
131
+
132
+  Widget _buildFilterChips() {
133
+    return Padding(
134
+      padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
135
+      child: Row(
136
+        children: [
137
+          _FilterChip(
138
+            label: '全部',
139
+            selected: _filterStatus == null,
140
+            onSelected: (_) {
141
+              setState(() => _filterStatus = null);
142
+              _loadBills();
143
+            },
144
+          ),
145
+          const SizedBox(width: 8),
146
+          _FilterChip(
147
+            label: '已缴费',
148
+            selected: _filterStatus == BillStatus.paid,
149
+            color: Colors.green,
150
+            onSelected: (_) {
151
+              setState(
152
+                  () => _filterStatus = _filterStatus == BillStatus.paid ? null : BillStatus.paid);
153
+              _loadBills();
154
+            },
155
+          ),
156
+          const SizedBox(width: 8),
157
+          _FilterChip(
158
+            label: '待缴费',
159
+            selected: _filterStatus == BillStatus.unpaid,
160
+            color: Colors.orange,
161
+            onSelected: (_) {
162
+              setState(
163
+                  () => _filterStatus = _filterStatus == BillStatus.unpaid ? null : BillStatus.unpaid);
164
+              _loadBills();
165
+            },
166
+          ),
167
+          const SizedBox(width: 8),
168
+          _FilterChip(
169
+            label: '欠费',
170
+            selected: _filterStatus == BillStatus.overdue,
171
+            color: Colors.red,
172
+            onSelected: (_) {
173
+              setState(
174
+                  () => _filterStatus = _filterStatus == BillStatus.overdue ? null : BillStatus.overdue);
175
+              _loadBills();
176
+            },
177
+          ),
178
+        ],
179
+      ),
180
+    );
181
+  }
182
+
183
+  void _showBillDetail(BillItem bill) {
184
+    showModalBottomSheet(
185
+      context: context,
186
+      isScrollControlled: true,
187
+      shape: const RoundedRectangleBorder(
188
+        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
189
+      ),
190
+      builder: (ctx) => _BillDetailSheet(bill: bill),
191
+    );
192
+  }
193
+}
194
+
195
+class _BillCard extends StatelessWidget {
196
+  final BillItem bill;
197
+  final VoidCallback onTap;
198
+
199
+  const _BillCard({required this.bill, required this.onTap});
200
+
201
+  Color get _statusColor {
202
+    switch (bill.status) {
203
+      case BillStatus.paid:
204
+        return Colors.green;
205
+      case BillStatus.unpaid:
206
+        return Colors.orange;
207
+      case BillStatus.overdue:
208
+        return Colors.red;
209
+    }
210
+  }
211
+
212
+  @override
213
+  Widget build(BuildContext context) {
214
+    return Card(
215
+      margin: const EdgeInsets.only(bottom: 8),
216
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
217
+      child: InkWell(
218
+        onTap: onTap,
219
+        borderRadius: BorderRadius.circular(10),
220
+        child: Padding(
221
+          padding: const EdgeInsets.all(12),
222
+          child: Row(
223
+            children: [
224
+              CircleAvatar(
225
+                backgroundColor: _statusColor.withAlpha(20),
226
+                child: Icon(
227
+                  bill.status == BillStatus.paid
228
+                      ? Icons.check_circle
229
+                      : bill.status == BillStatus.unpaid
230
+                          ? Icons.schedule
231
+                          : Icons.error,
232
+                  color: _statusColor,
233
+                  size: 20,
234
+                ),
235
+              ),
236
+              const SizedBox(width: 12),
237
+              Expanded(
238
+                child: Column(
239
+                  crossAxisAlignment: CrossAxisAlignment.start,
240
+                  children: [
241
+                    Row(
242
+                      children: [
243
+                        Text(
244
+                          bill.customerName,
245
+                          style: const TextStyle(
246
+                              fontWeight: FontWeight.w600, fontSize: 14),
247
+                        ),
248
+                        const SizedBox(width: 8),
249
+                        Text(
250
+                          bill.customerAddress,
251
+                          style: TextStyle(
252
+                              fontSize: 12, color: Colors.grey.shade600),
253
+                        ),
254
+                      ],
255
+                    ),
256
+                    const SizedBox(height: 4),
257
+                    Row(
258
+                      children: [
259
+                        Text('表号: ${bill.meterId}',
260
+                            style: TextStyle(
261
+                                fontSize: 11, color: Colors.grey.shade500)),
262
+                        const SizedBox(width: 12),
263
+                        Text(
264
+                          '用量: ${bill.usage.toStringAsFixed(1)} m³',
265
+                          style: TextStyle(
266
+                              fontSize: 11, color: Colors.grey.shade500),
267
+                        ),
268
+                      ],
269
+                    ),
270
+                  ],
271
+                ),
272
+              ),
273
+              Column(
274
+                crossAxisAlignment: CrossAxisAlignment.end,
275
+                children: [
276
+                  Text(
277
+                    '¥${bill.amount.toStringAsFixed(2)}',
278
+                    style: const TextStyle(
279
+                        fontWeight: FontWeight.w700, fontSize: 16),
280
+                  ),
281
+                  const SizedBox(height: 2),
282
+                  Container(
283
+                    padding:
284
+                        const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
285
+                    decoration: BoxDecoration(
286
+                      color: _statusColor.withAlpha(20),
287
+                      borderRadius: BorderRadius.circular(4),
288
+                    ),
289
+                    child: Text(
290
+                      bill.status.label,
291
+                      style: TextStyle(fontSize: 11, color: _statusColor),
292
+                    ),
293
+                  ),
294
+                ],
295
+              ),
296
+            ],
297
+          ),
298
+        ),
299
+      ),
300
+    );
301
+  }
302
+}
303
+
304
+class _BillDetailSheet extends StatelessWidget {
305
+  final BillItem bill;
306
+
307
+  const _BillDetailSheet({required this.bill});
308
+
309
+  Color get _statusColor {
310
+    switch (bill.status) {
311
+      case BillStatus.paid:
312
+        return Colors.green;
313
+      case BillStatus.unpaid:
314
+        return Colors.orange;
315
+      case BillStatus.overdue:
316
+        return Colors.red;
317
+    }
318
+  }
319
+
320
+  @override
321
+  Widget build(BuildContext context) {
322
+    return Padding(
323
+      padding: const EdgeInsets.all(20),
324
+      child: Column(
325
+        mainAxisSize: MainAxisSize.min,
326
+        crossAxisAlignment: CrossAxisAlignment.start,
327
+        children: [
328
+          Center(
329
+            child: Container(
330
+              width: 40,
331
+              height: 4,
332
+              decoration: BoxDecoration(
333
+                color: Colors.grey.shade300,
334
+                borderRadius: BorderRadius.circular(2),
335
+              ),
336
+            ),
337
+          ),
338
+          const SizedBox(height: 20),
339
+          Row(
340
+            children: [
341
+              Text('账单详情', style: Theme.of(context).textTheme.titleLarge),
342
+              const Spacer(),
343
+              Container(
344
+                padding:
345
+                    const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
346
+                decoration: BoxDecoration(
347
+                  color: _statusColor.withAlpha(20),
348
+                  borderRadius: BorderRadius.circular(6),
349
+                ),
350
+                child: Text(bill.status.label,
351
+                    style: TextStyle(color: _statusColor)),
352
+              ),
353
+            ],
354
+          ),
355
+          const Divider(height: 24),
356
+          _DetailRow(label: '账单编号', value: bill.id),
357
+          _DetailRow(label: '用户姓名', value: bill.customerName),
358
+          _DetailRow(label: '用户地址', value: bill.customerAddress),
359
+          _DetailRow(label: '水表号', value: bill.meterId),
360
+          _DetailRow(label: '账单月份', value: bill.period),
361
+          _DetailRow(
362
+              label: '上次读数',
363
+              value: '${bill.previousReading.toStringAsFixed(1)} m³'),
364
+          _DetailRow(
365
+              label: '本次读数',
366
+              value: '${bill.currentReading.toStringAsFixed(1)} m³'),
367
+          _DetailRow(
368
+              label: '用水量', value: '${bill.usage.toStringAsFixed(1)} m³'),
369
+          _DetailRow(
370
+              label: '单价', value: '¥${bill.unitPrice.toStringAsFixed(2)}/m³'),
371
+          _DetailRow(
372
+              label: '应缴金额',
373
+              value: '¥${bill.amount.toStringAsFixed(2)}',
374
+              highlight: true),
375
+          _DetailRow(
376
+              label: '缴费截止',
377
+              value:
378
+                  '${bill.dueDate.year}-${bill.dueDate.month.toString().padLeft(2, '0')}-${bill.dueDate.day.toString().padLeft(2, '0')}'),
379
+          if (bill.payTime != null)
380
+            _DetailRow(
381
+                label: '缴费时间',
382
+                value:
383
+                    '${bill.payTime!.month}/${bill.payTime!.day} ${bill.payTime!.hour}:${bill.payTime!.minute.toString().padLeft(2, '0')}'),
384
+          if (bill.payMethod != null)
385
+            _DetailRow(label: '支付方式', value: bill.payMethod!.label),
386
+          const SizedBox(height: 16),
387
+        ],
388
+      ),
389
+    );
390
+  }
391
+}
392
+
393
+class _DetailRow extends StatelessWidget {
394
+  final String label;
395
+  final String value;
396
+  final bool highlight;
397
+
398
+  const _DetailRow({
399
+    required this.label,
400
+    required this.value,
401
+    this.highlight = false,
402
+  });
403
+
404
+  @override
405
+  Widget build(BuildContext context) {
406
+    return Padding(
407
+      padding: const EdgeInsets.symmetric(vertical: 4),
408
+      child: Row(
409
+        children: [
410
+          SizedBox(
411
+            width: 80,
412
+            child: Text(label,
413
+                style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
414
+          ),
415
+          Expanded(
416
+            child: Text(
417
+              value,
418
+              style: TextStyle(
419
+                fontSize: 13,
420
+                fontWeight: highlight ? FontWeight.w700 : FontWeight.normal,
421
+                color: highlight
422
+                    ? Theme.of(context).colorScheme.primary
423
+                    : Colors.black87,
424
+              ),
425
+            ),
426
+          ),
427
+        ],
428
+      ),
429
+    );
430
+  }
431
+}
432
+
433
+class _SummaryItem extends StatelessWidget {
434
+  final String label;
435
+  final String value;
436
+  final Color color;
437
+
438
+  const _SummaryItem({
439
+    required this.label,
440
+    required this.value,
441
+    required this.color,
442
+  });
443
+
444
+  @override
445
+  Widget build(BuildContext context) {
446
+    return Column(
447
+      mainAxisSize: MainAxisSize.min,
448
+      children: [
449
+        Text(value,
450
+            style: TextStyle(
451
+                fontSize: 14, fontWeight: FontWeight.w600, color: color)),
452
+        const SizedBox(height: 2),
453
+        Text(label, style: TextStyle(fontSize: 11, color: Colors.grey.shade600)),
454
+      ],
455
+    );
456
+  }
457
+}
458
+
459
+class _FilterChip extends StatelessWidget {
460
+  final String label;
461
+  final bool selected;
462
+  final Color? color;
463
+  final ValueChanged<bool> onSelected;
464
+
465
+  const _FilterChip({
466
+    required this.label,
467
+    required this.selected,
468
+    this.color,
469
+    required this.onSelected,
470
+  });
471
+
472
+  @override
473
+  Widget build(BuildContext context) {
474
+    final activeColor = color ?? Theme.of(context).colorScheme.primary;
475
+    return ChoiceChip(
476
+      label: Text(label),
477
+      selected: selected,
478
+      selectedColor: activeColor.withAlpha(30),
479
+      labelStyle: TextStyle(
480
+        color: selected ? activeColor : Colors.grey,
481
+        fontSize: 13,
482
+      ),
483
+      side: BorderSide(
484
+        color: selected ? activeColor : Colors.grey.shade300,
485
+      ),
486
+      onSelected: onSelected,
487
+    );
488
+  }
489
+}

+ 444
- 0
mobile/lib/pages/revenue/meter_reading_page.dart Просмотреть файл

@@ -0,0 +1,444 @@
1
+import 'dart:async';
2
+import 'package:flutter/material.dart';
3
+import '../../services/revenue_service.dart';
4
+
5
+/// 抄表录入页面
6
+class MeterReadingPage extends StatefulWidget {
7
+  const MeterReadingPage({super.key});
8
+
9
+  @override
10
+  State<MeterReadingPage> createState() => _MeterReadingPageState();
11
+}
12
+
13
+class _MeterReadingPageState extends State<MeterReadingPage> {
14
+  final RevenueService _service = RevenueService.instance;
15
+  final _searchController = TextEditingController();
16
+  Timer? _debounce;
17
+  List<CustomerInfo> _customers = [];
18
+  bool _searching = false;
19
+  CustomerInfo? _selectedCustomer;
20
+
21
+  // 录入表单
22
+  final _readingController = TextEditingController();
23
+  final _remarkController = TextEditingController();
24
+  final List<String> _photos = [];
25
+  bool _submitting = false;
26
+
27
+  @override
28
+  void initState() {
29
+    super.initState();
30
+    _loadInitialCustomers();
31
+  }
32
+
33
+  @override
34
+  void dispose() {
35
+    _searchController.dispose();
36
+    _readingController.dispose();
37
+    _remarkController.dispose();
38
+    _debounce?.cancel();
39
+    super.dispose();
40
+  }
41
+
42
+  Future<void> _loadInitialCustomers() async {
43
+    setState(() => _searching = true);
44
+    final customers = await _service.searchCustomers('');
45
+    if (mounted) {
46
+      setState(() {
47
+        _customers = customers;
48
+        _searching = false;
49
+      });
50
+    }
51
+  }
52
+
53
+  void _onSearchChanged(String query) {
54
+    _debounce?.cancel();
55
+    _debounce = Timer(const Duration(milliseconds: 500), () async {
56
+      setState(() => _searching = true);
57
+      final results = await _service.searchCustomers(query);
58
+      if (mounted) {
59
+        setState(() {
60
+          _customers = results;
61
+          _searching = false;
62
+        });
63
+      }
64
+    });
65
+  }
66
+
67
+  @override
68
+  Widget build(BuildContext context) {
69
+    return Scaffold(
70
+      appBar: AppBar(
71
+        title: const Text('抄表录入'),
72
+      ),
73
+      body: _selectedCustomer != null
74
+          ? _buildReadingForm()
75
+          : _buildCustomerList(),
76
+    );
77
+  }
78
+
79
+  Widget _buildCustomerList() {
80
+    return Column(
81
+      children: [
82
+        // 搜索栏
83
+        Padding(
84
+          padding: const EdgeInsets.all(16),
85
+          child: TextField(
86
+            controller: _searchController,
87
+            decoration: InputDecoration(
88
+              hintText: '搜索用户姓名、表号或地址',
89
+              prefixIcon: const Icon(Icons.search),
90
+              suffixIcon: _searchController.text.isNotEmpty
91
+                  ? IconButton(
92
+                      icon: const Icon(Icons.clear),
93
+                      onPressed: () {
94
+                        _searchController.clear();
95
+                        _loadInitialCustomers();
96
+                      },
97
+                    )
98
+                  : null,
99
+              border: OutlineInputBorder(
100
+                borderRadius: BorderRadius.circular(12),
101
+              ),
102
+              filled: true,
103
+            ),
104
+            onChanged: _onSearchChanged,
105
+          ),
106
+        ),
107
+        // 用户列表
108
+        Expanded(
109
+          child: _searching
110
+              ? const Center(child: CircularProgressIndicator())
111
+              : _customers.isEmpty
112
+                  ? Center(
113
+                      child: Column(
114
+                        mainAxisSize: MainAxisSize.min,
115
+                        children: [
116
+                          Icon(Icons.search_off,
117
+                              size: 64, color: Colors.grey.shade300),
118
+                          const SizedBox(height: 16),
119
+                          const Text('未找到匹配用户',
120
+                              style: TextStyle(color: Colors.grey)),
121
+                        ],
122
+                      ),
123
+                    )
124
+                  : ListView.builder(
125
+                      padding: const EdgeInsets.symmetric(horizontal: 12),
126
+                      itemCount: _customers.length,
127
+                      itemBuilder: (ctx, i) => _CustomerTile(
128
+                        customer: _customers[i],
129
+                        onTap: () => setState(
130
+                            () => _selectedCustomer = _customers[i]),
131
+                      ),
132
+                    ),
133
+        ),
134
+      ],
135
+    );
136
+  }
137
+
138
+  Widget _buildReadingForm() {
139
+    final customer = _selectedCustomer!;
140
+    return SingleChildScrollView(
141
+      padding: const EdgeInsets.all(16),
142
+      child: Column(
143
+        crossAxisAlignment: CrossAxisAlignment.start,
144
+        children: [
145
+          // 用户信息卡
146
+          Card(
147
+            shape: RoundedRectangleBorder(
148
+                borderRadius: BorderRadius.circular(12)),
149
+            color: Colors.blue.shade50,
150
+            child: Padding(
151
+              padding: const EdgeInsets.all(16),
152
+              child: Column(
153
+                crossAxisAlignment: CrossAxisAlignment.start,
154
+                children: [
155
+                  Row(
156
+                    children: [
157
+                      const Icon(Icons.person, color: Colors.blue),
158
+                      const SizedBox(width: 8),
159
+                      Expanded(
160
+                        child: Text(
161
+                          customer.name,
162
+                          style: const TextStyle(
163
+                              fontSize: 16, fontWeight: FontWeight.w600),
164
+                        ),
165
+                      ),
166
+                      TextButton.icon(
167
+                        onPressed: () => setState(() {
168
+                          _selectedCustomer = null;
169
+                          _readingController.clear();
170
+                          _remarkController.clear();
171
+                          _photos.clear();
172
+                        }),
173
+                        icon: const Icon(Icons.arrow_back, size: 16),
174
+                        label: const Text('切换用户'),
175
+                      ),
176
+                    ],
177
+                  ),
178
+                  const Divider(),
179
+                  _FormInfoRow(label: '地址', value: customer.address),
180
+                  _FormInfoRow(label: '水表号', value: customer.meterId),
181
+                  _FormInfoRow(label: '水表型号', value: customer.meterModel),
182
+                  _FormInfoRow(label: '联系电话', value: customer.phone),
183
+                ],
184
+              ),
185
+            ),
186
+          ),
187
+          const SizedBox(height: 16),
188
+
189
+          // 读数录入
190
+          Card(
191
+            shape: RoundedRectangleBorder(
192
+                borderRadius: BorderRadius.circular(12)),
193
+            child: Padding(
194
+              padding: const EdgeInsets.all(16),
195
+              child: Column(
196
+                crossAxisAlignment: CrossAxisAlignment.start,
197
+                children: [
198
+                  Row(
199
+                    children: [
200
+                      const Icon(Icons.speed,
201
+                          size: 20, color: Colors.orange),
202
+                      const SizedBox(width: 8),
203
+                      Text('表计读数',
204
+                          style: Theme.of(context).textTheme.titleMedium),
205
+                    ],
206
+                  ),
207
+                  const SizedBox(height: 16),
208
+                  TextField(
209
+                    controller: _readingController,
210
+                    keyboardType:
211
+                        const TextInputType.numberWithOptions(decimal: true),
212
+                    decoration: InputDecoration(
213
+                      labelText: '当前读数 (m³)',
214
+                      hintText: '请输入水表当前读数',
215
+                      border: OutlineInputBorder(
216
+                        borderRadius: BorderRadius.circular(8),
217
+                      ),
218
+                      prefixIcon: const Icon(Icons.numbers),
219
+                    ),
220
+                  ),
221
+                  const SizedBox(height: 16),
222
+                  TextField(
223
+                    controller: _remarkController,
224
+                    maxLines: 2,
225
+                    decoration: InputDecoration(
226
+                      labelText: '备注(可选)',
227
+                      hintText: '如水表异常、更换水表等',
228
+                      border: OutlineInputBorder(
229
+                        borderRadius: BorderRadius.circular(8),
230
+                      ),
231
+                    ),
232
+                  ),
233
+                ],
234
+              ),
235
+            ),
236
+          ),
237
+          const SizedBox(height: 16),
238
+
239
+          // 拍照记录
240
+          Card(
241
+            shape: RoundedRectangleBorder(
242
+                borderRadius: BorderRadius.circular(12)),
243
+            child: Padding(
244
+              padding: const EdgeInsets.all(16),
245
+              child: Column(
246
+                crossAxisAlignment: CrossAxisAlignment.start,
247
+                children: [
248
+                  Row(
249
+                    children: [
250
+                      const Icon(Icons.camera_alt,
251
+                          size: 20, color: Colors.purple),
252
+                      const SizedBox(width: 8),
253
+                      Text('拍照记录',
254
+                          style: Theme.of(context).textTheme.titleMedium),
255
+                      const SizedBox(width: 8),
256
+                      Text(
257
+                        '(${_photos.length}/3)',
258
+                        style: TextStyle(
259
+                            fontSize: 12, color: Colors.grey.shade600),
260
+                      ),
261
+                    ],
262
+                  ),
263
+                  const SizedBox(height: 12),
264
+                  Wrap(
265
+                    spacing: 8,
266
+                    runSpacing: 8,
267
+                    children: [
268
+                      ..._photos.asMap().entries.map(
269
+                            (entry) => Stack(
270
+                              children: [
271
+                                Container(
272
+                                  width: 80,
273
+                                  height: 80,
274
+                                  decoration: BoxDecoration(
275
+                                    color: Colors.grey.shade200,
276
+                                    borderRadius: BorderRadius.circular(8),
277
+                                  ),
278
+                                  child: const Icon(Icons.image,
279
+                                      color: Colors.grey, size: 36),
280
+                                ),
281
+                                Positioned(
282
+                                  top: -4,
283
+                                  right: -4,
284
+                                  child: GestureDetector(
285
+                                    onTap: () => setState(
286
+                                        () => _photos.removeAt(entry.key)),
287
+                                    child: Container(
288
+                                      width: 22,
289
+                                      height: 22,
290
+                                      decoration: const BoxDecoration(
291
+                                        color: Colors.red,
292
+                                        shape: BoxShape.circle,
293
+                                      ),
294
+                                      child: const Icon(Icons.close,
295
+                                          size: 14, color: Colors.white),
296
+                                    ),
297
+                                  ),
298
+                                ),
299
+                              ],
300
+                            ),
301
+                          ),
302
+                      if (_photos.length < 3)
303
+                        GestureDetector(
304
+                          onTap: () => setState(() =>
305
+                              _photos.add('meter_photo_${_photos.length + 1}')),
306
+                          child: Container(
307
+                            width: 80,
308
+                            height: 80,
309
+                            decoration: BoxDecoration(
310
+                              border: Border.all(
311
+                                  color: Colors.purple, width: 1.5),
312
+                              borderRadius: BorderRadius.circular(8),
313
+                            ),
314
+                            child: const Icon(Icons.add_a_photo,
315
+                                color: Colors.purple, size: 28),
316
+                          ),
317
+                        ),
318
+                    ],
319
+                  ),
320
+                ],
321
+              ),
322
+            ),
323
+          ),
324
+          const SizedBox(height: 24),
325
+
326
+          // 提交按钮
327
+          SizedBox(
328
+            width: double.infinity,
329
+            child: FilledButton.icon(
330
+              onPressed: _submitting ? null : _submitReading,
331
+              icon: _submitting
332
+                  ? const SizedBox(
333
+                      width: 16,
334
+                      height: 16,
335
+                      child: CircularProgressIndicator(
336
+                          strokeWidth: 2, color: Colors.white))
337
+                  : const Icon(Icons.check, size: 18),
338
+              label: Text(_submitting ? '提交中...' : '提交读数'),
339
+            ),
340
+          ),
341
+        ],
342
+      ),
343
+    );
344
+  }
345
+
346
+  Future<void> _submitReading() async {
347
+    final reading = double.tryParse(_readingController.text);
348
+    if (reading == null || reading <= 0) {
349
+      ScaffoldMessenger.of(context).showSnackBar(
350
+        const SnackBar(
351
+          content: Text('请输入有效的表计读数'),
352
+          backgroundColor: Colors.red,
353
+        ),
354
+      );
355
+      return;
356
+    }
357
+
358
+    setState(() => _submitting = true);
359
+    final success = await _service.submitMeterReading(
360
+      meterId: _selectedCustomer!.meterId,
361
+      reading: reading,
362
+      photoPath: _photos.isEmpty ? null : _photos.first,
363
+      remark: _remarkController.text.isEmpty ? null : _remarkController.text,
364
+    );
365
+    if (mounted) {
366
+      setState(() => _submitting = false);
367
+      if (success) {
368
+        ScaffoldMessenger.of(context).showSnackBar(
369
+          SnackBar(
370
+            content: Text('${_selectedCustomer!.name} 的读数已提交: $reading m³'),
371
+            backgroundColor: Colors.green,
372
+          ),
373
+        );
374
+        setState(() {
375
+          _selectedCustomer = null;
376
+          _readingController.clear();
377
+          _remarkController.clear();
378
+          _photos.clear();
379
+        });
380
+      }
381
+    }
382
+  }
383
+}
384
+
385
+class _CustomerTile extends StatelessWidget {
386
+  final CustomerInfo customer;
387
+  final VoidCallback onTap;
388
+
389
+  const _CustomerTile({required this.customer, required this.onTap});
390
+
391
+  @override
392
+  Widget build(BuildContext context) {
393
+    return Card(
394
+      margin: const EdgeInsets.only(bottom: 8),
395
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
396
+      child: ListTile(
397
+        onTap: onTap,
398
+        shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
399
+        leading: CircleAvatar(
400
+          backgroundColor: Colors.blue.shade50,
401
+          child: Text(customer.name[0],
402
+              style: TextStyle(color: Colors.blue.shade700)),
403
+        ),
404
+        title: Text(customer.name,
405
+            style: const TextStyle(fontWeight: FontWeight.w500)),
406
+        subtitle: Column(
407
+          crossAxisAlignment: CrossAxisAlignment.start,
408
+          children: [
409
+            Text('${customer.address} | ${customer.meterId}',
410
+                style: const TextStyle(fontSize: 12)),
411
+          ],
412
+        ),
413
+        trailing: const Icon(Icons.chevron_right, color: Colors.grey),
414
+      ),
415
+    );
416
+  }
417
+}
418
+
419
+class _FormInfoRow extends StatelessWidget {
420
+  final String label;
421
+  final String value;
422
+
423
+  const _FormInfoRow({required this.label, required this.value});
424
+
425
+  @override
426
+  Widget build(BuildContext context) {
427
+    return Padding(
428
+      padding: const EdgeInsets.symmetric(vertical: 3),
429
+      child: Row(
430
+        children: [
431
+          SizedBox(
432
+            width: 70,
433
+            child: Text(label,
434
+                style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
435
+          ),
436
+          Expanded(
437
+            child: Text(value,
438
+                style: const TextStyle(fontSize: 13)),
439
+          ),
440
+        ],
441
+      ),
442
+    );
443
+  }
444
+}

+ 529
- 0
mobile/lib/pages/revenue/payment_page.dart Просмотреть файл

@@ -0,0 +1,529 @@
1
+import 'package:flutter/material.dart';
2
+import '../../services/revenue_service.dart';
3
+
4
+/// 缴费记录页面
5
+class PaymentPage extends StatefulWidget {
6
+  const PaymentPage({super.key});
7
+
8
+  @override
9
+  State<PaymentPage> createState() => _PaymentPageState();
10
+}
11
+
12
+class _PaymentPageState extends State<PaymentPage>
13
+    with SingleTickerProviderStateMixin {
14
+  final RevenueService _service = RevenueService.instance;
15
+  late TabController _tabController;
16
+
17
+  List<PaymentRecord> _paidRecords = [];
18
+  List<PaymentRecord> _pendingRecords = [];
19
+  List<PaymentRecord> _overdueRecords = [];
20
+  bool _loading = true;
21
+
22
+  @override
23
+  void initState() {
24
+    super.initState();
25
+    _tabController = TabController(length: 3, vsync: this);
26
+    _loadData();
27
+  }
28
+
29
+  @override
30
+  void dispose() {
31
+    _tabController.dispose();
32
+    super.dispose();
33
+  }
34
+
35
+  Future<void> _loadData() async {
36
+    setState(() => _loading = true);
37
+    try {
38
+      final paid =
39
+          await _service.getPaymentRecords(status: PaymentStatus.success);
40
+      final pending =
41
+          await _service.getPaymentRecords(status: PaymentStatus.pending);
42
+      final overdue =
43
+          await _service.getPaymentRecords(status: PaymentStatus.overdue);
44
+      if (mounted) {
45
+        setState(() {
46
+          _paidRecords = paid;
47
+          _pendingRecords = pending;
48
+          _overdueRecords = overdue;
49
+          _loading = false;
50
+        });
51
+      }
52
+    } catch (e) {
53
+      if (mounted) setState(() => _loading = false);
54
+    }
55
+  }
56
+
57
+  @override
58
+  Widget build(BuildContext context) {
59
+    final theme = Theme.of(context);
60
+
61
+    return Scaffold(
62
+      appBar: AppBar(
63
+        title: const Text('缴费记录'),
64
+        bottom: TabBar(
65
+          controller: _tabController,
66
+          labelColor: theme.colorScheme.primary,
67
+          unselectedLabelColor: Colors.grey,
68
+          indicatorColor: theme.colorScheme.primary,
69
+          tabs: [
70
+            Tab(
71
+              child: Row(
72
+                mainAxisSize: MainAxisSize.min,
73
+                children: [
74
+                  const Icon(Icons.check_circle, size: 18),
75
+                  const SizedBox(width: 4),
76
+                  Text('已缴 (${_paidRecords.length})'),
77
+                ],
78
+              ),
79
+            ),
80
+            Tab(
81
+              child: Row(
82
+                mainAxisSize: MainAxisSize.min,
83
+                children: [
84
+                  const Icon(Icons.schedule, size: 18),
85
+                  const SizedBox(width: 4),
86
+                  Text('待缴 (${_pendingRecords.length})'),
87
+                ],
88
+              ),
89
+            ),
90
+            Tab(
91
+              child: Row(
92
+                mainAxisSize: MainAxisSize.min,
93
+                children: [
94
+                  const Icon(Icons.error, size: 18),
95
+                  const SizedBox(width: 4),
96
+                  Text('欠费 (${_overdueRecords.length})'),
97
+                ],
98
+              ),
99
+            ),
100
+          ],
101
+        ),
102
+      ),
103
+      body: _loading
104
+          ? const Center(child: CircularProgressIndicator())
105
+          : RefreshIndicator(
106
+              onRefresh: _loadData,
107
+              child: TabBarView(
108
+                controller: _tabController,
109
+                children: [
110
+                  _PaymentListView(
111
+                    records: _paidRecords,
112
+                    type: PaymentStatus.success,
113
+                    onPay: null,
114
+                  ),
115
+                  _PaymentListView(
116
+                    records: _pendingRecords,
117
+                    type: PaymentStatus.pending,
118
+                    onPay: _showPayDialog,
119
+                  ),
120
+                  _PaymentListView(
121
+                    records: _overdueRecords,
122
+                    type: PaymentStatus.overdue,
123
+                    onPay: _showPayDialog,
124
+                  ),
125
+                ],
126
+              ),
127
+            ),
128
+    );
129
+  }
130
+
131
+  void _showPayDialog(PaymentRecord record) {
132
+    showModalBottomSheet(
133
+      context: context,
134
+      shape: const RoundedRectangleBorder(
135
+        borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
136
+      ),
137
+      builder: (ctx) => _PayDialog(
138
+        record: record,
139
+        onPay: (method) => _doPay(record, method),
140
+      ),
141
+    );
142
+  }
143
+
144
+  Future<void> _doPay(PaymentRecord record, PayMethod method) async {
145
+    Navigator.pop(context); // 关闭弹窗
146
+
147
+    // 显示加载
148
+    showDialog(
149
+      context: context,
150
+      barrierDismissible: false,
151
+      builder: (_) => const Center(
152
+        child: Card(
153
+          child: Padding(
154
+            padding: EdgeInsets.all(24),
155
+            child: Column(
156
+              mainAxisSize: MainAxisSize.min,
157
+              children: [
158
+                CircularProgressIndicator(),
159
+                SizedBox(height: 16),
160
+                Text('正在处理缴费...'),
161
+              ],
162
+            ),
163
+          ),
164
+        ),
165
+      ),
166
+    );
167
+
168
+    final result = await _service.payBill(billId: record.id, method: method);
169
+    if (mounted) {
170
+      Navigator.pop(context); // 关闭加载
171
+      if (result.success) {
172
+        ScaffoldMessenger.of(context).showSnackBar(
173
+          SnackBar(
174
+            content: Text(
175
+                '${record.customerName} 缴费成功! 交易号: ${result.transactionId}'),
176
+            backgroundColor: Colors.green,
177
+          ),
178
+        );
179
+        _loadData();
180
+      } else {
181
+        ScaffoldMessenger.of(context).showSnackBar(
182
+          SnackBar(
183
+            content: Text('缴费失败: ${result.message}'),
184
+            backgroundColor: Colors.red,
185
+          ),
186
+        );
187
+      }
188
+    }
189
+  }
190
+}
191
+
192
+class _PaymentListView extends StatelessWidget {
193
+  final List<PaymentRecord> records;
194
+  final PaymentStatus type;
195
+  final void Function(PaymentRecord)? onPay;
196
+
197
+  const _PaymentListView({
198
+    required this.records,
199
+    required this.type,
200
+    this.onPay,
201
+  });
202
+
203
+  @override
204
+  Widget build(BuildContext context) {
205
+    if (records.isEmpty) {
206
+      return ListView(
207
+        children: [
208
+          SizedBox(
209
+            height: MediaQuery.of(context).size.height * 0.6,
210
+            child: Center(
211
+              child: Column(
212
+                mainAxisSize: MainAxisSize.min,
213
+                children: [
214
+                  Icon(
215
+                    type == PaymentStatus.success
216
+                        ? Icons.check_circle_outline
217
+                        : type == PaymentStatus.pending
218
+                            ? Icons.schedule
219
+                            : Icons.error_outline,
220
+                    size: 64,
221
+                    color: Colors.grey.shade300,
222
+                  ),
223
+                  const SizedBox(height: 16),
224
+                  Text('暂无${type.label}记录',
225
+                      style: const TextStyle(color: Colors.grey, fontSize: 16)),
226
+                ],
227
+              ),
228
+            ),
229
+          ),
230
+        ],
231
+      );
232
+    }
233
+
234
+    return ListView.builder(
235
+      padding: const EdgeInsets.all(12),
236
+      itemCount: records.length,
237
+      itemBuilder: (ctx, i) => _PaymentCard(
238
+        record: records[i],
239
+        onPay: onPay != null ? () => onPay!(records[i]) : null,
240
+      ),
241
+    );
242
+  }
243
+}
244
+
245
+class _PaymentCard extends StatelessWidget {
246
+  final PaymentRecord record;
247
+  final VoidCallback? onPay;
248
+
249
+  const _PaymentCard({required this.record, this.onPay});
250
+
251
+  Color get _statusColor {
252
+    switch (record.status) {
253
+      case PaymentStatus.success:
254
+        return Colors.green;
255
+      case PaymentStatus.pending:
256
+        return Colors.orange;
257
+      case PaymentStatus.overdue:
258
+        return Colors.red;
259
+    }
260
+  }
261
+
262
+  IconData get _statusIcon {
263
+    switch (record.status) {
264
+      case PaymentStatus.success:
265
+        return Icons.check_circle;
266
+      case PaymentStatus.pending:
267
+        return Icons.schedule;
268
+      case PaymentStatus.overdue:
269
+        return Icons.error;
270
+    }
271
+  }
272
+
273
+  @override
274
+  Widget build(BuildContext context) {
275
+    return Card(
276
+      margin: const EdgeInsets.only(bottom: 10),
277
+      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
278
+      child: Padding(
279
+        padding: const EdgeInsets.all(14),
280
+        child: Column(
281
+          crossAxisAlignment: CrossAxisAlignment.start,
282
+          children: [
283
+            Row(
284
+              children: [
285
+                Icon(_statusIcon, color: _statusColor, size: 20),
286
+                const SizedBox(width: 8),
287
+                Expanded(
288
+                  child: Column(
289
+                    crossAxisAlignment: CrossAxisAlignment.start,
290
+                    children: [
291
+                      Text(
292
+                        record.customerName,
293
+                        style: const TextStyle(
294
+                            fontWeight: FontWeight.w600, fontSize: 14),
295
+                      ),
296
+                      Text(
297
+                        record.customerAddress,
298
+                        style: TextStyle(
299
+                            fontSize: 12, color: Colors.grey.shade600),
300
+                      ),
301
+                    ],
302
+                  ),
303
+                ),
304
+                Column(
305
+                  crossAxisAlignment: CrossAxisAlignment.end,
306
+                  children: [
307
+                    Text(
308
+                      '¥${record.amount.toStringAsFixed(2)}',
309
+                      style: const TextStyle(
310
+                          fontWeight: FontWeight.w700, fontSize: 16),
311
+                    ),
312
+                    Container(
313
+                      padding: const EdgeInsets.symmetric(
314
+                          horizontal: 6, vertical: 1),
315
+                      decoration: BoxDecoration(
316
+                        color: _statusColor.withAlpha(20),
317
+                        borderRadius: BorderRadius.circular(4),
318
+                      ),
319
+                      child: Text(
320
+                        record.status.label,
321
+                        style: TextStyle(fontSize: 11, color: _statusColor),
322
+                      ),
323
+                    ),
324
+                  ],
325
+                ),
326
+              ],
327
+            ),
328
+            const Divider(height: 16),
329
+            Wrap(
330
+              spacing: 16,
331
+              runSpacing: 4,
332
+              children: [
333
+                _MetaItem(icon: Icons.tag, text: record.meterId),
334
+                _MetaItem(icon: Icons.calendar_today, text: record.period),
335
+                if (record.payTime != null)
336
+                  _MetaItem(
337
+                      icon: Icons.access_time, text: record.payTimeStr),
338
+                if (record.method != null)
339
+                  _MetaItem(
340
+                      icon: Icons.payment, text: record.method!.label),
341
+                if (record.operator != null)
342
+                  _MetaItem(icon: Icons.person, text: record.operator!),
343
+              ],
344
+            ),
345
+            if (onPay != null) ...[
346
+              const SizedBox(height: 12),
347
+              Row(
348
+                mainAxisAlignment: MainAxisAlignment.end,
349
+                children: [
350
+                  FilledButton.icon(
351
+                    onPressed: onPay,
352
+                    icon: const Icon(Icons.payment, size: 16),
353
+                    label: const Text('立即缴费'),
354
+                  ),
355
+                ],
356
+              ),
357
+            ],
358
+          ],
359
+        ),
360
+      ),
361
+    );
362
+  }
363
+}
364
+
365
+class _MetaItem extends StatelessWidget {
366
+  final IconData icon;
367
+  final String text;
368
+
369
+  const _MetaItem({required this.icon, required this.text});
370
+
371
+  @override
372
+  Widget build(BuildContext context) {
373
+    return Row(
374
+      mainAxisSize: MainAxisSize.min,
375
+      children: [
376
+        Icon(icon, size: 14, color: Colors.grey),
377
+        const SizedBox(width: 4),
378
+        Text(text,
379
+            style: TextStyle(fontSize: 12, color: Colors.grey.shade700)),
380
+      ],
381
+    );
382
+  }
383
+}
384
+
385
+class _PayDialog extends StatefulWidget {
386
+  final PaymentRecord record;
387
+  final void Function(PayMethod method) onPay;
388
+
389
+  const _PayDialog({required this.record, required this.onPay});
390
+
391
+  @override
392
+  State<_PayDialog> createState() => _PayDialogState();
393
+}
394
+
395
+class _PayDialogState extends State<_PayDialog> {
396
+  PayMethod _selectedMethod = PayMethod.wechat;
397
+
398
+  @override
399
+  Widget build(BuildContext context) {
400
+    return Padding(
401
+      padding: const EdgeInsets.all(20),
402
+      child: Column(
403
+        mainAxisSize: MainAxisSize.min,
404
+        crossAxisAlignment: CrossAxisAlignment.start,
405
+        children: [
406
+          Center(
407
+            child: Container(
408
+              width: 40,
409
+              height: 4,
410
+              decoration: BoxDecoration(
411
+                color: Colors.grey.shade300,
412
+                borderRadius: BorderRadius.circular(2),
413
+              ),
414
+            ),
415
+          ),
416
+          const SizedBox(height: 20),
417
+          Text('确认缴费', style: Theme.of(context).textTheme.titleLarge),
418
+          const SizedBox(height: 16),
419
+          Card(
420
+            color: Colors.grey.shade50,
421
+            child: Padding(
422
+              padding: const EdgeInsets.all(12),
423
+              child: Column(
424
+                children: [
425
+                  _InfoLine(
426
+                      label: '用户', value: widget.record.customerName),
427
+                  _InfoLine(
428
+                      label: '地址', value: widget.record.customerAddress),
429
+                  _InfoLine(label: '账单月份', value: widget.record.period),
430
+                  _InfoLine(
431
+                    label: '缴费金额',
432
+                    value: '¥${widget.record.amount.toStringAsFixed(2)}',
433
+                    highlight: true,
434
+                  ),
435
+                ],
436
+              ),
437
+            ),
438
+          ),
439
+          const SizedBox(height: 16),
440
+          const Text('选择支付方式',
441
+              style: TextStyle(fontWeight: FontWeight.w500)),
442
+          const SizedBox(height: 8),
443
+          ...PayMethod.values.map((m) {
444
+            return RadioListTile<PayMethod>(
445
+              value: m,
446
+              groupValue: _selectedMethod,
447
+              onChanged: (v) => setState(() => _selectedMethod = v!),
448
+              title: Row(
449
+                children: [
450
+                  Icon(
451
+                    m == PayMethod.wechat
452
+                        ? Icons.wechat
453
+                        : m == PayMethod.alipay
454
+                            ? Icons.alipay
455
+                            : m == PayMethod.bankTransfer
456
+                                ? Icons.account_balance
457
+                                : m == PayMethod.cash
458
+                                    ? Icons.money
459
+                                    : Icons.more_horiz,
460
+                    size: 20,
461
+                    color: m == PayMethod.wechat
462
+                        ? Colors.green
463
+                        : m == PayMethod.alipay
464
+                            ? Colors.blue
465
+                            : Colors.grey,
466
+                  ),
467
+                  const SizedBox(width: 8),
468
+                  Text(m.label),
469
+                ],
470
+              ),
471
+              dense: true,
472
+            );
473
+          }),
474
+          const SizedBox(height: 16),
475
+          SizedBox(
476
+            width: double.infinity,
477
+            child: FilledButton.icon(
478
+              onPressed: () => widget.onPay(_selectedMethod),
479
+              icon: const Icon(Icons.check, size: 18),
480
+              label: Text(
481
+                  '确认缴费 ¥${widget.record.amount.toStringAsFixed(2)}'),
482
+            ),
483
+          ),
484
+          const SizedBox(height: 8),
485
+        ],
486
+      ),
487
+    );
488
+  }
489
+}
490
+
491
+class _InfoLine extends StatelessWidget {
492
+  final String label;
493
+  final String value;
494
+  final bool highlight;
495
+
496
+  const _InfoLine({
497
+    required this.label,
498
+    required this.value,
499
+    this.highlight = false,
500
+  });
501
+
502
+  @override
503
+  Widget build(BuildContext context) {
504
+    return Padding(
505
+      padding: const EdgeInsets.symmetric(vertical: 3),
506
+      child: Row(
507
+        children: [
508
+          SizedBox(
509
+            width: 70,
510
+            child: Text(label,
511
+                style: TextStyle(fontSize: 13, color: Colors.grey.shade600)),
512
+          ),
513
+          Expanded(
514
+            child: Text(
515
+              value,
516
+              style: TextStyle(
517
+                fontSize: 13,
518
+                fontWeight: highlight ? FontWeight.w700 : FontWeight.normal,
519
+                color: highlight
520
+                    ? Theme.of(context).colorScheme.primary
521
+                    : Colors.black87,
522
+              ),
523
+            ),
524
+          ),
525
+        ],
526
+      ),
527
+    );
528
+  }
529
+}

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

@@ -0,0 +1,395 @@
1
+/// 巡检管理相关 API 调用服务
2
+/// 当前使用 mock 数据,后端 API 就绪后替换为真实请求
3
+class PatrolService {
4
+  PatrolService._internal();
5
+  static final PatrolService instance = PatrolService._internal();
6
+  factory PatrolService() => instance;
7
+
8
+  // ==================== Mock 数据 ====================
9
+
10
+  /// 获取巡检任务列表
11
+  Future<List<PatrolTask>> getTaskList({PatrolTaskStatus? status}) async {
12
+    await Future.delayed(const Duration(milliseconds: 800));
13
+    final tasks = [
14
+      PatrolTask(
15
+        id: 'XJ-2024-001',
16
+        routeName: '城东片区巡检路线',
17
+        date: DateTime(2024, 6, 14),
18
+        totalPoints: 8,
19
+        completedPoints: 0,
20
+        priority: PatrolPriority.high,
21
+        status: PatrolTaskStatus.pending,
22
+        assignee: '张建国',
23
+        description: '城东片区主干管网及加压站设备巡检,包含8个巡检点',
24
+        points: _mockPointsEast,
25
+      ),
26
+      PatrolTask(
27
+        id: 'XJ-2024-002',
28
+        routeName: '城北加压站巡检',
29
+        date: DateTime(2024, 6, 14),
30
+        totalPoints: 4,
31
+        completedPoints: 0,
32
+        priority: PatrolPriority.medium,
33
+        status: PatrolTaskStatus.pending,
34
+        assignee: '李明',
35
+        description: '城北加压站设备日检,包含泵站、配电柜、压力表等设备',
36
+        points: _mockPointsNorth,
37
+      ),
38
+      PatrolTask(
39
+        id: 'XJ-2024-003',
40
+        routeName: '水厂设备日检',
41
+        date: DateTime(2024, 6, 15),
42
+        totalPoints: 12,
43
+        completedPoints: 0,
44
+        priority: PatrolPriority.high,
45
+        status: PatrolTaskStatus.pending,
46
+        assignee: '王芳',
47
+        description: '水厂全部设备日检,包含取水泵房、净水设备、加药间、消毒间等',
48
+        points: _mockPointsFactory,
49
+      ),
50
+      PatrolTask(
51
+        id: 'XJ-2024-004',
52
+        routeName: '城西管网巡检',
53
+        date: DateTime(2024, 6, 14),
54
+        totalPoints: 6,
55
+        completedPoints: 3,
56
+        priority: PatrolPriority.medium,
57
+        status: PatrolTaskStatus.ongoing,
58
+        assignee: '赵强',
59
+        description: '城西片区管网巡检,重点检查阀门井和消防栓',
60
+        points: _mockPointsWest,
61
+      ),
62
+      PatrolTask(
63
+        id: 'XJ-2024-005',
64
+        routeName: '城南片区周检',
65
+        date: DateTime(2024, 6, 13),
66
+        totalPoints: 10,
67
+        completedPoints: 10,
68
+        priority: PatrolPriority.low,
69
+        status: PatrolTaskStatus.completed,
70
+        assignee: '刘伟',
71
+        description: '城南片区管网周检',
72
+        points: _mockPointsSouth,
73
+        result: PatrolResult.normal,
74
+        reportTime: DateTime(2024, 6, 13, 16, 30),
75
+      ),
76
+      PatrolTask(
77
+        id: 'XJ-2024-006',
78
+        routeName: '水厂设备周检',
79
+        date: DateTime(2024, 6, 13),
80
+        totalPoints: 12,
81
+        completedPoints: 12,
82
+        priority: PatrolPriority.high,
83
+        status: PatrolTaskStatus.completed,
84
+        assignee: '张建国',
85
+        description: '水厂全部设备周检',
86
+        points: _mockPointsFactory,
87
+        result: PatrolResult.issueFound,
88
+        reportTime: DateTime(2024, 6, 13, 15, 0),
89
+        issues: '2号加药泵存在异响,已记录待维修',
90
+      ),
91
+    ];
92
+    if (status != null) {
93
+      return tasks.where((t) => t.status == status).toList();
94
+    }
95
+    return tasks;
96
+  }
97
+
98
+  /// 获取任务详情
99
+  Future<PatrolTask?> getTaskDetail(String taskId) async {
100
+    await Future.delayed(const Duration(milliseconds: 500));
101
+    final tasks = await getTaskList();
102
+    try {
103
+      return tasks.firstWhere((t) => t.id == taskId);
104
+    } catch (_) {
105
+      return null;
106
+    }
107
+  }
108
+
109
+  /// 开始巡检任务
110
+  Future<bool> startTask(String taskId) async {
111
+    await Future.delayed(const Duration(milliseconds: 600));
112
+    return true;
113
+  }
114
+
115
+  /// 提交巡检点上报
116
+  Future<bool> submitPointReport({
117
+    required String taskId,
118
+    required String pointId,
119
+    required PatrolPointStatus status,
120
+    String? remark,
121
+    List<String>? photoPaths,
122
+  }) async {
123
+    await Future.delayed(const Duration(milliseconds: 700));
124
+    return true;
125
+  }
126
+
127
+  /// 完成巡检任务
128
+  Future<bool> completeTask({
129
+    required String taskId,
130
+    required PatrolResult result,
131
+    String? summary,
132
+  }) async {
133
+    await Future.delayed(const Duration(milliseconds: 800));
134
+    return true;
135
+  }
136
+
137
+  /// 获取GPS轨迹点列表
138
+  Future<List<TrackPoint>> getTrackPoints(String taskId) async {
139
+    await Future.delayed(const Duration(milliseconds: 500));
140
+    return [
141
+      TrackPoint(
142
+        id: 'TP001',
143
+        lat: 30.2741,
144
+        lng: 120.1551,
145
+        altitude: 15.2,
146
+        speed: 0,
147
+        timestamp: DateTime(2024, 6, 14, 8, 0),
148
+        address: '水厂大门',
149
+      ),
150
+      TrackPoint(
151
+        id: 'TP002',
152
+        lat: 30.2748,
153
+        lng: 120.1562,
154
+        altitude: 14.8,
155
+        speed: 3.2,
156
+        timestamp: DateTime(2024, 6, 14, 8, 5),
157
+        address: '城东路段阀门井',
158
+      ),
159
+      TrackPoint(
160
+        id: 'TP003',
161
+        lat: 30.2755,
162
+        lng: 120.1578,
163
+        altitude: 15.0,
164
+        speed: 2.8,
165
+        timestamp: DateTime(2024, 6, 14, 8, 12),
166
+        address: '阳光小区加压站',
167
+      ),
168
+      TrackPoint(
169
+        id: 'TP004',
170
+        lat: 30.2763,
171
+        lng: 120.1590,
172
+        altitude: 14.5,
173
+        speed: 0,
174
+        timestamp: DateTime(2024, 6, 14, 8, 20),
175
+        address: '城东消防栓-03',
176
+      ),
177
+      TrackPoint(
178
+        id: 'TP005',
179
+        lat: 30.2770,
180
+        lng: 120.1605,
181
+        altitude: 15.3,
182
+        speed: 4.1,
183
+        timestamp: DateTime(2024, 6, 14, 8, 28),
184
+        address: '东方名城阀门井',
185
+      ),
186
+      TrackPoint(
187
+        id: 'TP006',
188
+        lat: 30.2778,
189
+        lng: 120.1618,
190
+        altitude: 14.9,
191
+        speed: 0,
192
+        timestamp: DateTime(2024, 6, 14, 8, 35),
193
+        address: '城东片区末端压力表',
194
+      ),
195
+    ];
196
+  }
197
+
198
+  // ==================== Mock 巡检点数据 ====================
199
+
200
+  static final List<PatrolPoint> _mockPointsEast = [
201
+    PatrolPoint(id: 'PP001', name: '城东路段阀门井', type: PatrolPointType.valve, lat: 30.2748, lng: 120.1562, order: 1),
202
+    PatrolPoint(id: 'PP002', name: '阳光小区加压站', type: PatrolPointType.pumpStation, lat: 30.2755, lng: 120.1578, order: 2),
203
+    PatrolPoint(id: 'PP003', name: '城东消防栓-03', type: PatrolPointType.hydrant, lat: 30.2763, lng: 120.1590, order: 3),
204
+    PatrolPoint(id: 'PP004', name: '东方名城阀门井', type: PatrolPointType.valve, lat: 30.2770, lng: 120.1605, order: 4),
205
+    PatrolPoint(id: 'PP005', name: '城东片区末端压力表', type: PatrolPointType.meter, lat: 30.2778, lng: 120.1618, order: 5),
206
+    PatrolPoint(id: 'PP006', name: '锦绣家园管网接口', type: PatrolPointType.junction, lat: 30.2785, lng: 120.1630, order: 6),
207
+    PatrolPoint(id: 'PP007', name: '翠湖路排气阀', type: PatrolPointType.valve, lat: 30.2792, lng: 120.1645, order: 7),
208
+    PatrolPoint(id: 'PP008', name: '城东片区测流点', type: PatrolPointType.meter, lat: 30.2800, lng: 120.1660, order: 8),
209
+  ];
210
+
211
+  static final List<PatrolPoint> _mockPointsNorth = [
212
+    PatrolPoint(id: 'PP009', name: '城北加压站1号泵', type: PatrolPointType.pumpStation, lat: 30.2900, lng: 120.1500, order: 1),
213
+    PatrolPoint(id: 'PP010', name: '城北加压站配电柜', type: PatrolPointType.electrical, lat: 30.2902, lng: 120.1505, order: 2),
214
+    PatrolPoint(id: 'PP011', name: '城北加压站出水压力表', type: PatrolPointType.meter, lat: 30.2905, lng: 120.1510, order: 3),
215
+    PatrolPoint(id: 'PP012', name: '城北加压站流量计', type: PatrolPointType.meter, lat: 30.2908, lng: 120.1515, order: 4),
216
+  ];
217
+
218
+  static final List<PatrolPoint> _mockPointsFactory = [
219
+    PatrolPoint(id: 'PP013', name: '取水泵房', type: PatrolPointType.pumpStation, lat: 30.2700, lng: 120.1400, order: 1),
220
+    PatrolPoint(id: 'PP014', name: '混合池', type: PatrolPointType.equipment, lat: 30.2705, lng: 120.1410, order: 2),
221
+    PatrolPoint(id: 'PP015', name: '絮凝池', type: PatrolPointType.equipment, lat: 30.2710, lng: 120.1420, order: 3),
222
+    PatrolPoint(id: 'PP016', name: '沉淀池', type: PatrolPointType.equipment, lat: 30.2715, lng: 120.1430, order: 4),
223
+    PatrolPoint(id: 'PP017', name: '滤池-1号', type: PatrolPointType.equipment, lat: 30.2720, lng: 120.1440, order: 5),
224
+    PatrolPoint(id: 'PP018', name: '滤池-2号', type: PatrolPointType.equipment, lat: 30.2722, lng: 120.1445, order: 6),
225
+    PatrolPoint(id: 'PP019', name: '清水池', type: PatrolPointType.reservoir, lat: 30.2725, lng: 120.1450, order: 7),
226
+    PatrolPoint(id: 'PP020', name: '加药间', type: PatrolPointType.equipment, lat: 30.2728, lng: 120.1455, order: 8),
227
+    PatrolPoint(id: 'PP021', name: '消毒间', type: PatrolPointType.equipment, lat: 30.2730, lng: 120.1460, order: 9),
228
+    PatrolPoint(id: 'PP022', name: '送水泵房', type: PatrolPointType.pumpStation, lat: 30.2733, lng: 120.1465, order: 10),
229
+    PatrolPoint(id: 'PP023', name: '出厂水水质仪', type: PatrolPointType.meter, lat: 30.2735, lng: 120.1470, order: 11),
230
+    PatrolPoint(id: 'PP024', name: '出水流量计', type: PatrolPointType.meter, lat: 30.2738, lng: 120.1475, order: 12),
231
+  ];
232
+
233
+  static final List<PatrolPoint> _mockPointsWest = [
234
+    PatrolPoint(id: 'PP025', name: '城西阀门井-01', type: PatrolPointType.valve, lat: 30.2750, lng: 120.1450, order: 1),
235
+    PatrolPoint(id: 'PP026', name: '城西消防栓-01', type: PatrolPointType.hydrant, lat: 30.2758, lng: 120.1438, order: 2),
236
+    PatrolPoint(id: 'PP027', name: '翠湖花园加压站', type: PatrolPointType.pumpStation, lat: 30.2765, lng: 120.1425, order: 3),
237
+    PatrolPoint(id: 'PP028', name: '城西排气阀', type: PatrolPointType.valve, lat: 30.2772, lng: 120.1412, order: 4),
238
+    PatrolPoint(id: 'PP029', name: '城西测流点', type: PatrolPointType.meter, lat: 30.2780, lng: 120.1400, order: 5),
239
+    PatrolPoint(id: 'PP030', name: '城西末端压力表', type: PatrolPointType.meter, lat: 30.2788, lng: 120.1388, order: 6),
240
+  ];
241
+
242
+  static final List<PatrolPoint> _mockPointsSouth = [
243
+    PatrolPoint(id: 'PP031', name: '城南阀门井-01', type: PatrolPointType.valve, lat: 30.2650, lng: 120.1520, order: 1),
244
+    PatrolPoint(id: 'PP032', name: '城南加压站', type: PatrolPointType.pumpStation, lat: 30.2640, lng: 120.1530, order: 2),
245
+    PatrolPoint(id: 'PP033', name: '阳光小区消防栓', type: PatrolPointType.hydrant, lat: 30.2630, lng: 120.1540, order: 3),
246
+    PatrolPoint(id: 'PP034', name: '城南排气阀', type: PatrolPointType.valve, lat: 30.2620, lng: 120.1550, order: 4),
247
+    PatrolPoint(id: 'PP035', name: '城南测流点', type: PatrolPointType.meter, lat: 30.2610, lng: 120.1560, order: 5),
248
+    PatrolPoint(id: 'PP036', name: '学校管网接口', type: PatrolPointType.junction, lat: 30.2600, lng: 120.1570, order: 6),
249
+    PatrolPoint(id: 'PP037', name: '医院管网接口', type: PatrolPointType.junction, lat: 30.2590, lng: 120.1580, order: 7),
250
+    PatrolPoint(id: 'PP038', name: '城南末端压力表', type: PatrolPointType.meter, lat: 30.2580, lng: 120.1590, order: 8),
251
+    PatrolPoint(id: 'PP039', name: '城南消防栓-02', type: PatrolPointType.hydrant, lat: 30.2570, lng: 120.1600, order: 9),
252
+    PatrolPoint(id: 'PP040', name: '城南回水阀门', type: PatrolPointType.valve, lat: 30.2560, lng: 120.1610, order: 10),
253
+  ];
254
+}
255
+
256
+// ==================== 数据模型 ====================
257
+
258
+/// 巡检任务状态
259
+enum PatrolTaskStatus {
260
+  pending('待执行'),
261
+  ongoing('进行中'),
262
+  completed('已完成');
263
+
264
+  final String label;
265
+  const PatrolTaskStatus(this.label);
266
+}
267
+
268
+/// 巡检优先级
269
+enum PatrolPriority {
270
+  high('高', 0xFFF44336),
271
+  medium('中', 0xFFFF9800),
272
+  low('低', 0xFF4CAF50);
273
+
274
+  final String label;
275
+  final int color;
276
+  const PatrolPriority(this.label, this.color);
277
+}
278
+
279
+/// 巡检结果
280
+enum PatrolResult {
281
+  normal('正常'),
282
+  issueFound('发现问题');
283
+
284
+  final String label;
285
+  const PatrolResult(this.label);
286
+}
287
+
288
+/// 巡检点类型
289
+enum PatrolPointType {
290
+  valve('阀门井'),
291
+  pumpStation('泵站'),
292
+  hydrant('消防栓'),
293
+  meter('仪表'),
294
+  junction('管网接口'),
295
+  electrical('配电柜'),
296
+  equipment('设备'),
297
+  reservoir('水池');
298
+
299
+  final String label;
300
+  const PatrolPointType(this.label);
301
+}
302
+
303
+/// 巡检点状态
304
+enum PatrolPointStatus {
305
+  normal('正常'),
306
+  abnormal('异常'),
307
+  maintenance('需维护');
308
+
309
+  final String label;
310
+  const PatrolPointStatus(this.label);
311
+}
312
+
313
+/// 巡检点
314
+class PatrolPoint {
315
+  final String id;
316
+  final String name;
317
+  final PatrolPointType type;
318
+  final double lat;
319
+  final double lng;
320
+  final int order;
321
+  PatrolPointStatus? status;
322
+  String? remark;
323
+  List<String>? photos;
324
+
325
+  PatrolPoint({
326
+    required this.id,
327
+    required this.name,
328
+    required this.type,
329
+    required this.lat,
330
+    required this.lng,
331
+    required this.order,
332
+    this.status,
333
+    this.remark,
334
+    this.photos,
335
+  });
336
+}
337
+
338
+/// GPS 轨迹点
339
+class TrackPoint {
340
+  final String id;
341
+  final double lat;
342
+  final double lng;
343
+  final double altitude;
344
+  final double speed;
345
+  final DateTime timestamp;
346
+  final String address;
347
+
348
+  const TrackPoint({
349
+    required this.id,
350
+    required this.lat,
351
+    required this.lng,
352
+    required this.altitude,
353
+    required this.speed,
354
+    required this.timestamp,
355
+    required this.address,
356
+  });
357
+}
358
+
359
+/// 巡检任务
360
+class PatrolTask {
361
+  final String id;
362
+  final String routeName;
363
+  final DateTime date;
364
+  final int totalPoints;
365
+  final int completedPoints;
366
+  final PatrolPriority priority;
367
+  final PatrolTaskStatus status;
368
+  final String assignee;
369
+  final String description;
370
+  final List<PatrolPoint> points;
371
+  final PatrolResult? result;
372
+  final DateTime? reportTime;
373
+  final String? issues;
374
+
375
+  const PatrolTask({
376
+    required this.id,
377
+    required this.routeName,
378
+    required this.date,
379
+    required this.totalPoints,
380
+    required this.completedPoints,
381
+    required this.priority,
382
+    required this.status,
383
+    required this.assignee,
384
+    required this.description,
385
+    required this.points,
386
+    this.result,
387
+    this.reportTime,
388
+    this.issues,
389
+  });
390
+
391
+  double get progress => totalPoints > 0 ? completedPoints / totalPoints : 0;
392
+
393
+  String get dateStr =>
394
+      '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
395
+}

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

@@ -0,0 +1,452 @@
1
+/// 营收管理相关 API 调用服务
2
+/// 当前使用 mock 数据,后端 API 就绪后替换为真实请求
3
+class RevenueService {
4
+  RevenueService._internal();
5
+  static final RevenueService instance = RevenueService._internal();
6
+  factory RevenueService() => instance;
7
+
8
+  // ==================== Mock 数据 ====================
9
+
10
+  /// 获取月度账单列表
11
+  Future<List<BillItem>> getBillList({String? period, BillStatus? status}) async {
12
+    await Future.delayed(const Duration(milliseconds: 800));
13
+    final bills = [
14
+      BillItem(
15
+        id: 'B202405-001',
16
+        customerName: '张三',
17
+        customerAddress: '阳光小区3-501',
18
+        meterId: 'S001-501',
19
+        period: '2024-05',
20
+        previousReading: 1250.0,
21
+        currentReading: 1285.5,
22
+        usage: 35.5,
23
+        unitPrice: 2.41,
24
+        amount: 85.50,
25
+        status: BillStatus.paid,
26
+        dueDate: DateTime(2024, 6, 15),
27
+        payTime: DateTime(2024, 6, 10, 14, 30),
28
+        payMethod: PayMethod.wechat,
29
+      ),
30
+      BillItem(
31
+        id: 'B202405-002',
32
+        customerName: '李四',
33
+        customerAddress: '翠湖花园2-302',
34
+        meterId: 'S002-302',
35
+        period: '2024-05',
36
+        previousReading: 890.0,
37
+        currentReading: 940.0,
38
+        usage: 50.0,
39
+        unitPrice: 2.41,
40
+        amount: 120.00,
41
+        status: BillStatus.unpaid,
42
+        dueDate: DateTime(2024, 6, 15),
43
+      ),
44
+      BillItem(
45
+        id: 'B202405-003',
46
+        customerName: '王五',
47
+        customerAddress: '东方名城8-101',
48
+        meterId: 'S003-101',
49
+        period: '2024-05',
50
+        previousReading: 2100.0,
51
+        currentReading: 2127.0,
52
+        usage: 27.0,
53
+        unitPrice: 2.41,
54
+        amount: 65.20,
55
+        status: BillStatus.paid,
56
+        dueDate: DateTime(2024, 6, 15),
57
+        payTime: DateTime(2024, 6, 8, 9, 15),
58
+        payMethod: PayMethod.alipay,
59
+      ),
60
+      BillItem(
61
+        id: 'B202405-004',
62
+        customerName: '赵六',
63
+        customerAddress: '锦绣家园5-601',
64
+        meterId: 'S004-601',
65
+        period: '2024-05',
66
+        previousReading: 1560.0,
67
+        currentReading: 1601.0,
68
+        usage: 41.0,
69
+        unitPrice: 2.41,
70
+        amount: 98.80,
71
+        status: BillStatus.overdue,
72
+        dueDate: DateTime(2024, 6, 15),
73
+      ),
74
+      BillItem(
75
+        id: 'B202405-005',
76
+        customerName: '孙七',
77
+        customerAddress: '阳光小区1-203',
78
+        meterId: 'S001-203',
79
+        period: '2024-05',
80
+        previousReading: 780.0,
81
+        currentReading: 810.0,
82
+        usage: 30.0,
83
+        unitPrice: 2.41,
84
+        amount: 72.30,
85
+        status: BillStatus.unpaid,
86
+        dueDate: DateTime(2024, 6, 15),
87
+      ),
88
+      BillItem(
89
+        id: 'B202405-006',
90
+        customerName: '周八',
91
+        customerAddress: '城东商铺A-12',
92
+        meterId: 'S005-A12',
93
+        period: '2024-05',
94
+        previousReading: 3200.0,
95
+        currentReading: 3450.0,
96
+        usage: 250.0,
97
+        unitPrice: 3.85,
98
+        amount: 962.50,
99
+        status: BillStatus.paid,
100
+        dueDate: DateTime(2024, 6, 15),
101
+        payTime: DateTime(2024, 6, 5, 10, 0),
102
+        payMethod: PayMethod.bankTransfer,
103
+      ),
104
+      BillItem(
105
+        id: 'B202405-007',
106
+        customerName: '吴九',
107
+        customerAddress: '翠湖花园5-102',
108
+        meterId: 'S002-102',
109
+        period: '2024-05',
110
+        previousReading: 450.0,
111
+        currentReading: 478.0,
112
+        usage: 28.0,
113
+        unitPrice: 2.41,
114
+        amount: 67.48,
115
+        status: BillStatus.paid,
116
+        dueDate: DateTime(2024, 6, 15),
117
+        payTime: DateTime(2024, 6, 12, 16, 45),
118
+        payMethod: PayMethod.cash,
119
+      ),
120
+      BillItem(
121
+        id: 'B202405-008',
122
+        customerName: '郑十',
123
+        customerAddress: '东方名城3-802',
124
+        meterId: 'S003-802',
125
+        period: '2024-05',
126
+        previousReading: 1100.0,
127
+        currentReading: 1155.0,
128
+        usage: 55.0,
129
+        unitPrice: 2.41,
130
+        amount: 132.55,
131
+        status: BillStatus.overdue,
132
+        dueDate: DateTime(2024, 6, 15),
133
+      ),
134
+    ];
135
+
136
+    var result = bills;
137
+    if (status != null) {
138
+      result = result.where((b) => b.status == status).toList();
139
+    }
140
+    if (period != null) {
141
+      result = result.where((b) => b.period == period).toList();
142
+    }
143
+    return result;
144
+  }
145
+
146
+  /// 搜索用户(抄表录入用)
147
+  Future<List<CustomerInfo>> searchCustomers(String keyword) async {
148
+    await Future.delayed(const Duration(milliseconds: 500));
149
+    final customers = [
150
+      CustomerInfo(id: 'C001', name: '张三', address: '阳光小区3-501', meterId: 'S001-501', meterModel: 'LXS-20E', phone: '13800138001'),
151
+      CustomerInfo(id: 'C002', name: '李四', address: '翠湖花园2-302', meterId: 'S002-302', meterModel: 'LXS-25E', phone: '13800138002'),
152
+      CustomerInfo(id: 'C003', name: '王五', address: '东方名城8-101', meterId: 'S003-101', meterModel: 'LXS-20E', phone: '13800138003'),
153
+      CustomerInfo(id: 'C004', name: '赵六', address: '锦绣家园5-601', meterId: 'S004-601', meterModel: 'LXS-32E', phone: '13800138004'),
154
+      CustomerInfo(id: 'C005', name: '孙七', address: '阳光小区1-203', meterId: 'S001-203', meterModel: 'LXS-20E', phone: '13800138005'),
155
+      CustomerInfo(id: 'C006', name: '周八', address: '城东商铺A-12', meterId: 'S005-A12', meterModel: 'LXS-50E', phone: '13800138006'),
156
+      CustomerInfo(id: 'C007', name: '吴九', address: '翠湖花园5-102', meterId: 'S002-102', meterModel: 'LXS-20E', phone: '13800138007'),
157
+      CustomerInfo(id: 'C008', name: '郑十', address: '东方名城3-802', meterId: 'S003-802', meterModel: 'LXS-25E', phone: '13800138008'),
158
+    ];
159
+    if (keyword.isEmpty) return customers;
160
+    return customers
161
+        .where((c) =>
162
+            c.name.contains(keyword) ||
163
+            c.meterId.contains(keyword) ||
164
+            c.address.contains(keyword))
165
+        .toList();
166
+  }
167
+
168
+  /// 提交抄表读数
169
+  Future<bool> submitMeterReading({
170
+    required String meterId,
171
+    required double reading,
172
+    String? photoPath,
173
+    String? remark,
174
+  }) async {
175
+    await Future.delayed(const Duration(milliseconds: 700));
176
+    return true;
177
+  }
178
+
179
+  /// 获取缴费记录
180
+  Future<List<PaymentRecord>> getPaymentRecords({PaymentStatus? status}) async {
181
+    await Future.delayed(const Duration(milliseconds: 700));
182
+    final records = [
183
+      PaymentRecord(
184
+        id: 'P2024-001',
185
+        customerName: '张三',
186
+        customerAddress: '阳光小区3-501',
187
+        meterId: 'S001-501',
188
+        amount: 85.50,
189
+        period: '2024-05',
190
+        payTime: DateTime(2024, 6, 10, 14, 30),
191
+        method: PayMethod.wechat,
192
+        status: PaymentStatus.success,
193
+        operator: '系统',
194
+      ),
195
+      PaymentRecord(
196
+        id: 'P2024-002',
197
+        customerName: '王五',
198
+        customerAddress: '东方名城8-101',
199
+        meterId: 'S003-101',
200
+        amount: 65.20,
201
+        period: '2024-05',
202
+        payTime: DateTime(2024, 6, 8, 9, 15),
203
+        method: PayMethod.alipay,
204
+        status: PaymentStatus.success,
205
+        operator: '系统',
206
+      ),
207
+      PaymentRecord(
208
+        id: 'P2024-003',
209
+        customerName: '周八',
210
+        customerAddress: '城东商铺A-12',
211
+        meterId: 'S005-A12',
212
+        amount: 962.50,
213
+        period: '2024-05',
214
+        payTime: DateTime(2024, 6, 5, 10, 0),
215
+        method: PayMethod.bankTransfer,
216
+        status: PaymentStatus.success,
217
+        operator: '柜台-李芳',
218
+      ),
219
+      PaymentRecord(
220
+        id: 'P2024-004',
221
+        customerName: '吴九',
222
+        customerAddress: '翠湖花园5-102',
223
+        meterId: 'S002-102',
224
+        amount: 67.48,
225
+        period: '2024-05',
226
+        payTime: DateTime(2024, 6, 12, 16, 45),
227
+        method: PayMethod.cash,
228
+        status: PaymentStatus.success,
229
+        operator: '柜台-李芳',
230
+      ),
231
+      PaymentRecord(
232
+        id: 'P2024-005',
233
+        customerName: '赵六',
234
+        customerAddress: '锦绣家园5-601',
235
+        meterId: 'S004-601',
236
+        amount: 98.80,
237
+        period: '2024-05',
238
+        payTime: null,
239
+        method: null,
240
+        status: PaymentStatus.pending,
241
+        operator: null,
242
+      ),
243
+      PaymentRecord(
244
+        id: 'P2024-006',
245
+        customerName: '郑十',
246
+        customerAddress: '东方名城3-802',
247
+        meterId: 'S003-802',
248
+        amount: 132.55,
249
+        period: '2024-05',
250
+        payTime: null,
251
+        method: null,
252
+        status: PaymentStatus.overdue,
253
+        operator: null,
254
+      ),
255
+    ];
256
+    if (status != null) {
257
+      return records.where((r) => r.status == status).toList();
258
+    }
259
+    return records;
260
+  }
261
+
262
+  /// 执行缴费操作
263
+  Future<PaymentResult> payBill({
264
+    required String billId,
265
+    required PayMethod method,
266
+  }) async {
267
+    await Future.delayed(const Duration(milliseconds: 1000));
268
+    return PaymentResult(
269
+      success: true,
270
+      transactionId: 'TXN${DateTime.now().millisecondsSinceEpoch}',
271
+      message: '缴费成功',
272
+    );
273
+  }
274
+
275
+  /// 获取营收概览
276
+  Future<RevenueSummary> getRevenueSummary() async {
277
+    await Future.delayed(const Duration(milliseconds: 600));
278
+    return const RevenueSummary(
279
+      totalAmount: 358620,
280
+      paidAmount: 312450,
281
+      unpaidAmount: 46170,
282
+      overdueAmount: 12800,
283
+      totalCustomers: 1280,
284
+      paidCustomers: 1050,
285
+      unpaidCustomers: 180,
286
+      overdueCustomers: 50,
287
+      collectionRate: 87.1,
288
+      monthOverMonth: 5.8,
289
+    );
290
+  }
291
+}
292
+
293
+// ==================== 数据模型 ====================
294
+
295
+/// 账单状态
296
+enum BillStatus {
297
+  paid('已缴费'),
298
+  unpaid('待缴费'),
299
+  overdue('欠费');
300
+
301
+  final String label;
302
+  const BillStatus(this.label);
303
+}
304
+
305
+/// 支付方式
306
+enum PayMethod {
307
+  wechat('微信支付'),
308
+  alipay('支付宝'),
309
+  bankTransfer('银行转账'),
310
+  cash('现金'),
311
+  other('其他');
312
+
313
+  final String label;
314
+  const PayMethod(this.label);
315
+}
316
+
317
+/// 缴费状态
318
+enum PaymentStatus {
319
+  success('已缴'),
320
+  pending('待缴'),
321
+  overdue('欠费');
322
+
323
+  final String label;
324
+  const PaymentStatus(this.label);
325
+}
326
+
327
+/// 账单
328
+class BillItem {
329
+  final String id;
330
+  final String customerName;
331
+  final String customerAddress;
332
+  final String meterId;
333
+  final String period;
334
+  final double previousReading;
335
+  final double currentReading;
336
+  final double usage;
337
+  final double unitPrice;
338
+  final double amount;
339
+  final BillStatus status;
340
+  final DateTime dueDate;
341
+  final DateTime? payTime;
342
+  final PayMethod? payMethod;
343
+
344
+  const BillItem({
345
+    required this.id,
346
+    required this.customerName,
347
+    required this.customerAddress,
348
+    required this.meterId,
349
+    required this.period,
350
+    required this.previousReading,
351
+    required this.currentReading,
352
+    required this.usage,
353
+    required this.unitPrice,
354
+    required this.amount,
355
+    required this.status,
356
+    required this.dueDate,
357
+    this.payTime,
358
+    this.payMethod,
359
+  });
360
+}
361
+
362
+/// 客户信息
363
+class CustomerInfo {
364
+  final String id;
365
+  final String name;
366
+  final String address;
367
+  final String meterId;
368
+  final String meterModel;
369
+  final String phone;
370
+
371
+  const CustomerInfo({
372
+    required this.id,
373
+    required this.name,
374
+    required this.address,
375
+    required this.meterId,
376
+    required this.meterModel,
377
+    required this.phone,
378
+  });
379
+}
380
+
381
+/// 缴费记录
382
+class PaymentRecord {
383
+  final String id;
384
+  final String customerName;
385
+  final String customerAddress;
386
+  final String meterId;
387
+  final double amount;
388
+  final String period;
389
+  final DateTime? payTime;
390
+  final PayMethod? method;
391
+  final PaymentStatus status;
392
+  final String? operator;
393
+
394
+  const PaymentRecord({
395
+    required this.id,
396
+    required this.customerName,
397
+    required this.customerAddress,
398
+    required this.meterId,
399
+    required this.amount,
400
+    required this.period,
401
+    this.payTime,
402
+    this.method,
403
+    required this.status,
404
+    this.operator,
405
+  });
406
+
407
+  String get payTimeStr {
408
+    if (payTime == null) return '-';
409
+    return '${payTime!.year}-${payTime!.month.toString().padLeft(2, '0')}-${payTime!.day.toString().padLeft(2, '0')} '
410
+        '${payTime!.hour.toString().padLeft(2, '0')}:${payTime!.minute.toString().padLeft(2, '0')}';
411
+  }
412
+}
413
+
414
+/// 缴费结果
415
+class PaymentResult {
416
+  final bool success;
417
+  final String transactionId;
418
+  final String message;
419
+
420
+  const PaymentResult({
421
+    required this.success,
422
+    required this.transactionId,
423
+    required this.message,
424
+  });
425
+}
426
+
427
+/// 营收概览
428
+class RevenueSummary {
429
+  final double totalAmount;
430
+  final double paidAmount;
431
+  final double unpaidAmount;
432
+  final double overdueAmount;
433
+  final int totalCustomers;
434
+  final int paidCustomers;
435
+  final int unpaidCustomers;
436
+  final int overdueCustomers;
437
+  final double collectionRate;
438
+  final double monthOverMonth;
439
+
440
+  const RevenueSummary({
441
+    required this.totalAmount,
442
+    required this.paidAmount,
443
+    required this.unpaidAmount,
444
+    required this.overdueAmount,
445
+    required this.totalCustomers,
446
+    required this.paidCustomers,
447
+    required this.unpaidCustomers,
448
+    required this.overdueCustomers,
449
+    required this.collectionRate,
450
+    required this.monthOverMonth,
451
+  });
452
+}