feat: add Flutter mobile clients and staff delivery API
This commit is contained in:
26
apps/service_app/lib/app/app.dart
Normal file
26
apps/service_app/lib/app/app.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../ui/core/app_theme.dart';
|
||||
import 'dependencies.dart';
|
||||
import 'router.dart';
|
||||
|
||||
class ServiceClientApp extends StatefulWidget {
|
||||
const ServiceClientApp({required this.dependencies, super.key});
|
||||
|
||||
final AppDependencies dependencies;
|
||||
|
||||
@override
|
||||
State<ServiceClientApp> createState() => _ServiceClientAppState();
|
||||
}
|
||||
|
||||
class _ServiceClientAppState extends State<ServiceClientApp> {
|
||||
late final _router = createRouter(widget.dependencies);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp.router(
|
||||
title: '瓶安芯服务工作台',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.light(),
|
||||
routerConfig: _router,
|
||||
);
|
||||
}
|
||||
96
apps/service_app/lib/app/dependencies.dart
Normal file
96
apps/service_app/lib/app/dependencies.dart
Normal file
@@ -0,0 +1,96 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../data/offline/encrypted_draft_store.dart';
|
||||
import '../data/repositories/service_repository.dart';
|
||||
import '../data/services/api_client.dart';
|
||||
import '../data/services/location_service.dart';
|
||||
import '../data/services/secure_session_store.dart';
|
||||
|
||||
class AppDependencies {
|
||||
AppDependencies._({
|
||||
required this.session,
|
||||
required this.repository,
|
||||
required this.drafts,
|
||||
});
|
||||
|
||||
final StaffSession session;
|
||||
final ServiceRepository repository;
|
||||
final EncryptedDraftStore drafts;
|
||||
|
||||
static Future<AppDependencies> create() async {
|
||||
final store = SecureSessionStore();
|
||||
final session = StaffSession(store);
|
||||
await session.restore();
|
||||
final api = ApiClient(() => session.token);
|
||||
return AppDependencies._(
|
||||
session: session,
|
||||
repository: ServiceRepository(api, GeolocatorLocationService()),
|
||||
drafts: EncryptedDraftStore(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class StaffSession extends ChangeNotifier {
|
||||
StaffSession(this._store);
|
||||
|
||||
static const _root = '/heqi/client/v1/staff';
|
||||
static const _tokenKey = 'service_app_access_token';
|
||||
static const _identityKey = 'service_app_identity';
|
||||
static const _roleKey = 'service_app_role';
|
||||
static const _deviceKey = 'service_app_device';
|
||||
final SecureSessionStore _store;
|
||||
|
||||
String _token = '';
|
||||
String _identity = '';
|
||||
String _roleCode = '';
|
||||
String _deviceIdentity = '';
|
||||
|
||||
String get token => _token;
|
||||
String get identity => _identity;
|
||||
String get roleCode => _roleCode;
|
||||
String get deviceIdentity => _deviceIdentity;
|
||||
bool get isAuthenticated => _token.isNotEmpty;
|
||||
|
||||
Future<void> restore() async {
|
||||
_token = await _store.read(_tokenKey) ?? '';
|
||||
_identity = await _store.read(_identityKey) ?? '';
|
||||
_roleCode = await _store.read(_roleKey) ?? '';
|
||||
_deviceIdentity = await _store.read(_deviceKey) ?? '';
|
||||
if (_deviceIdentity.isEmpty) {
|
||||
_deviceIdentity = const Uuid().v7();
|
||||
await _store.write(_deviceKey, _deviceIdentity);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> login(String phone, String password) async {
|
||||
final api = ApiClient(() => '');
|
||||
final details = jsonMap(
|
||||
await api.post(
|
||||
'$_root/auth/login',
|
||||
authenticated: false,
|
||||
body: {'phone': phone, 'mode': 'password', 'password': password},
|
||||
),
|
||||
);
|
||||
_token = details['access_token'] as String? ?? '';
|
||||
_identity = details['identity'] as String? ?? '';
|
||||
_roleCode = details['role_code'] as String? ?? '';
|
||||
if (_token.isEmpty || _identity.isEmpty || _roleCode.isEmpty) {
|
||||
throw const ApiException(500, '工作人员登录上下文缺失');
|
||||
}
|
||||
await _store.write(_tokenKey, _token);
|
||||
await _store.write(_identityKey, _identity);
|
||||
await _store.write(_roleKey, _roleCode);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
_token = '';
|
||||
_identity = '';
|
||||
_roleCode = '';
|
||||
await _store.delete(_tokenKey);
|
||||
await _store.delete(_identityKey);
|
||||
await _store.delete(_roleKey);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
140
apps/service_app/lib/app/router.dart
Normal file
140
apps/service_app/lib/app/router.dart
Normal file
@@ -0,0 +1,140 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../ui/features/auth/login_page.dart';
|
||||
import '../ui/features/evidence/evidence_page.dart';
|
||||
import '../ui/features/preflight/preflight_page.dart';
|
||||
import '../ui/features/profile/profile_page.dart';
|
||||
import '../ui/features/work/work_detail_page.dart';
|
||||
import '../ui/features/work/work_list_page.dart';
|
||||
import 'dependencies.dart';
|
||||
|
||||
GoRouter createRouter(AppDependencies dependencies) => GoRouter(
|
||||
initialLocation: '/preflight',
|
||||
refreshListenable: dependencies.session,
|
||||
redirect: (context, state) {
|
||||
final login = state.matchedLocation == '/login';
|
||||
if (!dependencies.session.isAuthenticated && !login) return '/login';
|
||||
if (dependencies.session.isAuthenticated && login) return '/preflight';
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/login',
|
||||
builder: (context, state) => LoginPage(session: dependencies.session),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/preflight',
|
||||
builder: (context, state) => PreflightPage(
|
||||
session: dependencies.session,
|
||||
repository: dependencies.repository,
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/tasks/:identity',
|
||||
builder: (context, state) => WorkDetailPage(
|
||||
session: dependencies.session,
|
||||
repository: dependencies.repository,
|
||||
drafts: dependencies.drafts,
|
||||
identity: state.pathParameters['identity']!,
|
||||
),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: 'evidence',
|
||||
builder: (context, state) => EvidencePage(
|
||||
session: dependencies.session,
|
||||
repository: dependencies.repository,
|
||||
drafts: dependencies.drafts,
|
||||
taskIdentity: state.pathParameters['identity']!,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (context, state, shell) => _ServiceShell(
|
||||
navigationShell: shell,
|
||||
roleCode: dependencies.session.roleCode,
|
||||
),
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/work',
|
||||
builder: (context, state) => WorkListPage(
|
||||
session: dependencies.session,
|
||||
repository: dependencies.repository,
|
||||
completed: false,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/records',
|
||||
builder: (context, state) => WorkListPage(
|
||||
session: dependencies.session,
|
||||
repository: dependencies.repository,
|
||||
completed: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/me',
|
||||
builder: (context, state) => ProfilePage(
|
||||
session: dependencies.session,
|
||||
repository: dependencies.repository,
|
||||
drafts: dependencies.drafts,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
class _ServiceShell extends StatelessWidget {
|
||||
const _ServiceShell({required this.navigationShell, required this.roleCode});
|
||||
|
||||
final StatefulNavigationShell navigationShell;
|
||||
final String roleCode;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: navigationShell,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: navigationShell.currentIndex,
|
||||
onDestinationSelected: (index) => navigationShell.goBranch(
|
||||
index,
|
||||
initialLocation: index == navigationShell.currentIndex,
|
||||
),
|
||||
destinations: [
|
||||
NavigationDestination(
|
||||
icon: Icon(
|
||||
roleCode == 'delivery' ? Icons.local_shipping_outlined : Icons.assignment_outlined,
|
||||
),
|
||||
selectedIcon: Icon(roleCode == 'delivery' ? Icons.local_shipping : Icons.assignment),
|
||||
label: roleCode == 'delivery'
|
||||
? '配送'
|
||||
: roleCode == 'operations'
|
||||
? '安检'
|
||||
: '工单',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.history),
|
||||
selectedIcon: Icon(Icons.history_toggle_off),
|
||||
label: '记录',
|
||||
),
|
||||
const NavigationDestination(
|
||||
icon: Icon(Icons.person_outline),
|
||||
selectedIcon: Icon(Icons.person),
|
||||
label: '我的',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
146
apps/service_app/lib/data/offline/encrypted_draft_store.dart
Normal file
146
apps/service_app/lib/data/offline/encrypted_draft_store.dart
Normal file
@@ -0,0 +1,146 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cryptography/cryptography.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
class EncryptedDraftStore {
|
||||
EncryptedDraftStore({
|
||||
FlutterSecureStorage? storage,
|
||||
AesGcm? algorithm,
|
||||
}) : _storage = storage ?? const FlutterSecureStorage(),
|
||||
_algorithm = algorithm ?? AesGcm.with256bits();
|
||||
|
||||
static const _keyPrefix = 'service_draft_key_';
|
||||
final FlutterSecureStorage _storage;
|
||||
final AesGcm _algorithm;
|
||||
|
||||
Future<void> saveDraft({
|
||||
required String accountIdentity,
|
||||
required String taskIdentity,
|
||||
required Map<String, Object?> value,
|
||||
}) async {
|
||||
final directory = await _accountDirectory(accountIdentity);
|
||||
final key = await _key(accountIdentity);
|
||||
final nonce = List<int>.generate(12, (_) => Random.secure().nextInt(256));
|
||||
final box = await _algorithm.encrypt(
|
||||
utf8.encode(jsonEncode(value)),
|
||||
secretKey: key,
|
||||
nonce: nonce,
|
||||
);
|
||||
final payload = jsonEncode({
|
||||
'nonce': base64Encode(box.nonce),
|
||||
'cipherText': base64Encode(box.cipherText),
|
||||
'mac': base64Encode(box.mac.bytes),
|
||||
});
|
||||
await File('${directory.path}/$taskIdentity.draft').writeAsString(payload, flush: true);
|
||||
}
|
||||
|
||||
Future<String> sealAttachment({
|
||||
required String accountIdentity,
|
||||
required String taskIdentity,
|
||||
required String stage,
|
||||
required String sourcePath,
|
||||
}) async {
|
||||
final source = File(sourcePath);
|
||||
final bytes = await source.readAsBytes();
|
||||
final box = await _encrypt(accountIdentity, bytes);
|
||||
final name = '$taskIdentity-$stage-${DateTime.now().microsecondsSinceEpoch}.evidence';
|
||||
final file = File('${(await _accountDirectory(accountIdentity)).path}/$name');
|
||||
await file.writeAsString(_boxJson(box), flush: true);
|
||||
if (source.existsSync()) await source.delete();
|
||||
return name;
|
||||
}
|
||||
|
||||
Future<String> materializeAttachment({
|
||||
required String accountIdentity,
|
||||
required String sealedName,
|
||||
}) async {
|
||||
final source = File('${(await _accountDirectory(accountIdentity)).path}/$sealedName');
|
||||
final clear = await _decrypt(accountIdentity, await source.readAsString());
|
||||
final temporary = await getTemporaryDirectory();
|
||||
final file = File('${temporary.path}/${sealedName.replaceAll('.evidence', '.jpg')}');
|
||||
await file.writeAsBytes(clear, flush: true);
|
||||
return file.path;
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>?> readDraft(String accountIdentity, String taskIdentity) async {
|
||||
final file = File('${(await _accountDirectory(accountIdentity)).path}/$taskIdentity.draft');
|
||||
if (!file.existsSync()) return null;
|
||||
final payload = jsonDecode(await file.readAsString());
|
||||
if (payload is! Map) return null;
|
||||
final clear = await _decrypt(accountIdentity, await file.readAsString());
|
||||
final decoded = jsonDecode(utf8.decode(clear));
|
||||
if (decoded is! Map) return null;
|
||||
return decoded.map<String, Object?>((key, value) => MapEntry(key.toString(), value));
|
||||
}
|
||||
|
||||
Future<int> count(String accountIdentity) async {
|
||||
final directory = await _accountDirectory(accountIdentity);
|
||||
return directory
|
||||
.listSync()
|
||||
.whereType<File>()
|
||||
.where((file) => file.path.endsWith('.draft'))
|
||||
.length;
|
||||
}
|
||||
|
||||
Future<void> deleteDraft(String accountIdentity, String taskIdentity) async {
|
||||
final directory = await _accountDirectory(accountIdentity);
|
||||
final file = File('${directory.path}/$taskIdentity.draft');
|
||||
if (file.existsSync()) await file.delete();
|
||||
for (final evidence in directory.listSync().whereType<File>().where(
|
||||
(item) => item.uri.pathSegments.last.startsWith('$taskIdentity-'),
|
||||
)) {
|
||||
await evidence.delete();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> discardAccount(String accountIdentity) async {
|
||||
final directory = await _accountDirectory(accountIdentity);
|
||||
if (directory.existsSync()) await directory.delete(recursive: true);
|
||||
await _storage.delete(key: '$_keyPrefix$accountIdentity');
|
||||
}
|
||||
|
||||
Future<Directory> _accountDirectory(String accountIdentity) async {
|
||||
final root = await getApplicationSupportDirectory();
|
||||
final directory = Directory('${root.path}/drafts/$accountIdentity');
|
||||
if (!directory.existsSync()) await directory.create(recursive: true);
|
||||
return directory;
|
||||
}
|
||||
|
||||
Future<SecretKey> _key(String accountIdentity) async {
|
||||
final storageKey = '$_keyPrefix$accountIdentity';
|
||||
var encoded = await _storage.read(key: storageKey);
|
||||
if (encoded == null) {
|
||||
encoded = base64Encode(await (await _algorithm.newSecretKey()).extractBytes());
|
||||
await _storage.write(key: storageKey, value: encoded);
|
||||
}
|
||||
return SecretKey(base64Decode(encoded));
|
||||
}
|
||||
|
||||
Future<SecretBox> _encrypt(String accountIdentity, List<int> clear) async {
|
||||
final nonce = List<int>.generate(12, (_) => Random.secure().nextInt(256));
|
||||
return _algorithm.encrypt(clear, secretKey: await _key(accountIdentity), nonce: nonce);
|
||||
}
|
||||
|
||||
Future<List<int>> _decrypt(String accountIdentity, String encodedPayload) async {
|
||||
final payload = jsonDecode(encodedPayload);
|
||||
if (payload is! Map) throw const FormatException('Invalid encrypted draft');
|
||||
return _algorithm.decrypt(
|
||||
SecretBox(
|
||||
base64Decode(payload['cipherText'] as String),
|
||||
nonce: base64Decode(payload['nonce'] as String),
|
||||
mac: Mac(base64Decode(payload['mac'] as String)),
|
||||
),
|
||||
secretKey: await _key(accountIdentity),
|
||||
);
|
||||
}
|
||||
|
||||
String _boxJson(SecretBox box) => jsonEncode({
|
||||
'nonce': base64Encode(box.nonce),
|
||||
'cipherText': base64Encode(box.cipherText),
|
||||
'mac': base64Encode(box.mac.bytes),
|
||||
});
|
||||
}
|
||||
166
apps/service_app/lib/data/repositories/service_repository.dart
Normal file
166
apps/service_app/lib/data/repositories/service_repository.dart
Normal file
@@ -0,0 +1,166 @@
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../domain/models/service_models.dart';
|
||||
import '../services/api_client.dart';
|
||||
import '../services/location_service.dart';
|
||||
|
||||
class ServiceRepository {
|
||||
ServiceRepository(this._api, this._location);
|
||||
|
||||
static const root = '/heqi/client/v1/staff';
|
||||
final ApiClient _api;
|
||||
final LocationService _location;
|
||||
|
||||
Future<StaffProfile> profile() async =>
|
||||
StaffProfile.fromJson(jsonMap(await _api.get('$root/auth/profile')));
|
||||
|
||||
Future<PreflightResult> preflight() async =>
|
||||
PreflightResult.fromJson(jsonMap(await _api.get('$root/preflight')));
|
||||
|
||||
Future<Map<String, Object?>> wallet() async => jsonMap(await _api.get('$root/wallet'));
|
||||
|
||||
Future<void> attendance(String action, String deviceIdentity) async {
|
||||
final location = await _location.current();
|
||||
await _api.post(
|
||||
'$root/attendance',
|
||||
body: {
|
||||
'action': action,
|
||||
'occurred_at': location.occurredAt.toUtc().toIso8601String(),
|
||||
'longitude': location.longitude,
|
||||
'latitude': location.latitude,
|
||||
'device_identity': deviceIdentity,
|
||||
'request_no': const Uuid().v7(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<WorkItem>> tasks(String roleCode) async {
|
||||
if (roleCode == 'delivery') {
|
||||
return jsonList(
|
||||
await _api.get('$root/delivery/orders'),
|
||||
).map(WorkItem.delivery).toList(growable: false);
|
||||
}
|
||||
return jsonList(
|
||||
await _api.get('$root/tickets'),
|
||||
).map(WorkItem.ticket).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<WorkItem> deliveryDetail(String identity) async {
|
||||
final details = jsonMap(await _api.get('$root/delivery/orders/$identity'));
|
||||
return WorkItem.delivery(jsonMap(details['order']));
|
||||
}
|
||||
|
||||
Future<WorkItem> ticketDetail(String identity) async =>
|
||||
WorkItem.ticket(jsonMap(await _api.get('$root/tickets/$identity')));
|
||||
|
||||
Future<void> start(WorkItem item, String roleCode) async {
|
||||
final path = roleCode == 'delivery'
|
||||
? '$root/delivery/orders/${item.identity}/start'
|
||||
: '$root/tickets/${item.identity}/start';
|
||||
await _api.post(path, body: {'reason': '工作人员开始执行'});
|
||||
}
|
||||
|
||||
Future<void> exception(WorkItem item, String roleCode, String reason) async {
|
||||
final path = roleCode == 'delivery'
|
||||
? '$root/delivery/orders/${item.identity}/exception'
|
||||
: '$root/tickets/${item.identity}/exception';
|
||||
await _api.post(path, body: {'reason': reason});
|
||||
}
|
||||
|
||||
Future<void> recover(WorkItem item, String roleCode, String reason) async {
|
||||
final path = roleCode == 'delivery'
|
||||
? '$root/delivery/orders/${item.identity}/recover'
|
||||
: '$root/tickets/${item.identity}/recover';
|
||||
await _api.post(path, body: {'reason': reason});
|
||||
}
|
||||
|
||||
Future<void> appendCurrentTrack(String identity) async {
|
||||
final point = await _location.current();
|
||||
await _api.post(
|
||||
'$root/delivery/orders/$identity/tracks',
|
||||
body: {
|
||||
'points': [_pointJson(point)],
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> arrive(String identity) async {
|
||||
final point = await _location.current();
|
||||
await _api.post('$root/delivery/orders/$identity/arrive', body: _pointJson(point));
|
||||
}
|
||||
|
||||
Future<void> submitDeliveryReceipt({
|
||||
required String identity,
|
||||
required String recipientName,
|
||||
required String recipientPhone,
|
||||
required String proofFile,
|
||||
}) async {
|
||||
final proofUri = await _api.upload(proofFile);
|
||||
await _api.post(
|
||||
'$root/delivery/orders/$identity/submit-receipt',
|
||||
body: {
|
||||
'request_no': const Uuid().v7(),
|
||||
'confirm_type': 'signature',
|
||||
'recipient_name': recipientName,
|
||||
'recipient_phone': recipientPhone,
|
||||
'proof_uri': proofUri,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> submitTicketResult({
|
||||
required String identity,
|
||||
required String result,
|
||||
required String conclusion,
|
||||
required List<EvidenceInput> evidence,
|
||||
}) async {
|
||||
final location = await _location.current();
|
||||
final uploaded = <Map<String, Object?>>[];
|
||||
for (final item in evidence) {
|
||||
final uri = await _api.upload(
|
||||
item.filePath,
|
||||
contentType: item.mediaType == 'video' ? 'video/mp4' : 'image/jpeg',
|
||||
);
|
||||
uploaded.add({
|
||||
'evidence_type': item.evidenceType,
|
||||
'media_type': item.mediaType,
|
||||
'file_uri': uri,
|
||||
'captured_at': item.capturedAt.toUtc().toIso8601String(),
|
||||
'longitude': location.longitude,
|
||||
'latitude': location.latitude,
|
||||
'request_no': item.requestNo,
|
||||
});
|
||||
}
|
||||
await _api.post(
|
||||
'$root/tickets/$identity/submit-result',
|
||||
body: {'result': result, 'conclusion': conclusion, 'evidences': uploaded},
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _pointJson(LocationPoint point) => {
|
||||
'request_no': const Uuid().v7(),
|
||||
'longitude': point.longitude,
|
||||
'latitude': point.latitude,
|
||||
'occurred_at': point.occurredAt.toUtc().toIso8601String(),
|
||||
'source': 'gps',
|
||||
'accuracy': point.accuracy,
|
||||
'speed': '',
|
||||
'direction': '',
|
||||
};
|
||||
}
|
||||
|
||||
class EvidenceInput {
|
||||
const EvidenceInput({
|
||||
required this.evidenceType,
|
||||
required this.mediaType,
|
||||
required this.filePath,
|
||||
required this.capturedAt,
|
||||
required this.requestNo,
|
||||
});
|
||||
|
||||
final String evidenceType;
|
||||
final String mediaType;
|
||||
final String filePath;
|
||||
final DateTime capturedAt;
|
||||
final String requestNo;
|
||||
}
|
||||
93
apps/service_app/lib/data/services/api_client.dart
Normal file
93
apps/service_app/lib/data/services/api_client.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
class ApiException implements Exception {
|
||||
const ApiException(this.code, this.message);
|
||||
|
||||
final int code;
|
||||
final String message;
|
||||
|
||||
@override
|
||||
String toString() => message;
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
ApiClient(this._tokenProvider, {http.Client? client, String? baseUrl})
|
||||
: _client = client ?? http.Client(),
|
||||
baseUrl =
|
||||
baseUrl ??
|
||||
const String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://10.0.2.2:12426',
|
||||
);
|
||||
|
||||
final String baseUrl;
|
||||
final String Function() _tokenProvider;
|
||||
final http.Client _client;
|
||||
|
||||
Future<Object?> get(String path, {bool authenticated = true}) =>
|
||||
_send('GET', path, authenticated: authenticated);
|
||||
|
||||
Future<Object?> post(
|
||||
String path, {
|
||||
Map<String, Object?>? body,
|
||||
bool authenticated = true,
|
||||
}) => _send('POST', path, body: body, authenticated: authenticated);
|
||||
|
||||
Future<Object?> put(String path, {Map<String, Object?>? body}) => _send('PUT', path, body: body);
|
||||
|
||||
Future<String> upload(String filePath, {String contentType = 'image/jpeg'}) async {
|
||||
final request = http.MultipartRequest('POST', Uri.parse('$baseUrl/upload/file'));
|
||||
request.headers[HttpHeaders.authorizationHeader] = _tokenProvider();
|
||||
request.fields['declared_content_type'] = contentType;
|
||||
request.files.add(await http.MultipartFile.fromPath('file', filePath));
|
||||
final response = await http.Response.fromStream(await request.send());
|
||||
final details = _decode(response);
|
||||
return jsonMap(details)['uri'] as String? ?? '';
|
||||
}
|
||||
|
||||
Future<Object?> _send(
|
||||
String method,
|
||||
String path, {
|
||||
Map<String, Object?>? body,
|
||||
bool authenticated = true,
|
||||
}) async {
|
||||
final request = http.Request(method, Uri.parse('$baseUrl$path'));
|
||||
request.headers[HttpHeaders.acceptHeader] = 'application/json';
|
||||
if (authenticated && _tokenProvider().isNotEmpty) {
|
||||
request.headers[HttpHeaders.authorizationHeader] = _tokenProvider();
|
||||
}
|
||||
if (body != null) {
|
||||
request.headers[HttpHeaders.contentTypeHeader] = 'application/json; charset=UTF-8';
|
||||
request.body = jsonEncode(body);
|
||||
}
|
||||
final response = await http.Response.fromStream(await _client.send(request));
|
||||
return _decode(response);
|
||||
}
|
||||
|
||||
Object? _decode(http.Response response) {
|
||||
if (response.statusCode < 200 || response.statusCode >= 300) {
|
||||
throw ApiException(response.statusCode, '网络请求失败(${response.statusCode})');
|
||||
}
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, Object?>) {
|
||||
throw const ApiException(500, '服务端响应格式错误');
|
||||
}
|
||||
final code = (decoded['code'] as num?)?.toInt() ?? 500;
|
||||
if (code != 0) throw ApiException(code, decoded['message'] as String? ?? '操作失败');
|
||||
return decoded['details'];
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, Object?> jsonMap(Object? value) {
|
||||
if (value is Map<String, Object?>) return value;
|
||||
if (value is Map) return value.map((key, item) => MapEntry(key.toString(), item));
|
||||
throw const ApiException(500, '服务端数据格式错误');
|
||||
}
|
||||
|
||||
List<Map<String, Object?>> jsonList(Object? value) {
|
||||
if (value is! List) return const [];
|
||||
return value.map<Map<String, Object?>>(jsonMap).toList(growable: false);
|
||||
}
|
||||
42
apps/service_app/lib/data/services/location_service.dart
Normal file
42
apps/service_app/lib/data/services/location_service.dart
Normal file
@@ -0,0 +1,42 @@
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
class LocationPoint {
|
||||
const LocationPoint({
|
||||
required this.longitude,
|
||||
required this.latitude,
|
||||
required this.accuracy,
|
||||
required this.occurredAt,
|
||||
});
|
||||
|
||||
final String longitude;
|
||||
final String latitude;
|
||||
final String accuracy;
|
||||
final DateTime occurredAt;
|
||||
}
|
||||
|
||||
abstract interface class LocationService {
|
||||
Future<LocationPoint> current();
|
||||
}
|
||||
|
||||
class GeolocatorLocationService implements LocationService {
|
||||
@override
|
||||
Future<LocationPoint> current() async {
|
||||
if (!await Geolocator.isLocationServiceEnabled()) {
|
||||
throw StateError('请先开启系统定位服务');
|
||||
}
|
||||
var permission = await Geolocator.checkPermission();
|
||||
if (permission == LocationPermission.denied) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
if (permission == LocationPermission.denied || permission == LocationPermission.deniedForever) {
|
||||
throw StateError('定位权限未授权,无法完成该在线动作');
|
||||
}
|
||||
final position = await Geolocator.getCurrentPosition();
|
||||
return LocationPoint(
|
||||
longitude: position.longitude.toStringAsFixed(7),
|
||||
latitude: position.latitude.toStringAsFixed(7),
|
||||
accuracy: position.accuracy.toStringAsFixed(1),
|
||||
occurredAt: position.timestamp,
|
||||
);
|
||||
}
|
||||
}
|
||||
12
apps/service_app/lib/data/services/secure_session_store.dart
Normal file
12
apps/service_app/lib/data/services/secure_session_store.dart
Normal file
@@ -0,0 +1,12 @@
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class SecureSessionStore {
|
||||
SecureSessionStore({FlutterSecureStorage? storage})
|
||||
: _storage = storage ?? const FlutterSecureStorage();
|
||||
|
||||
final FlutterSecureStorage _storage;
|
||||
|
||||
Future<String?> read(String key) => _storage.read(key: key);
|
||||
Future<void> write(String key, String value) => _storage.write(key: key, value: value);
|
||||
Future<void> delete(String key) => _storage.delete(key: key);
|
||||
}
|
||||
125
apps/service_app/lib/domain/models/service_models.dart
Normal file
125
apps/service_app/lib/domain/models/service_models.dart
Normal file
@@ -0,0 +1,125 @@
|
||||
class StaffProfile {
|
||||
const StaffProfile({
|
||||
required this.identity,
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.roleCode,
|
||||
required this.workStatus,
|
||||
});
|
||||
|
||||
final String identity;
|
||||
final String name;
|
||||
final String phone;
|
||||
final String roleCode;
|
||||
final String workStatus;
|
||||
|
||||
factory StaffProfile.fromJson(Map<String, Object?> json) => StaffProfile(
|
||||
identity: json['identity'] as String? ?? '',
|
||||
name: json['name'] as String? ?? '',
|
||||
phone: json['phone'] as String? ?? '',
|
||||
roleCode: json['role_code'] as String? ?? '',
|
||||
workStatus: json['work_status'] as String? ?? 'off_duty',
|
||||
);
|
||||
}
|
||||
|
||||
class PreflightResult {
|
||||
const PreflightResult({
|
||||
required this.roleCode,
|
||||
required this.workStatus,
|
||||
required this.canWork,
|
||||
required this.checks,
|
||||
});
|
||||
|
||||
final String roleCode;
|
||||
final String workStatus;
|
||||
final bool canWork;
|
||||
final Map<String, Object?> checks;
|
||||
|
||||
factory PreflightResult.fromJson(Map<String, Object?> json) => PreflightResult(
|
||||
roleCode: json['role_code'] as String? ?? '',
|
||||
workStatus: json['work_status'] as String? ?? 'off_duty',
|
||||
canWork: json['can_work'] as bool? ?? false,
|
||||
checks: _map(json['checks']),
|
||||
);
|
||||
}
|
||||
|
||||
class WorkItem {
|
||||
const WorkItem({
|
||||
required this.identity,
|
||||
required this.number,
|
||||
required this.title,
|
||||
required this.address,
|
||||
required this.status,
|
||||
required this.allowedActions,
|
||||
required this.raw,
|
||||
});
|
||||
|
||||
final String identity;
|
||||
final String number;
|
||||
final String title;
|
||||
final String address;
|
||||
final int status;
|
||||
final List<String> allowedActions;
|
||||
final Map<String, Object?> raw;
|
||||
|
||||
factory WorkItem.delivery(Map<String, Object?> json) => WorkItem(
|
||||
identity: json['identity'] as String? ?? '',
|
||||
number: json['order_no'] as String? ?? '',
|
||||
title: '燃气配送',
|
||||
address: json['address'] as String? ?? '',
|
||||
status: (json['order_status'] as num?)?.toInt() ?? 0,
|
||||
allowedActions: (json['allowed_actions'] as List? ?? const [])
|
||||
.map((item) => item.toString())
|
||||
.toList(growable: false),
|
||||
raw: json,
|
||||
);
|
||||
|
||||
factory WorkItem.ticket(Map<String, Object?> json) => WorkItem(
|
||||
identity: json['identity'] as String? ?? '',
|
||||
number: json['ticket_no'] as String? ?? '',
|
||||
title: _ticketTitle(json['category'] as String? ?? ''),
|
||||
address: json['address'] as String? ?? '',
|
||||
status: (json['ticket_status'] as num?)?.toInt() ?? 0,
|
||||
allowedActions: _ticketActions((json['ticket_status'] as num?)?.toInt() ?? 0),
|
||||
raw: json,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, Object?> _map(Object? value) {
|
||||
if (value is Map<String, Object?>) return value;
|
||||
if (value is Map) return value.map((key, item) => MapEntry(key.toString(), item));
|
||||
return const {};
|
||||
}
|
||||
|
||||
String _ticketTitle(String category) => switch (category) {
|
||||
'installation' => '安装任务',
|
||||
'repair' => '维修任务',
|
||||
'inspection' => '安全检查',
|
||||
'reinspection' => '复检任务',
|
||||
_ => '服务任务',
|
||||
};
|
||||
|
||||
List<String> _ticketActions(int status) => switch (status) {
|
||||
18 => const ['start'],
|
||||
11 => const ['submit_result', 'exception'],
|
||||
21 => const ['recover'],
|
||||
_ => const [],
|
||||
};
|
||||
|
||||
String roleName(String role) => switch (role) {
|
||||
'delivery' => '配送员',
|
||||
'installer' => '安装维修员',
|
||||
'operations' => '安检员',
|
||||
_ => '工作人员',
|
||||
};
|
||||
|
||||
String statusName(int status) => switch (status) {
|
||||
11 => '处理中',
|
||||
18 => '已分派',
|
||||
20 => '已就绪',
|
||||
21 => '异常',
|
||||
23 => '已完成',
|
||||
33 => '配送中',
|
||||
34 => '待确认',
|
||||
_ => '状态 $status',
|
||||
};
|
||||
10
apps/service_app/lib/main.dart
Normal file
10
apps/service_app/lib/main.dart
Normal file
@@ -0,0 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app/app.dart';
|
||||
import 'app/dependencies.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
final dependencies = await AppDependencies.create();
|
||||
runApp(ServiceClientApp(dependencies: dependencies));
|
||||
}
|
||||
36
apps/service_app/lib/ui/core/app_theme.dart
Normal file
36
apps/service_app/lib/ui/core/app_theme.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData light() {
|
||||
final scheme = ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF6C4CF1),
|
||||
primary: const Color(0xFF6C4CF1),
|
||||
secondary: const Color(0xFF16A66A),
|
||||
surface: Colors.white,
|
||||
);
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: scheme,
|
||||
scaffoldBackgroundColor: const Color(0xFFF5F4FA),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
116
apps/service_app/lib/ui/features/auth/login_page.dart
Normal file
116
apps/service_app/lib/ui/features/auth/login_page.dart
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../app/dependencies.dart';
|
||||
|
||||
class LoginPage extends StatefulWidget {
|
||||
const LoginPage({required this.session, super.key});
|
||||
|
||||
final StaffSession session;
|
||||
|
||||
@override
|
||||
State<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends State<LoginPage> {
|
||||
final _phone = TextEditingController();
|
||||
final _password = TextEditingController();
|
||||
bool _busy = false;
|
||||
String? _error;
|
||||
|
||||
Future<void> _login() async {
|
||||
setState(() {
|
||||
_busy = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
await widget.session.login(_phone.text.trim(), _password.text);
|
||||
} catch (error) {
|
||||
if (mounted) setState(() => _error = error.toString());
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phone.dispose();
|
||||
_password.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.engineering_rounded,
|
||||
size: 72,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'瓶安芯服务工作台',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.headlineMedium?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text('配送、安装维修与安检共用入口', textAlign: TextAlign.center),
|
||||
const SizedBox(height: 34),
|
||||
TextField(
|
||||
controller: _phone,
|
||||
keyboardType: TextInputType.phone,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '工作人员手机号',
|
||||
prefixIcon: Icon(Icons.phone_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _password,
|
||||
obscureText: true,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '登录密码',
|
||||
prefixIcon: Icon(Icons.lock_outline),
|
||||
),
|
||||
),
|
||||
if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
_error!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
ElevatedButton(
|
||||
onPressed: _busy ? null : _login,
|
||||
child: _busy
|
||||
? const SizedBox.square(
|
||||
dimension: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text('进入工作台'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
const Text(
|
||||
'账号与角色由平台审核分配,不提供自助注册',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
225
apps/service_app/lib/ui/features/evidence/evidence_page.dart
Normal file
225
apps/service_app/lib/ui/features/evidence/evidence_page.dart
Normal file
@@ -0,0 +1,225 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../app/dependencies.dart';
|
||||
import '../../../data/offline/encrypted_draft_store.dart';
|
||||
import '../../../data/repositories/service_repository.dart';
|
||||
|
||||
class EvidencePage extends StatefulWidget {
|
||||
const EvidencePage({
|
||||
required this.session,
|
||||
required this.repository,
|
||||
required this.drafts,
|
||||
required this.taskIdentity,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StaffSession session;
|
||||
final ServiceRepository repository;
|
||||
final EncryptedDraftStore drafts;
|
||||
final String taskIdentity;
|
||||
|
||||
@override
|
||||
State<EvidencePage> createState() => _EvidencePageState();
|
||||
}
|
||||
|
||||
class _EvidencePageState extends State<EvidencePage> {
|
||||
final _picker = ImagePicker();
|
||||
final _result = TextEditingController();
|
||||
final Map<String, Map<String, Object?>> _evidence = {};
|
||||
bool _busy = false;
|
||||
String _conclusion = 'qualified';
|
||||
|
||||
List<String> get _requiredStages => widget.session.roleCode == 'operations'
|
||||
? const ['inspection', 'signature']
|
||||
: const ['before', 'during', 'after', 'signature'];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_restore();
|
||||
}
|
||||
|
||||
Future<void> _restore() async {
|
||||
final draft = await widget.drafts.readDraft(widget.session.identity, widget.taskIdentity);
|
||||
if (draft == null || !mounted) return;
|
||||
final values = draft['evidence'];
|
||||
if (values is List) {
|
||||
for (final value in values.whereType<Map<Object?, Object?>>()) {
|
||||
final mapped = value.map<String, Object?>((key, item) => MapEntry(key.toString(), item));
|
||||
final stage = mapped['stage'] as String? ?? '';
|
||||
if (stage.isNotEmpty) _evidence[stage] = mapped;
|
||||
}
|
||||
}
|
||||
_result.text = draft['result'] as String? ?? '';
|
||||
_conclusion = draft['conclusion'] as String? ?? 'qualified';
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _capture(String stage) async {
|
||||
final image = await _picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 82,
|
||||
maxWidth: 1800,
|
||||
);
|
||||
if (image == null) return;
|
||||
final sealedName = await widget.drafts.sealAttachment(
|
||||
accountIdentity: widget.session.identity,
|
||||
taskIdentity: widget.taskIdentity,
|
||||
stage: stage,
|
||||
sourcePath: image.path,
|
||||
);
|
||||
_evidence[stage] = {
|
||||
'stage': stage,
|
||||
'sealed_name': sealedName,
|
||||
'captured_at': DateTime.now().toUtc().toIso8601String(),
|
||||
'request_no': const Uuid().v7(),
|
||||
'media_type': stage == 'signature' ? 'signature' : 'image',
|
||||
};
|
||||
await _save();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _save() => widget.drafts.saveDraft(
|
||||
accountIdentity: widget.session.identity,
|
||||
taskIdentity: widget.taskIdentity,
|
||||
value: {
|
||||
'task_identity': widget.taskIdentity,
|
||||
'result': _result.text,
|
||||
'conclusion': _conclusion,
|
||||
'updated_at': DateTime.now().toUtc().toIso8601String(),
|
||||
'evidence': _evidence.values.toList(),
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_requiredStages.every(_evidence.containsKey) || _result.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请完成结果说明和全部必需取证项')));
|
||||
return;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
final temporaryFiles = <String>[];
|
||||
try {
|
||||
await _save();
|
||||
final inputs = <EvidenceInput>[];
|
||||
for (final item in _evidence.values) {
|
||||
final path = await widget.drafts.materializeAttachment(
|
||||
accountIdentity: widget.session.identity,
|
||||
sealedName: item['sealed_name'] as String,
|
||||
);
|
||||
temporaryFiles.add(path);
|
||||
inputs.add(
|
||||
EvidenceInput(
|
||||
evidenceType: item['stage'] as String,
|
||||
mediaType: item['media_type'] as String,
|
||||
filePath: path,
|
||||
capturedAt: DateTime.parse(item['captured_at'] as String),
|
||||
requestNo: item['request_no'] as String,
|
||||
),
|
||||
);
|
||||
}
|
||||
await widget.repository.submitTicketResult(
|
||||
identity: widget.taskIdentity,
|
||||
result: _result.text.trim(),
|
||||
conclusion: _conclusion,
|
||||
evidence: inputs,
|
||||
);
|
||||
await widget.drafts.deleteDraft(widget.session.identity, widget.taskIdentity);
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('提交失败,草稿仍安全保留:$error')));
|
||||
}
|
||||
} finally {
|
||||
for (final path in temporaryFiles) {
|
||||
final file = File(path);
|
||||
if (file.existsSync()) await file.delete();
|
||||
}
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_result.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('现场取证')),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(18),
|
||||
children: [
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(18),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline),
|
||||
SizedBox(width: 12),
|
||||
Expanded(child: Text('照片与签名先按当前账号加密暂存;上传成功前仅显示“已暂存”。')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
..._requiredStages.map(
|
||||
(stage) => Card(
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
_evidence.containsKey(stage) ? Icons.check_circle : Icons.camera_alt_outlined,
|
||||
color: _evidence.containsKey(stage) ? Colors.green : null,
|
||||
),
|
||||
title: Text(_stageName(stage)),
|
||||
subtitle: Text(_evidence.containsKey(stage) ? '已加密暂存' : '尚未采集'),
|
||||
trailing: TextButton(
|
||||
onPressed: _busy ? null : () => _capture(stage),
|
||||
child: Text(_evidence.containsKey(stage) ? '重拍' : '拍摄'),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _result,
|
||||
maxLines: 4,
|
||||
onChanged: (_) => _save(),
|
||||
decoration: const InputDecoration(labelText: '现场结果说明'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _conclusion,
|
||||
decoration: const InputDecoration(labelText: '结论'),
|
||||
items: const [
|
||||
DropdownMenuItem(value: 'qualified', child: Text('合格')),
|
||||
DropdownMenuItem(value: 'noncompliant', child: Text('不合格')),
|
||||
DropdownMenuItem(value: 'high_risk', child: Text('高风险')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) return;
|
||||
setState(() => _conclusion = value);
|
||||
_save();
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
ElevatedButton(
|
||||
onPressed: _busy ? null : _submit,
|
||||
child: Text(_busy ? '正在上传并等待服务端确认…' : '在线提交结果'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
String _stageName(String stage) => switch (stage) {
|
||||
'before' => '作业前照片',
|
||||
'during' => '作业中照片',
|
||||
'after' => '作业后照片',
|
||||
'inspection' => '检查现场照片',
|
||||
'signature' => '用户签名图片',
|
||||
_ => stage,
|
||||
};
|
||||
}
|
||||
143
apps/service_app/lib/ui/features/preflight/preflight_page.dart
Normal file
143
apps/service_app/lib/ui/features/preflight/preflight_page.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/dependencies.dart';
|
||||
import '../../../data/repositories/service_repository.dart';
|
||||
import '../../../domain/models/service_models.dart';
|
||||
|
||||
class PreflightPage extends StatefulWidget {
|
||||
const PreflightPage({required this.session, required this.repository, super.key});
|
||||
|
||||
final StaffSession session;
|
||||
final ServiceRepository repository;
|
||||
|
||||
@override
|
||||
State<PreflightPage> createState() => _PreflightPageState();
|
||||
}
|
||||
|
||||
class _PreflightPageState extends State<PreflightPage> {
|
||||
late Future<PreflightResult> _future;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = widget.repository.preflight();
|
||||
}
|
||||
|
||||
Future<void> _attendance(String action) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await widget.repository.attendance(action, widget.session.deviceIdentity);
|
||||
setState(() => _future = widget.repository.preflight());
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('作业前检查')),
|
||||
body: FutureBuilder<PreflightResult>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
if (snapshot.hasError) return Center(child: Text(snapshot.error.toString()));
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final result = snapshot.data!;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(18),
|
||||
children: [
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(22),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(roleName(result.roleCode), style: const TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
result.canWork ? '可以开始今日作业' : '仍有前置条件未完成',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w900,
|
||||
fontSize: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...result.checks.entries.map((entry) {
|
||||
final detail = entry.value is Map
|
||||
? Map<Object?, Object?>.from(entry.value as Map)
|
||||
: const <Object?, Object?>{};
|
||||
final status = detail['status']?.toString() ?? 'blocked';
|
||||
final passed = status == 'passed';
|
||||
return Card(
|
||||
child: ListTile(
|
||||
leading: Icon(
|
||||
passed
|
||||
? Icons.check_circle
|
||||
: status == 'not_configured'
|
||||
? Icons.info_outline
|
||||
: Icons.cancel,
|
||||
color: passed
|
||||
? Colors.green
|
||||
: status == 'not_configured'
|
||||
? Colors.orange
|
||||
: Colors.red,
|
||||
),
|
||||
title: Text(_label(entry.key)),
|
||||
subtitle: Text(
|
||||
status == 'not_configured'
|
||||
? '平台暂未启用,不冒充校验通过'
|
||||
: passed
|
||||
? '已通过服务端校验'
|
||||
: '未通过',
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 18),
|
||||
if (result.workStatus != 'on_duty')
|
||||
ElevatedButton.icon(
|
||||
onPressed: _busy ? null : () => _attendance('clock_in'),
|
||||
icon: const Icon(Icons.location_on),
|
||||
label: const Text('定位并上班打卡'),
|
||||
)
|
||||
else ...[
|
||||
ElevatedButton(
|
||||
onPressed: result.canWork ? () => context.go('/work') : null,
|
||||
child: const Text('进入工作台'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _busy ? null : () => _attendance('clock_out'),
|
||||
child: const Text('下班打卡'),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
String _label(String value) => switch (value) {
|
||||
'account' => '账号状态',
|
||||
'role' => '岗位',
|
||||
'organization' => '所属组织',
|
||||
'credential' => '人员资质',
|
||||
'attendance' => '上班状态',
|
||||
'daily_training' => '每日培训',
|
||||
'service_area' => '服务区域',
|
||||
'authorized_device' => '授权设备',
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
143
apps/service_app/lib/ui/features/profile/profile_page.dart
Normal file
143
apps/service_app/lib/ui/features/profile/profile_page.dart
Normal file
@@ -0,0 +1,143 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/dependencies.dart';
|
||||
import '../../../data/offline/encrypted_draft_store.dart';
|
||||
import '../../../data/repositories/service_repository.dart';
|
||||
import '../../../domain/models/service_models.dart';
|
||||
|
||||
class ProfilePage extends StatefulWidget {
|
||||
const ProfilePage({
|
||||
required this.session,
|
||||
required this.repository,
|
||||
required this.drafts,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StaffSession session;
|
||||
final ServiceRepository repository;
|
||||
final EncryptedDraftStore drafts;
|
||||
|
||||
@override
|
||||
State<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends State<ProfilePage> {
|
||||
late Future<(StaffProfile, Map<String, Object?>, int)> _future;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<(StaffProfile, Map<String, Object?>, int)> _load() async => (
|
||||
await widget.repository.profile(),
|
||||
await widget.repository.wallet(),
|
||||
await widget.drafts.count(widget.session.identity),
|
||||
);
|
||||
|
||||
Future<void> _logout(int draftCount) async {
|
||||
if (draftCount > 0) {
|
||||
final discard = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('仍有未同步现场草稿'),
|
||||
content: Text('当前账号有 $draftCount 份加密草稿。建议返回任务页完成上传;若确认放弃,将安全删除草稿和附件。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('返回上传')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('放弃并删除')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (discard != true) return;
|
||||
await widget.drafts.discardAccount(widget.session.identity);
|
||||
}
|
||||
await widget.session.logout();
|
||||
if (mounted) context.go('/login');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('我的工作台')),
|
||||
body: FutureBuilder<(StaffProfile, Map<String, Object?>, int)>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
if (snapshot.hasError) return Center(child: Text(snapshot.error.toString()));
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final (profile, wallet, drafts) = snapshot.data!;
|
||||
final balance = (wallet['balance'] as num?)?.toInt() ?? 0;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 30,
|
||||
child: Text(profile.name.isEmpty ? '工' : profile.name.substring(0, 1)),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
profile.name,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
|
||||
),
|
||||
Text('${roleName(profile.roleCode)} · ${profile.phone}'),
|
||||
],
|
||||
),
|
||||
),
|
||||
Chip(label: Text(profile.workStatus == 'on_duty' ? '在岗' : '离岗')),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.account_balance_wallet_outlined),
|
||||
title: const Text('钱包余额'),
|
||||
trailing: Text(
|
||||
'¥${(balance / 100).toStringAsFixed(2)}',
|
||||
style: const TextStyle(fontWeight: FontWeight.w900),
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.lock_outline),
|
||||
title: const Text('加密现场草稿'),
|
||||
subtitle: const Text('仅当前账号重新认证后可恢复'),
|
||||
trailing: Text('$drafts 份'),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.verified_user_outlined),
|
||||
title: const Text('重新执行作业前检查'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => context.go('/preflight'),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.logout),
|
||||
title: const Text('退出登录'),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _logout(drafts),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
290
apps/service_app/lib/ui/features/work/work_detail_page.dart
Normal file
290
apps/service_app/lib/ui/features/work/work_detail_page.dart
Normal file
@@ -0,0 +1,290 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
import '../../../app/dependencies.dart';
|
||||
import '../../../data/offline/encrypted_draft_store.dart';
|
||||
import '../../../data/repositories/service_repository.dart';
|
||||
import '../../../domain/models/service_models.dart';
|
||||
|
||||
class WorkDetailPage extends StatefulWidget {
|
||||
const WorkDetailPage({
|
||||
required this.session,
|
||||
required this.repository,
|
||||
required this.drafts,
|
||||
required this.identity,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StaffSession session;
|
||||
final ServiceRepository repository;
|
||||
final EncryptedDraftStore drafts;
|
||||
final String identity;
|
||||
|
||||
@override
|
||||
State<WorkDetailPage> createState() => _WorkDetailPageState();
|
||||
}
|
||||
|
||||
class _WorkDetailPageState extends State<WorkDetailPage> {
|
||||
late Future<WorkItem> _future;
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_future = _load();
|
||||
}
|
||||
|
||||
Future<WorkItem> _load() => widget.session.roleCode == 'delivery'
|
||||
? widget.repository.deliveryDetail(widget.identity)
|
||||
: widget.repository.ticketDetail(widget.identity);
|
||||
|
||||
Future<void> _run(Future<void> Function(WorkItem) action, WorkItem item) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await action(item);
|
||||
setState(() => _future = _load());
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(error.toString())));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _reason() async {
|
||||
final controller = TextEditingController();
|
||||
final value = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('填写原因'),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
maxLines: 3,
|
||||
decoration: const InputDecoration(labelText: '原因'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
child: const Text('确认'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
return value;
|
||||
}
|
||||
|
||||
Future<void> _receipt(WorkItem item) async {
|
||||
final existing = await widget.drafts.readDraft(widget.session.identity, item.identity);
|
||||
if (!mounted) return;
|
||||
String? sealedName;
|
||||
if (existing?['kind'] == 'delivery_receipt' && existing?['sealed_name'] is String) {
|
||||
final reuse = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('发现未提交签收草稿'),
|
||||
content: const Text('是否继续提交上次加密保存的签收凭证?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('重新拍摄')),
|
||||
FilledButton(onPressed: () => Navigator.pop(context, true), child: const Text('继续提交')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (reuse == true) sealedName = existing!['sealed_name'] as String;
|
||||
}
|
||||
if (sealedName == null) {
|
||||
final image = await ImagePicker().pickImage(source: ImageSource.camera, imageQuality: 82);
|
||||
if (image == null) return;
|
||||
sealedName = await widget.drafts.sealAttachment(
|
||||
accountIdentity: widget.session.identity,
|
||||
taskIdentity: item.identity,
|
||||
stage: 'receipt',
|
||||
sourcePath: image.path,
|
||||
);
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
String? temporaryPath;
|
||||
try {
|
||||
await widget.drafts.saveDraft(
|
||||
accountIdentity: widget.session.identity,
|
||||
taskIdentity: item.identity,
|
||||
value: {
|
||||
'task_identity': item.identity,
|
||||
'kind': 'delivery_receipt',
|
||||
'sealed_name': sealedName,
|
||||
'request_no': const Uuid().v7(),
|
||||
'captured_at': DateTime.now().toUtc().toIso8601String(),
|
||||
},
|
||||
);
|
||||
temporaryPath = await widget.drafts.materializeAttachment(
|
||||
accountIdentity: widget.session.identity,
|
||||
sealedName: sealedName,
|
||||
);
|
||||
await widget.repository.submitDeliveryReceipt(
|
||||
identity: item.identity,
|
||||
recipientName: item.raw['contact_name'] as String? ?? '收货人',
|
||||
recipientPhone: item.raw['contact_phone'] as String? ?? '',
|
||||
proofFile: temporaryPath,
|
||||
);
|
||||
await widget.drafts.deleteDraft(widget.session.identity, item.identity);
|
||||
if (mounted) setState(() => _future = _load());
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('签收提交失败,加密草稿已保留:$error')));
|
||||
}
|
||||
} finally {
|
||||
if (temporaryPath != null) {
|
||||
final file = File(temporaryPath);
|
||||
if (file.existsSync()) await file.delete();
|
||||
}
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('任务详情')),
|
||||
body: FutureBuilder<WorkItem>(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
if (snapshot.hasError) return Center(child: Text(snapshot.error.toString()));
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final item = snapshot.data!;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(18),
|
||||
children: [
|
||||
Card(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(22),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(item.number, style: const TextStyle(color: Colors.white70)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
item.title,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Chip(label: Text(statusName(item.status))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.location_on_outlined),
|
||||
title: const Text('服务地址'),
|
||||
subtitle: Text(item.address.isEmpty ? '未提供地址' : item.address),
|
||||
),
|
||||
),
|
||||
Card(
|
||||
child: ListTile(
|
||||
leading: const Icon(Icons.person_outline),
|
||||
title: Text(item.raw['contact_name'] as String? ?? '服务用户'),
|
||||
subtitle: Text(item.raw['contact_phone'] as String? ?? ''),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (item.allowedActions.contains('start'))
|
||||
ElevatedButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => _run(
|
||||
(value) => widget.repository.start(value, widget.session.roleCode),
|
||||
item,
|
||||
),
|
||||
child: const Text('开始处理'),
|
||||
),
|
||||
if (item.allowedActions.contains('append_tracks'))
|
||||
OutlinedButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => _run(
|
||||
(value) => widget.repository.appendCurrentTrack(value.identity),
|
||||
item,
|
||||
),
|
||||
child: const Text('上报当前位置'),
|
||||
),
|
||||
if (item.allowedActions.contains('arrive'))
|
||||
ElevatedButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () => _run((value) => widget.repository.arrive(value.identity), item),
|
||||
child: const Text('到达并校验围栏'),
|
||||
),
|
||||
if (item.allowedActions.contains('submit_receipt'))
|
||||
ElevatedButton(
|
||||
onPressed: _busy ? null : () => _receipt(item),
|
||||
child: const Text('拍摄签收凭证并提交'),
|
||||
),
|
||||
if (item.allowedActions.contains('submit_result'))
|
||||
ElevatedButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () async {
|
||||
final changed = await context.push<bool>(
|
||||
'/tasks/${item.identity}/evidence',
|
||||
);
|
||||
if (changed == true) setState(() => _future = _load());
|
||||
},
|
||||
child: const Text('现场取证与提交'),
|
||||
),
|
||||
if (item.allowedActions.contains('exception'))
|
||||
TextButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () async {
|
||||
final reason = await _reason();
|
||||
if (reason != null && reason.isNotEmpty) {
|
||||
await _run(
|
||||
(value) =>
|
||||
widget.repository.exception(value, widget.session.roleCode, reason),
|
||||
item,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('标记异常'),
|
||||
),
|
||||
if (item.allowedActions.contains('recover'))
|
||||
ElevatedButton(
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () async {
|
||||
final reason = await _reason();
|
||||
if (reason != null && reason.isNotEmpty) {
|
||||
await _run(
|
||||
(value) =>
|
||||
widget.repository.recover(value, widget.session.roleCode, reason),
|
||||
item,
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('恢复任务'),
|
||||
),
|
||||
if (_busy)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
117
apps/service_app/lib/ui/features/work/work_list_page.dart
Normal file
117
apps/service_app/lib/ui/features/work/work_list_page.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../../app/dependencies.dart';
|
||||
import '../../../data/repositories/service_repository.dart';
|
||||
import '../../../domain/models/service_models.dart';
|
||||
import 'work_list_view_model.dart';
|
||||
|
||||
class WorkListPage extends StatefulWidget {
|
||||
const WorkListPage({
|
||||
required this.session,
|
||||
required this.repository,
|
||||
required this.completed,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final StaffSession session;
|
||||
final ServiceRepository repository;
|
||||
final bool completed;
|
||||
|
||||
@override
|
||||
State<WorkListPage> createState() => _WorkListPageState();
|
||||
}
|
||||
|
||||
class _WorkListPageState extends State<WorkListPage> {
|
||||
late final WorkListViewModel _viewModel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_viewModel = WorkListViewModel(
|
||||
widget.repository,
|
||||
widget.session.roleCode,
|
||||
completed: widget.completed,
|
||||
)..load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_viewModel.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.completed ? '作业记录' : '${roleName(widget.session.roleCode)}工作台'),
|
||||
),
|
||||
body: ListenableBuilder(
|
||||
listenable: _viewModel,
|
||||
builder: (context, _) {
|
||||
if (_viewModel.loading && _viewModel.items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_viewModel.error != null && _viewModel.items.isEmpty) {
|
||||
return Center(child: Text(_viewModel.error.toString()));
|
||||
}
|
||||
return RefreshIndicator(
|
||||
onRefresh: _viewModel.load,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 28),
|
||||
children: [
|
||||
Card(
|
||||
color: widget.session.roleCode == 'operations'
|
||||
? const Color(0xFFE9F8F0)
|
||||
: const Color(0xFFEEEAFE),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
widget.session.roleCode == 'delivery'
|
||||
? Icons.local_shipping
|
||||
: widget.session.roleCode == 'installer'
|
||||
? Icons.handyman
|
||||
: Icons.health_and_safety,
|
||||
size: 38,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Text(
|
||||
widget.completed ? '服务端确认完成的历史记录' : '仅展示服务端分派给本账号的任务',
|
||||
style: const TextStyle(fontWeight: FontWeight.w800),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_viewModel.items.isEmpty)
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(42),
|
||||
child: Center(child: Text('暂无任务')),
|
||||
)
|
||||
else
|
||||
..._viewModel.items.map(
|
||||
(item) => Card(
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
title: Text(item.title, style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text('${item.number}\n${item.address}'),
|
||||
),
|
||||
trailing: Chip(label: Text(statusName(item.status))),
|
||||
onTap: () => context.push('/tasks/${item.identity}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../../data/repositories/service_repository.dart';
|
||||
import '../../../domain/models/service_models.dart';
|
||||
|
||||
class WorkListViewModel extends ChangeNotifier {
|
||||
WorkListViewModel(this._repository, this._roleCode, {required this.completed});
|
||||
|
||||
final ServiceRepository _repository;
|
||||
final String _roleCode;
|
||||
final bool completed;
|
||||
List<WorkItem> _items = const [];
|
||||
Object? _error;
|
||||
bool _loading = false;
|
||||
|
||||
List<WorkItem> get items => _items;
|
||||
Object? get error => _error;
|
||||
bool get loading => _loading;
|
||||
|
||||
Future<void> load() async {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
try {
|
||||
final all = await _repository.tasks(_roleCode);
|
||||
_items = all
|
||||
.where((item) => completed ? item.status == 23 : item.status != 23 && item.status != 22)
|
||||
.toList();
|
||||
} catch (error) {
|
||||
_error = error;
|
||||
} finally {
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user