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

This commit is contained in:
andyesfly committed 2026-10-02 17:21:53 +08:00
commit f61b7bf90b
120 files changed
+15229

No files matched your search

+95
View File
@@ -0,0 +1,95 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import 'survey_models.dart';
final surveyApiProvider = Provider<SurveyApi>(
(ref) => SurveyApi(ref.watch(apiClientProvider)),
);
/// SOP 回执增量保存结果。
class SopSaveResult {
const SopSaveResult({required this.updated, required this.skipped, required this.sopResults});
final int updated;
final List<int> skipped;
final List<SurveySopResult> sopResults;
factory SopSaveResult.fromJson(Map<String, dynamic> json) => SopSaveResult(
updated: (json['updated'] as num?)?.toInt() ?? 0,
skipped: (json['skipped'] as List? ?? []).map((e) => (e as num).toInt()).toList(),
sopResults: (json['sopResults'] as List? ?? [])
.map((e) => SurveySopResult.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
);
}
/// 勘察工单接口,对应 uni-app 版 `api/survey.ts`。
class SurveyApi {
SurveyApi(this._client);
final ApiClient _client;
Future<ApiResponse<SurveyPage>> list({
int page = 1,
int pageSize = 100,
String? keyword,
int? status,
int? customerId,
int? priority,
int? workerId,
int? salesmanId,
String? planStartDate,
String? planEndDate,
int? includeDraft,
}) => _client.get<SurveyPage>(
'/survey-orders/',
query: {
'page': page,
'pageSize': pageSize,
'keyword': keyword,
'status': status,
'customerId': customerId,
'priority': priority,
'workerId': workerId,
'salesmanId': salesmanId,
'planStartDate': planStartDate,
'planEndDate': planEndDate,
'includeDraft': includeDraft,
},
parse: (d) {
final m = (d as Map).cast<String, dynamic>();
return SurveyPage(
list: (m['list'] as List? ?? [])
.map((e) => SurveyItem.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
total: (m['total'] as num?)?.toInt() ?? 0,
page: (m['page'] as num?)?.toInt() ?? 1,
pageSize: (m['pageSize'] as num?)?.toInt() ?? pageSize,
);
},
);
Future<ApiResponse<SurveyItem>> detail(int id) => _client.get<SurveyItem>(
'/survey-orders/$id',
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<SurveyItem>> update(int id, Map<String, dynamic> body) =>
_client.put<SurveyItem>(
'/survey-orders/$id',
body: body,
parse: (d) => SurveyItem.fromJson((d as Map).cast<String, dynamic>()),
);
/// 按 SOP 检查项增量保存现场回执;
/// 仅现场勘察中(status=1)且工单对当前用户可见时后端才放行。
Future<ApiResponse<SopSaveResult>> saveSopResults(int id, List<SopResultPayload> results) =>
_client.put<SopSaveResult>(
'/survey-orders/$id/sop-results',
body: {'results': results.map((e) => e.toJson()).toList()},
parse: (d) => SopSaveResult.fromJson((d as Map).cast<String, dynamic>()),
);
}
+357
View File
@@ -0,0 +1,357 @@
import 'package:flutter/painting.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/id_util.dart';
/// 勘察工单状态:0 待指派 1 现场勘察中 2 待技术复核 3 已归档 4 预留 5 已取消。
abstract final class SurveyStatus {
static const pendingAssign = 0;
static const surveying = 1;
static const pendingReview = 2;
static const archived = 3;
static const reserved = 4;
static const canceled = 5;
}
/// 优先级:1 特急 P1 2 加急 P2 3 常规 P3。
abstract final class SurveyPriority {
static const urgent = 1;
static const expedited = 2;
static const regular = 3;
}
/// App 端展示用的状态枚举(与 uni-app 版 `SurveyStatusMap` 一一对应)。
enum SurveyStatusKey { pending, inProgress, review, completed, canceled }
const surveyStatusMap = <SurveyStatusKey, ({String text, ChipTone tone})>{
SurveyStatusKey.pending: (text: '待处理', tone: ChipTone.warn),
SurveyStatusKey.inProgress: (text: '进行中', tone: ChipTone.primary),
SurveyStatusKey.review: (text: '待复核', tone: ChipTone.secondary),
SurveyStatusKey.completed: (text: '已完成', tone: ChipTone.tertiary),
SurveyStatusKey.canceled: (text: '已取消', tone: ChipTone.error),
};
/// 数字状态 → App 展示键。
SurveyStatusKey mapSurveyStatus(int? code) => switch (code) {
SurveyStatus.surveying => SurveyStatusKey.inProgress,
SurveyStatus.pendingReview => SurveyStatusKey.review,
SurveyStatus.archived => SurveyStatusKey.completed,
SurveyStatus.canceled => SurveyStatusKey.canceled,
_ => SurveyStatusKey.pending,
};
String surveyStatusText(int? code) => surveyStatusMap[mapSurveyStatus(code)]!.text;
ChipTone surveyStatusTone(int? code) => surveyStatusMap[mapSurveyStatus(code)]!.tone;
const surveyPriorityMap = <int, ({String text, ChipTone tone})>{
1: (text: '特急 P1', tone: ChipTone.error),
2: (text: '加急 P2', tone: ChipTone.warn),
3: (text: '常规 P3', tone: ChipTone.muted),
};
String surveyPriorityText(int? p) => surveyPriorityMap[p]?.text ?? '—';
ChipTone surveyPriorityTone(int? p) => surveyPriorityMap[p]?.tone ?? ChipTone.muted;
/// 列表卡片左侧色条颜色。
Color surveyStatusBarColor(int? status) => switch (status) {
SurveyStatus.pendingAssign => AppColors.statusWarn,
SurveyStatus.surveying => AppColors.primary,
SurveyStatus.pendingReview => AppColors.secondary,
SurveyStatus.archived => AppColors.tertiaryContainer,
SurveyStatus.canceled => AppColors.statusError,
_ => AppColors.borderRule,
};
class SurveyCustomer {
const SurveyCustomer({required this.id, required this.customerName, this.city, this.address});
final int id;
final String customerName;
final String? city;
final String? address;
factory SurveyCustomer.fromJson(Map<String, dynamic> json) => SurveyCustomer(
id: toIntOr(json['id'], 0),
customerName: json['customerName'] as String? ?? '',
city: json['city'] as String?,
address: json['address'] as String?,
);
}
class SurveyWorker {
const SurveyWorker({required this.id, required this.workerName, this.phone});
final int id;
final String workerName;
final String? phone;
factory SurveyWorker.fromJson(Map<String, dynamic> json) => SurveyWorker(
id: toIntOr(json['id'], 0),
workerName: json['workerName'] as String? ?? '',
phone: json['phone'] as String?,
);
}
class SurveySopResult {
const SurveySopResult({
required this.id,
required this.surveyOrderId,
required this.sopItemId,
required this.checked,
required this.itemName,
this.itemDesc = '',
this.receiptType = '',
this.receiptRequire = '',
this.required = false,
this.receiptValue = const [],
this.receiptUrls = const [],
this.remark = '',
});
final int id;
final int surveyOrderId;
final int sopItemId;
final bool checked;
final String itemName;
final String itemDesc;
final String receiptType;
final String receiptRequire;
final bool required;
/// 回执内容:photo/meter 类为照片对象 key 数组,form 类为单元素文本值数组。
final List<String> receiptValue;
/// 详情接口附带:照片类回执的预签名访问 URL(与 receiptValue 顺序一致)。
final List<String> receiptUrls;
final String remark;
factory SurveySopResult.fromJson(Map<String, dynamic> json) => SurveySopResult(
id: toIntOr(json['id'], 0),
surveyOrderId: toIntOr(json['surveyOrderId'], 0),
sopItemId: toIntOr(json['sopItemId'], 0),
checked: json['checked'] == true,
itemName: json['itemName'] as String? ?? '',
itemDesc: json['itemDesc'] as String? ?? '',
receiptType: json['receiptType'] as String? ?? '',
receiptRequire: json['receiptRequire'] as String? ?? '',
required: json['required'] == true,
receiptValue: (json['receiptValue'] as List? ?? []).map((e) => e.toString()).toList(),
receiptUrls: (json['receiptUrls'] as List? ?? []).map((e) => e.toString()).toList(),
remark: json['remark'] as String? ?? '',
);
}
/// 逐项回执的增量提交体:只传需要变更的 SOP 项,未传项保持原值。
class SopResultPayload {
const SopResultPayload({required this.sopItemId, this.checked, this.receiptValue, this.remark});
final int sopItemId;
final bool? checked;
final List<String>? receiptValue;
final String? remark;
Map<String, dynamic> toJson() => {
'sopItemId': sopItemId,
if (checked != null) 'checked': checked,
if (receiptValue != null) 'receiptValue': receiptValue,
if (remark != null) 'remark': remark,
};
}
/// 勘察工单,与后端 `/api/survey-orders` 契约对齐。
class SurveyItem {
const SurveyItem({
required this.id,
required this.surveyNo,
required this.customerId,
required this.surveyor,
required this.priority,
required this.taskTitle,
required this.siteLocation,
required this.source,
required this.loopSpec,
required this.progressDone,
required this.drawingCount,
required this.techContact,
required this.slaLevel,
required this.urgeCount,
required this.isDraft,
required this.photos,
required this.reviewRemark,
required this.cancelReason,
required this.remark,
required this.status,
required this.createdAt,
required this.updatedAt,
required this.customerName,
required this.workerName,
required this.salesmanName,
required this.salesmanPhone,
this.workerId,
this.surveyTime,
this.loopCount,
this.progressTotal,
this.salesmanId,
this.planStart,
this.planEnd,
this.expectMinutes,
this.expectArrivalAt,
this.lastUrgedAt,
this.estimatedFee,
this.actualFee,
this.reviewedAt,
this.customer,
this.worker,
this.sopResults,
});
final int id;
final String surveyNo;
final int customerId;
final String surveyor;
final int? workerId;
final String? surveyTime;
final int priority;
final String taskTitle;
final String siteLocation;
final String source;
final String loopSpec;
final int? loopCount;
final int progressDone;
final int? progressTotal;
final int drawingCount;
final int? salesmanId;
final String techContact;
final String? planStart;
final String? planEnd;
final int? expectMinutes;
final String? expectArrivalAt;
final String slaLevel;
final int urgeCount;
final String? lastUrgedAt;
final int isDraft;
final num? estimatedFee;
final num? actualFee;
final List<String> photos;
final String reviewRemark;
final String? reviewedAt;
final String cancelReason;
final String remark;
final int status;
final String? createdAt;
final String? updatedAt;
// decorate 注入的冗余字段
final SurveyCustomer? customer;
final String customerName;
final SurveyWorker? worker;
final String workerName;
final String salesmanName;
final String salesmanPhone;
// 仅详情接口返回
final List<SurveySopResult>? sopResults;
factory SurveyItem.fromJson(Map<String, dynamic> json) => SurveyItem(
id: toIntOr(json['id'], 0),
surveyNo: json['surveyNo'] as String? ?? '',
customerId: toIntOr(json['customerId'], 0),
surveyor: json['surveyor'] as String? ?? '',
workerId: toIntOrNull(json['workerId']),
surveyTime: json['surveyTime'] as String?,
priority: toIntOr(json['priority'], 3),
taskTitle: json['taskTitle'] as String? ?? '',
siteLocation: json['siteLocation'] as String? ?? '',
source: json['source'] as String? ?? '',
loopSpec: json['loopSpec'] as String? ?? '',
loopCount: toIntOrNull(json['loopCount']),
progressDone: toIntOr(json['progressDone'], 0),
progressTotal: toIntOrNull(json['progressTotal']),
drawingCount: toIntOr(json['drawingCount'], 0),
salesmanId: toIntOrNull(json['salesmanId']),
techContact: json['techContact'] as String? ?? '',
planStart: json['planStart'] as String?,
planEnd: json['planEnd'] as String?,
expectMinutes: toIntOrNull(json['expectMinutes']),
expectArrivalAt: json['expectArrivalAt'] as String?,
slaLevel: json['slaLevel'] as String? ?? '',
urgeCount: toIntOr(json['urgeCount'], 0),
lastUrgedAt: json['lastUrgedAt'] as String?,
isDraft: toIntOr(json['isDraft'], 0),
estimatedFee: json['estimatedFee'] as num?,
actualFee: json['actualFee'] as num?,
photos: (json['photos'] as List? ?? []).map((e) => e.toString()).toList(),
reviewRemark: json['reviewRemark'] as String? ?? '',
reviewedAt: json['reviewedAt'] as String?,
cancelReason: json['cancelReason'] as String? ?? '',
remark: json['remark'] as String? ?? '',
status: toIntOr(json['status'], 0),
createdAt: json['createdAt'] as String?,
updatedAt: json['updatedAt'] as String?,
customer: json['customer'] == null
? null
: SurveyCustomer.fromJson((json['customer'] as Map).cast<String, dynamic>()),
customerName: json['customerName'] as String? ?? '',
worker: json['worker'] == null
? null
: SurveyWorker.fromJson((json['worker'] as Map).cast<String, dynamic>()),
workerName: json['workerName'] as String? ?? '',
salesmanName: json['salesmanName'] as String? ?? '',
salesmanPhone: json['salesmanPhone'] as String? ?? '',
sopResults: json['sopResults'] == null
? null
: (json['sopResults'] as List)
.map((e) => SurveySopResult.fromJson((e as Map).cast<String, dynamic>()))
.toList(),
);
/// 计划时间展示,等价于 `formatSurveyPlanTime`。
String get planTimeText => formatSurveyPlanTime(planStart, planEnd);
String get displayTitle => taskTitle.isNotEmpty ? taskTitle : '勘察 #$surveyNo';
String get displayCustomer => customerName.isNotEmpty ? customerName : (customer?.customerName ?? '客户 #$customerId');
}
class SurveyPage {
const SurveyPage({
required this.list,
required this.total,
required this.page,
required this.pageSize,
});
final List<SurveyItem> list;
final int total;
final int page;
final int pageSize;
}
/// 勘察单计划时间展示:`05-12 09:00 - 11:00` / `05-12 09:00 - 05-13 10:00` / `未安排`。
String formatSurveyPlanTime(String? planStart, String? planEnd) {
if ((planStart == null || planStart.isEmpty) && (planEnd == null || planEnd.isEmpty)) {
return '未安排';
}
String hm(DateTime dt) =>
'${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} '
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
String md(DateTime dt) =>
'${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
final start = parseSurveyDate(planStart);
final end = parseSurveyDate(planEnd);
if (start != null && end != null) {
final sameDay =
start.year == end.year && start.month == end.month && start.day == end.day;
return sameDay ? '${hm(start)} - ${md(end)}' : '${hm(start)} - ${hm(end)}';
}
final only = start ?? end;
return only == null ? (planStart ?? planEnd ?? '未安排') : hm(only);
}
DateTime? parseSurveyDate(String? raw) {
if (raw == null || raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_typography.dart';
import '../../../shared/widgets/app_feedback.dart';
import '../../../shared/widgets/x_card.dart';
import '../../../shared/widgets/x_chip.dart';
import '../../../shared/widgets/x_common.dart';
import '../../../shared/widgets/x_icon.dart';
import '../../../shared/widgets/x_top_bar.dart';
import '../data/survey_api.dart';
import '../data/survey_models.dart';
/// 勘测任务列表,对应 `pages/survey/list.vue`。
class SurveyListPage extends ConsumerStatefulWidget {
const SurveyListPage({super.key});
@override
ConsumerState<SurveyListPage> createState() => _SurveyListPageState();
}
class _SurveyListPageState extends ConsumerState<SurveyListPage> {
List<SurveyItem> _list = const [];
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
try {
final res = await ref.read(surveyApiProvider).list(pageSize: 100);
if (!mounted) return;
if (res.isOk && res.data != null) {
setState(() => _list = res.data!.list);
}
} catch (_) {
if (mounted) showAppToast('加载失败');
}
}
Future<void> _goForm(SurveyItem item) async {
await context.push('/survey/form?id=${item.id}');
if (mounted) _load();
}
@override
Widget build(BuildContext context) {
return XPageShell(
topBar: XTopBar.brand(title: '勘测任务'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
'管理您的现场勘测与检查任务',
style: TextStyle(fontSize: 14, color: AppColors.textMuted),
),
),
Expanded(child: _buildContent()),
],
),
);
}
Widget _buildContent() {
if (_list.isEmpty) {
return ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
children: const [XPlaceholder(text: '暂无勘测任务')],
);
}
return ListView.builder(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 24),
itemCount: _list.length,
itemBuilder: (context, index) => _buildCard(_list[index]),
);
}
Widget _buildCard(SurveyItem item) {
return XCard(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
onTap: () => _goForm(item),
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(right: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${item.customerName.isEmpty ? '未知客户' : item.customerName}的勘测',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.onSurface,
),
),
const SizedBox(height: 4),
Text(
'任务ID:${item.surveyNo}',
style: AppTypography.dataMono.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
),
XChip(
surveyStatusText(item.status),
tone: surveyStatusTone(item.status),
),
],
),
),
Padding(
padding: const EdgeInsets.fromLTRB(0, 8, 0, 14),
child: Column(
children: [
_buildLine(
'location',
AppColors.primary,
item.siteLocation.isEmpty ? '地点未填' : item.siteLocation,
),
const SizedBox(height: 10),
_buildLine(
'schedule',
AppColors.statusWarn,
'计划时间:${item.planTimeText}',
),
],
),
),
],
),
);
}
Widget _buildLine(String icon, Color color, String text) {
return Row(
children: [
XIcon(icon, size: 18, color: color),
const SizedBox(width: 10),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 14, color: AppColors.onSurfaceVariant),
),
),
],
);
}
}