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

+105
View File
@@ -0,0 +1,105 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../core/network/api_client.dart';
import '../../../core/network/api_response.dart';
import '../../../core/providers.dart';
import 'visit_models.dart';
final visitApiProvider = Provider<VisitApi>(
(ref) => VisitApi(ref.watch(apiClientProvider)),
);
/// 拜访台账接口,对应 uni-app 版 `api/customerVisit.ts`。
class VisitApi {
VisitApi(this._client);
final ApiClient _client;
Future<ApiResponse<VisitPage>> list({
int page = 1,
int pageSize = 100,
String? keyword,
int? customerId,
int? salesmanId,
int? visitType,
int? status,
int? priority,
String? planStartDate,
String? planEndDate,
}) => _client.get<VisitPage>(
'/customer-visits',
query: {
'page': page,
'pageSize': pageSize,
'keyword': keyword,
'customerId': customerId,
'salesmanId': salesmanId,
'visitType': visitType,
'status': status,
'priority': priority,
'planStartDate': planStartDate,
'planEndDate': planEndDate,
},
parse: (d) {
final m = (d as Map).cast<String, dynamic>();
return VisitPage(
list: (m['list'] as List? ?? [])
.map((e) => VisitInfo.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<VisitStats>> stats() => _client.get<VisitStats>(
'/customer-visits/stats',
parse: (d) => VisitStats.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<VisitInfo>> detail(int id) => _client.get<VisitInfo>(
'/customer-visits/$id',
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<VisitInfo>> create(Map<String, dynamic> body) =>
_client.post<VisitInfo>(
'/customer-visits',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
/// 批量安排拜访:多选客户生成统一的待执行计划。
Future<ApiResponse<VisitBatchResult>> batchCreate(Map<String, dynamic> body) =>
_client.post<VisitBatchResult>(
'/customer-visits/batch',
body: body,
parse: (d) => VisitBatchResult.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<VisitInfo>> update(int id, Map<String, dynamic> body) =>
_client.put<VisitInfo>(
'/customer-visits/$id',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
/// 状态流转:待执行 → 已完成 / 取消 等。
Future<ApiResponse<VisitInfo>> updateStatus(int id, Map<String, dynamic> body) =>
_client.put<VisitInfo>(
'/customer-visits/$id/status',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
/// 改派:更换跟进业务员(仅 ADMIN)。
Future<ApiResponse<VisitInfo>> reassign(int id, Map<String, dynamic> body) =>
_client.put<VisitInfo>(
'/customer-visits/$id/reassign',
body: body,
parse: (d) => VisitInfo.fromJson((d as Map).cast<String, dynamic>()),
);
Future<ApiResponse<void>> delete(int id) => _client.delete<void>('/customer-visits/$id');
}
+342
View File
@@ -0,0 +1,342 @@
import 'package:flutter/painting.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/utils/id_util.dart';
/// 数值下拉选项。
typedef ValueOption = ({int value, String label});
/// 拜访类型:1 首访摸排 2 方案沟通 3 商务二访 4 维保回访 5 应急巡检
const visitTypeOptions = <ValueOption>[
(value: 1, label: '首访摸排'),
(value: 2, label: '方案沟通'),
(value: 3, label: '商务二访'),
(value: 4, label: '维保回访'),
(value: 5, label: '应急巡检'),
];
/// 拜访方式:1 现场拜访 2 商务拜访 3 电话沟通 4 线上会议
const visitModeOptions = <ValueOption>[
(value: 1, label: '现场拜访'),
(value: 2, label: '商务拜访'),
(value: 3, label: '电话沟通'),
(value: 4, label: '线上会议'),
];
/// 优先级:1 特急 P1 2 加急 P2 3 常规 P3
const visitPriorityOptions = <ValueOption>[
(value: 1, label: '特急 · P1'),
(value: 2, label: '加急 · P2'),
(value: 3, label: '常规 · P3'),
];
/// 意向评级:1=A 高意向 2=B 中意向 3=C 低意向
const visitIntentOptions = <ValueOption>[
(value: 1, label: 'A级 · 高意向'),
(value: 2, label: 'B级 · 中意向'),
(value: 3, label: 'C级 · 低意向'),
];
/// 状态:0 待执行 1 已完成待复核 2 取证待技术复核 3 已立项转勘察 4 已归档 5 已取消
const visitStatusOptions = <ValueOption>[
(value: 0, label: '待执行'),
(value: 1, label: '已完成待复核'),
(value: 2, label: '取证待技术复核'),
(value: 3, label: '已立项转勘察'),
(value: 4, label: '已归档'),
(value: 5, label: '已取消'),
];
abstract final class VisitStatus {
static const pending = 0;
static const done = 1;
static const evidence = 2;
static const survey = 3;
static const archived = 4;
static const canceled = 5;
}
const visitStatusMap = <int, ({String text, ChipTone tone})>{
0: (text: '待执行', tone: ChipTone.warn),
1: (text: '已完成待复核', tone: ChipTone.primary),
2: (text: '取证待技术复核', tone: ChipTone.secondary),
3: (text: '已立项转勘察', tone: ChipTone.secondary),
4: (text: '已归档', tone: ChipTone.tertiary),
5: (text: '已取消', tone: ChipTone.error),
};
const visitPriorityMap = <int, ({String text, ChipTone tone})>{
1: (text: '特急 P1', tone: ChipTone.error),
2: (text: '加急 P2', tone: ChipTone.warn),
3: (text: '常规 P3', tone: ChipTone.muted),
};
String visitStatusText(int? s) => visitStatusMap[s]?.text ?? '未知';
ChipTone visitStatusTone(int? s) => visitStatusMap[s]?.tone ?? ChipTone.muted;
String visitPriorityText(int? p) => visitPriorityMap[p]?.text ?? '—';
ChipTone visitPriorityTone(int? p) => visitPriorityMap[p]?.tone ?? ChipTone.muted;
String optionLabel(List<ValueOption> options, int? value) =>
options.where((o) => o.value == value).map((o) => o.label).firstOrNull ?? '—';
/// 列表卡片左侧色条颜色。
Color visitStatusBarColor(int status) => switch (status) {
0 => AppColors.statusWarn,
1 => AppColors.primary,
2 || 3 => AppColors.secondary,
4 => AppColors.tertiaryContainer,
5 => AppColors.statusError,
_ => AppColors.borderRule,
};
/// 关联客户(列表/详情 include 的精简字段)。
class VisitCustomer {
const VisitCustomer({
required this.id,
this.customerNo,
this.customerName,
this.customerLevel,
this.contactPerson,
this.contactPhone,
this.address,
this.salesmanId,
});
final int id;
final String? customerNo;
final String? customerName;
final String? customerLevel;
final String? contactPerson;
final String? contactPhone;
final String? address;
final int? salesmanId;
factory VisitCustomer.fromJson(Map<String, dynamic> json) => VisitCustomer(
id: toIntOr(json['id'], 0),
customerNo: json['customerNo'] as String?,
customerName: json['customerName'] as String?,
customerLevel: json['customerLevel'] as String?,
contactPerson: json['contactPerson'] as String?,
contactPhone: json['contactPhone'] as String?,
address: json['address'] as String?,
salesmanId: toIntOrNull(json['salesmanId']),
);
}
/// 拜访台账,与后端 `/api/customer-visits` 契约对齐。
class VisitInfo {
const VisitInfo({
required this.id,
required this.visitNo,
required this.customerId,
required this.visitPerson,
required this.status,
this.visitPhone,
this.visitContent,
this.visitTime,
this.salesmanId,
this.willPlan,
this.visitType,
this.visitMode,
this.priority,
this.subject,
this.planTime,
this.planEndTime,
this.checkinTime,
this.checkinStatus,
this.checkinLng,
this.checkinLat,
this.spaceDesc,
this.intentLevel,
this.intentDesc,
this.demands,
this.companion,
this.receptionPerson,
this.receptionTitle,
this.receptionPhone,
this.resultRating,
this.nextVisitTime,
this.nextAction,
this.photos = const [],
this.photoUrls = const [],
this.surveyOrderId,
this.projectNo,
this.createdAt,
this.updatedAt,
this.customer,
this.salesmanName,
this.salesmanPhone,
});
final int id;
final String visitNo;
final int customerId;
final String visitPerson;
final int status;
final String? visitPhone;
final String? visitContent;
final String? visitTime;
final int? salesmanId;
final int? willPlan;
final int? visitType;
final int? visitMode;
final int? priority;
final String? subject;
final String? planTime;
final String? planEndTime;
final String? checkinTime;
final int? checkinStatus;
final double? checkinLng;
final double? checkinLat;
final String? spaceDesc;
final int? intentLevel;
final String? intentDesc;
final String? demands;
final String? companion;
final String? receptionPerson;
final String? receptionTitle;
final String? receptionPhone;
final String? resultRating;
final String? nextVisitTime;
final String? nextAction;
/// 现场照片对象 key 列表(持久化字段)
final List<String> photos;
/// 现场照片预签名访问 URL 列表(后端动态生成)
final List<String> photoUrls;
final int? surveyOrderId;
final String? projectNo;
final String? createdAt;
final String? updatedAt;
final VisitCustomer? customer;
final String? salesmanName;
final String? salesmanPhone;
factory VisitInfo.fromJson(Map<String, dynamic> json) => VisitInfo(
id: toIntOr(json['id'], 0),
visitNo: json['visitNo'] as String? ?? '',
customerId: toIntOr(json['customerId'], 0),
visitPerson: json['visitPerson'] as String? ?? '',
status: toIntOr(json['status'], 0),
visitPhone: json['visitPhone'] as String?,
visitContent: json['visitContent'] as String?,
visitTime: json['visitTime'] as String?,
salesmanId: toIntOrNull(json['salesmanId']),
willPlan: toIntOrNull(json['willPlan']),
visitType: toIntOrNull(json['visitType']),
visitMode: toIntOrNull(json['visitMode']),
priority: toIntOrNull(json['priority']),
subject: json['subject'] as String?,
planTime: json['planTime'] as String?,
planEndTime: json['planEndTime'] as String?,
checkinTime: json['checkinTime'] as String?,
checkinStatus: toIntOrNull(json['checkinStatus']),
checkinLng: (json['checkinLng'] as num?)?.toDouble(),
checkinLat: (json['checkinLat'] as num?)?.toDouble(),
spaceDesc: json['spaceDesc'] as String?,
intentLevel: toIntOrNull(json['intentLevel']),
intentDesc: json['intentDesc'] as String?,
demands: json['demands'] as String?,
companion: json['companion'] as String?,
receptionPerson: json['receptionPerson'] as String?,
receptionTitle: json['receptionTitle'] as String?,
receptionPhone: json['receptionPhone'] as String?,
resultRating: json['resultRating'] as String?,
nextVisitTime: json['nextVisitTime'] as String?,
nextAction: json['nextAction'] as String?,
photos: (json['photos'] as List? ?? []).map((e) => e.toString()).toList(),
photoUrls: (json['photoUrls'] as List? ?? []).map((e) => e.toString()).toList(),
surveyOrderId: toIntOrNull(json['surveyOrderId']),
projectNo: json['projectNo'] as String?,
createdAt: json['created_at'] as String?,
updatedAt: json['updated_at'] as String?,
customer: json['customer'] == null
? null
: VisitCustomer.fromJson((json['customer'] as Map).cast<String, dynamic>()),
salesmanName: json['salesmanName'] as String?,
salesmanPhone: json['salesmanPhone'] as String?,
);
String get customerName =>
customer?.customerName ?? '客户 #$customerId';
}
class VisitPage {
const VisitPage({
required this.list,
required this.total,
required this.page,
required this.pageSize,
});
final List<VisitInfo> list;
final int total;
final int page;
final int pageSize;
}
/// KPI 统计卡数据。
class VisitStats {
const VisitStats({
required this.total,
required this.pending,
required this.todayPlan,
required this.urgentPending,
required this.conversionRate,
required this.pendingReview,
required this.completed,
required this.converted,
required this.intentTotal,
});
final int total;
final int pending;
final int todayPlan;
final int urgentPending;
final num conversionRate;
final int pendingReview;
final int completed;
final int converted;
final int intentTotal;
factory VisitStats.fromJson(Map<String, dynamic> json) => VisitStats(
total: toIntOr(json['total'], 0),
pending: toIntOr(json['pending'], 0),
todayPlan: toIntOr(json['todayPlan'], 0),
urgentPending: toIntOr(json['urgentPending'], 0),
conversionRate: (json['conversionRate'] as num?) ?? 0,
pendingReview: toIntOr(json['pendingReview'], 0),
completed: toIntOr(json['completed'], 0),
converted: toIntOr(json['converted'], 0),
intentTotal: toIntOr(json['intentTotal'], 0),
);
static const empty = VisitStats(
total: 0,
pending: 0,
todayPlan: 0,
urgentPending: 0,
conversionRate: 0,
pendingReview: 0,
completed: 0,
converted: 0,
intentTotal: 0,
);
}
/// 批量安排拜访结果。
class VisitBatchResult {
const VisitBatchResult({required this.total, required this.ids});
final int total;
final List<int> ids;
factory VisitBatchResult.fromJson(Map<String, dynamic> json) => VisitBatchResult(
total: toIntOr(json['total'], 0),
ids: (json['ids'] as List? ?? []).map((e) => toIntOr(e, 0)).toList(),
);
}