diff --git a/apps/service_app/README.md b/apps/service_app/README.md index 79364e7..ff47093 100644 --- a/apps/service_app/README.md +++ b/apps/service_app/README.md @@ -1,6 +1,6 @@ # 瓶安芯服务工作台 -配送员、安装维修员和安检员共用的 Android/iOS Flutter 客户端。首期一账号一角色,统一访问 `/heqi/client/v1/staff`。 +配送员、安装维修员和安检员共用的 Android/iOS/Web Flutter 客户端。首期一账号一角色,统一访问 `/heqi/client/v1/staff`。 ## 本地运行 @@ -20,4 +20,7 @@ flutter analyze flutter test flutter build apk --debug flutter build ios --simulator --no-codesign +flutter build web --release --dart-define=API_BASE_URL=https://api.example.com ``` + +仓库根目录可使用 `API_BASE_URL=https://api.example.com ./scripts/build-apps.sh service_app all` 同时生成 Android 和 Web 产物。Web 端支持登录、任务处理、按浏览器授权采集位置和相机取证,但不支持 Android/iOS 的后台定位保障;生产环境必须使用 HTTPS API 地址。 diff --git a/apps/service_app/lib/data/offline/encrypted_draft_store.dart b/apps/service_app/lib/data/offline/encrypted_draft_store.dart index ea2a5cd..1b021c7 100644 --- a/apps/service_app/lib/data/offline/encrypted_draft_store.dart +++ b/apps/service_app/lib/data/offline/encrypted_draft_store.dart @@ -1,146 +1,2 @@ -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 saveDraft({ - required String accountIdentity, - required String taskIdentity, - required Map value, - }) async { - final directory = await _accountDirectory(accountIdentity); - final key = await _key(accountIdentity); - final nonce = List.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 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 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?> 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((key, value) => MapEntry(key.toString(), value)); - } - - Future count(String accountIdentity) async { - final directory = await _accountDirectory(accountIdentity); - return directory - .listSync() - .whereType() - .where((file) => file.path.endsWith('.draft')) - .length; - } - - Future 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().where( - (item) => item.uri.pathSegments.last.startsWith('$taskIdentity-'), - )) { - await evidence.delete(); - } - } - - Future discardAccount(String accountIdentity) async { - final directory = await _accountDirectory(accountIdentity); - if (directory.existsSync()) await directory.delete(recursive: true); - await _storage.delete(key: '$_keyPrefix$accountIdentity'); - } - - Future _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 _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 _encrypt(String accountIdentity, List clear) async { - final nonce = List.generate(12, (_) => Random.secure().nextInt(256)); - return _algorithm.encrypt(clear, secretKey: await _key(accountIdentity), nonce: nonce); - } - - Future> _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), - }); -} +export 'encrypted_draft_store_io.dart' + if (dart.library.html) 'encrypted_draft_store_web.dart'; diff --git a/apps/service_app/lib/data/offline/encrypted_draft_store_io.dart b/apps/service_app/lib/data/offline/encrypted_draft_store_io.dart new file mode 100644 index 0000000..f31348a --- /dev/null +++ b/apps/service_app/lib/data/offline/encrypted_draft_store_io.dart @@ -0,0 +1,154 @@ +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:image_picker/image_picker.dart'; +import 'package:path_provider/path_provider.dart'; + +/// Android/iOS 端按账号加密保存现场草稿和附件。 +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 saveDraft({ + required String accountIdentity, + required String taskIdentity, + required Map value, + }) async { + final directory = await _accountDirectory(accountIdentity); + final box = await _encrypt(accountIdentity, utf8.encode(jsonEncode(value))); + await File( + '${directory.path}/$taskIdentity.draft', + ).writeAsString(_boxJson(box), flush: true); + } + + Future sealAttachment({ + required String accountIdentity, + required String taskIdentity, + required String stage, + required XFile source, + }) async { + final box = await _encrypt(accountIdentity, await source.readAsBytes()); + final name = '$taskIdentity-$stage-${DateTime.now().microsecondsSinceEpoch}.evidence'; + final file = File( + '${(await _accountDirectory(accountIdentity)).path}/$name', + ); + await file.writeAsString(_boxJson(box), flush: true); + final sourceFile = File(source.path); + if (sourceFile.existsSync()) await sourceFile.delete(); + return name; + } + + Future 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 XFile(file.path); + } + + Future discardMaterializedAttachment(XFile file) async { + final temporary = File(file.path); + if (temporary.existsSync()) await temporary.delete(); + } + + Future?> readDraft( + String accountIdentity, + String taskIdentity, + ) async { + final file = File( + '${(await _accountDirectory(accountIdentity)).path}/$taskIdentity.draft', + ); + if (!file.existsSync()) return null; + final decoded = jsonDecode( + utf8.decode(await _decrypt(accountIdentity, await file.readAsString())), + ); + if (decoded is! Map) return null; + return decoded.map( + (key, value) => MapEntry(key.toString(), value), + ); + } + + Future count(String accountIdentity) async => (await _accountDirectory( + accountIdentity, + )).listSync().whereType().where((file) => file.path.endsWith('.draft')).length; + + Future 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().where( + (item) => item.uri.pathSegments.last.startsWith('$taskIdentity-'), + )) { + await evidence.delete(); + } + } + + Future discardAccount(String accountIdentity) async { + final directory = await _accountDirectory(accountIdentity); + if (directory.existsSync()) await directory.delete(recursive: true); + await _storage.delete(key: '$_keyPrefix$accountIdentity'); + } + + Future _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 _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 _encrypt(String accountIdentity, List clear) async => _algorithm.encrypt( + clear, + secretKey: await _key(accountIdentity), + nonce: List.generate(12, (_) => Random.secure().nextInt(256)), + ); + + Future> _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), + }); +} diff --git a/apps/service_app/lib/data/offline/encrypted_draft_store_web.dart b/apps/service_app/lib/data/offline/encrypted_draft_store_web.dart new file mode 100644 index 0000000..b829cd5 --- /dev/null +++ b/apps/service_app/lib/data/offline/encrypted_draft_store_web.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:cryptography/cryptography.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:web/web.dart' as web; + +/// Web 端仅保存 AES-GCM 密文;密钥继续由浏览器安全存储插件管理。 +class EncryptedDraftStore { + EncryptedDraftStore({FlutterSecureStorage? storage, AesGcm? algorithm}) + : _storage = storage ?? const FlutterSecureStorage(), + _algorithm = algorithm ?? AesGcm.with256bits(); + + static const _keyPrefix = 'service_draft_key_'; + static const _entryPrefix = 'service_draft_entry_'; + final FlutterSecureStorage _storage; + final AesGcm _algorithm; + + Future saveDraft({ + required String accountIdentity, + required String taskIdentity, + required Map value, + }) async { + final box = await _encrypt(accountIdentity, utf8.encode(jsonEncode(value))); + _entries.setItem(_draftKey(accountIdentity, taskIdentity), _boxJson(box)); + } + + Future sealAttachment({ + required String accountIdentity, + required String taskIdentity, + required String stage, + required XFile source, + }) async { + final box = await _encrypt(accountIdentity, await source.readAsBytes()); + final name = '$taskIdentity-$stage-${DateTime.now().microsecondsSinceEpoch}.evidence'; + _entries.setItem( + _attachmentKey(accountIdentity, name), + jsonEncode({'box': _boxJson(box), 'file_name': source.name}), + ); + return name; + } + + Future materializeAttachment({ + required String accountIdentity, + required String sealedName, + }) async { + final encoded = _entries.getItem(_attachmentKey(accountIdentity, sealedName)); + if (encoded == null) { + throw StateError('未找到加密附件'); + } + final payload = jsonDecode(encoded); + if (payload is! Map) { + throw const FormatException('Invalid encrypted attachment'); + } + final clear = await _decrypt(accountIdentity, payload['box'] as String); + return XFile.fromData( + Uint8List.fromList(clear), + name: payload['file_name'] as String? ?? '$sealedName.jpg', + ); + } + + Future discardMaterializedAttachment(XFile file) async {} + + Future?> readDraft(String accountIdentity, String taskIdentity) async { + final encoded = _entries.getItem(_draftKey(accountIdentity, taskIdentity)); + if (encoded == null) { + return null; + } + final decoded = jsonDecode(utf8.decode(await _decrypt(accountIdentity, encoded))); + if (decoded is! Map) { + return null; + } + return decoded.map((key, value) => MapEntry(key.toString(), value)); + } + + Future count(String accountIdentity) async => + _entryKeys.where((key) => key.startsWith('$_entryPrefix${accountIdentity}_draft_')).length; + + Future deleteDraft(String accountIdentity, String taskIdentity) async { + final prefix = '$_entryPrefix${accountIdentity}_'; + _entries.removeItem( + '$prefix' + 'draft_$taskIdentity', + ); + final attachmentPrefix = + '$prefix' + 'attachment_$taskIdentity-'; + for (final key in _entryKeys.where((key) => key.startsWith(attachmentPrefix)).toList()) { + _entries.removeItem(key); + } + } + + Future discardAccount(String accountIdentity) async { + final prefix = '$_entryPrefix${accountIdentity}_'; + for (final key in _entryKeys.where((key) => key.startsWith(prefix)).toList()) { + _entries.removeItem(key); + } + await _storage.delete(key: '$_keyPrefix$accountIdentity'); + } + + web.Storage get _entries => web.window.localStorage; + + Iterable get _entryKeys => + List.generate(_entries.length, (index) => _entries.key(index)).whereType(); + + String _draftKey(String accountIdentity, String taskIdentity) => + '$_entryPrefix${accountIdentity}_draft_$taskIdentity'; + + String _attachmentKey(String accountIdentity, String sealedName) => + '$_entryPrefix${accountIdentity}_attachment_$sealedName'; + + Future _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 _encrypt(String accountIdentity, List clear) async => _algorithm.encrypt( + clear, + secretKey: await _key(accountIdentity), + nonce: List.generate(12, (_) => Random.secure().nextInt(256)), + ); + + Future> _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), + }); +} diff --git a/apps/service_app/lib/data/repositories/service_repository.dart b/apps/service_app/lib/data/repositories/service_repository.dart index 2a3aaf0..848a91f 100644 --- a/apps/service_app/lib/data/repositories/service_repository.dart +++ b/apps/service_app/lib/data/repositories/service_repository.dart @@ -1,4 +1,5 @@ import 'package:uuid/uuid.dart'; +import 'package:image_picker/image_picker.dart'; import '../../domain/models/service_models.dart'; import '../services/api_client.dart'; @@ -86,14 +87,17 @@ class ServiceRepository { Future arrive(String identity) async { final point = await _location.current(); - await _api.post('$root/delivery/orders/$identity/arrive', body: _pointJson(point)); + await _api.post( + '$root/delivery/orders/$identity/arrive', + body: _pointJson(point), + ); } Future submitDeliveryReceipt({ required String identity, required String recipientName, required String recipientPhone, - required String proofFile, + required XFile proofFile, }) async { final proofUri = await _api.upload(proofFile); await _api.post( @@ -118,7 +122,7 @@ class ServiceRepository { final uploaded = >[]; for (final item in evidence) { final uri = await _api.upload( - item.filePath, + item.file, contentType: item.mediaType == 'video' ? 'video/mp4' : 'image/jpeg', ); uploaded.add({ @@ -153,14 +157,14 @@ class EvidenceInput { const EvidenceInput({ required this.evidenceType, required this.mediaType, - required this.filePath, + required this.file, required this.capturedAt, required this.requestNo, }); final String evidenceType; final String mediaType; - final String filePath; + final XFile file; final DateTime capturedAt; final String requestNo; } diff --git a/apps/service_app/lib/data/services/api_client.dart b/apps/service_app/lib/data/services/api_client.dart index ca00154..9e470d6 100644 --- a/apps/service_app/lib/data/services/api_client.dart +++ b/apps/service_app/lib/data/services/api_client.dart @@ -1,7 +1,7 @@ import 'dart:convert'; -import 'dart:io'; import 'package:http/http.dart' as http; +import 'package:image_picker/image_picker.dart'; class ApiException implements Exception { const ApiException(this.code, this.message); @@ -38,11 +38,21 @@ class ApiClient { Future put(String path, {Map? body}) => _send('PUT', path, body: body); - Future upload(String filePath, {String contentType = 'image/jpeg'}) async { - final request = http.MultipartRequest('POST', Uri.parse('$baseUrl/upload/file')); - request.headers[HttpHeaders.authorizationHeader] = _tokenProvider(); + /// 上传跨平台文件对象;浏览器端不能依赖本地文件路径。 + Future upload(XFile file, {String contentType = 'image/jpeg'}) async { + final request = http.MultipartRequest( + 'POST', + Uri.parse('$baseUrl/upload/file'), + ); + request.headers['authorization'] = _tokenProvider(); request.fields['declared_content_type'] = contentType; - request.files.add(await http.MultipartFile.fromPath('file', filePath)); + request.files.add( + http.MultipartFile.fromBytes( + 'file', + await file.readAsBytes(), + filename: file.name, + ), + ); final response = await http.Response.fromStream(await request.send()); final details = _decode(response); return jsonMap(details)['uri'] as String? ?? ''; @@ -55,15 +65,17 @@ class ApiClient { bool authenticated = true, }) async { final request = http.Request(method, Uri.parse('$baseUrl$path')); - request.headers[HttpHeaders.acceptHeader] = 'application/json'; + request.headers['accept'] = 'application/json'; if (authenticated && _tokenProvider().isNotEmpty) { - request.headers[HttpHeaders.authorizationHeader] = _tokenProvider(); + request.headers['authorization'] = _tokenProvider(); } if (body != null) { - request.headers[HttpHeaders.contentTypeHeader] = 'application/json; charset=UTF-8'; + request.headers['content-type'] = 'application/json; charset=UTF-8'; request.body = jsonEncode(body); } - final response = await http.Response.fromStream(await _client.send(request)); + final response = await http.Response.fromStream( + await _client.send(request), + ); return _decode(response); } diff --git a/apps/service_app/lib/ui/features/evidence/evidence_page.dart b/apps/service_app/lib/ui/features/evidence/evidence_page.dart index d9d6746..038a709 100644 --- a/apps/service_app/lib/ui/features/evidence/evidence_page.dart +++ b/apps/service_app/lib/ui/features/evidence/evidence_page.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:image_picker/image_picker.dart'; import 'package:uuid/uuid.dart'; @@ -44,12 +42,17 @@ class _EvidencePageState extends State { } Future _restore() async { - final draft = await widget.drafts.readDraft(widget.session.identity, widget.taskIdentity); + 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>()) { - final mapped = value.map((key, item) => MapEntry(key.toString(), item)); + final mapped = value.map( + (key, item) => MapEntry(key.toString(), item), + ); final stage = mapped['stage'] as String? ?? ''; if (stage.isNotEmpty) _evidence[stage] = mapped; } @@ -70,7 +73,7 @@ class _EvidencePageState extends State { accountIdentity: widget.session.identity, taskIdentity: widget.taskIdentity, stage: stage, - sourcePath: image.path, + source: image, ); _evidence[stage] = { 'stage': stage, @@ -97,25 +100,27 @@ class _EvidencePageState extends State { Future _submit() async { if (!_requiredStages.every(_evidence.containsKey) || _result.text.trim().isEmpty) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请完成结果说明和全部必需取证项'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('请完成结果说明和全部必需取证项'))); return; } setState(() => _busy = true); - final temporaryFiles = []; + final temporaryFiles = []; try { await _save(); final inputs = []; for (final item in _evidence.values) { - final path = await widget.drafts.materializeAttachment( + final file = await widget.drafts.materializeAttachment( accountIdentity: widget.session.identity, sealedName: item['sealed_name'] as String, ); - temporaryFiles.add(path); + temporaryFiles.add(file); inputs.add( EvidenceInput( evidenceType: item['stage'] as String, mediaType: item['media_type'] as String, - filePath: path, + file: file, capturedAt: DateTime.parse(item['captured_at'] as String), requestNo: item['request_no'] as String, ), @@ -127,16 +132,20 @@ class _EvidencePageState extends State { conclusion: _conclusion, evidence: inputs, ); - await widget.drafts.deleteDraft(widget.session.identity, widget.taskIdentity); + 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'))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('提交失败,草稿仍安全保留:$error'))); } } finally { - for (final path in temporaryFiles) { - final file = File(path); - if (file.existsSync()) await file.delete(); + for (final file in temporaryFiles) { + await widget.drafts.discardMaterializedAttachment(file); } if (mounted) setState(() => _busy = false); } diff --git a/apps/service_app/lib/ui/features/work/work_detail_page.dart b/apps/service_app/lib/ui/features/work/work_detail_page.dart index 1f0f4db..e7949f2 100644 --- a/apps/service_app/lib/ui/features/work/work_detail_page.dart +++ b/apps/service_app/lib/ui/features/work/work_detail_page.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; @@ -42,14 +40,19 @@ class _WorkDetailPageState extends State { ? widget.repository.deliveryDetail(widget.identity) : widget.repository.ticketDetail(widget.identity); - Future _run(Future Function(WorkItem) action, WorkItem item) async { + Future _run( + Future 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()))); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.toString()))); } } finally { if (mounted) setState(() => _busy = false); @@ -68,7 +71,10 @@ class _WorkDetailPageState extends State { decoration: const InputDecoration(labelText: '原因'), ), actions: [ - TextButton(onPressed: () => Navigator.pop(context), child: const Text('取消')), + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), FilledButton( onPressed: () => Navigator.pop(context, controller.text.trim()), child: const Text('确认'), @@ -81,7 +87,10 @@ class _WorkDetailPageState extends State { } Future _receipt(WorkItem item) async { - final existing = await widget.drafts.readDraft(widget.session.identity, item.identity); + 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) { @@ -91,25 +100,34 @@ class _WorkDetailPageState extends State { 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('继续提交')), + 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); + 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, + source: image, ); } setState(() => _busy = true); - String? temporaryPath; + XFile? temporaryFile; try { await widget.drafts.saveDraft( accountIdentity: widget.session.identity, @@ -122,7 +140,7 @@ class _WorkDetailPageState extends State { 'captured_at': DateTime.now().toUtc().toIso8601String(), }, ); - temporaryPath = await widget.drafts.materializeAttachment( + temporaryFile = await widget.drafts.materializeAttachment( accountIdentity: widget.session.identity, sealedName: sealedName, ); @@ -130,7 +148,7 @@ class _WorkDetailPageState extends State { identity: item.identity, recipientName: item.raw['contact_name'] as String? ?? '收货人', recipientPhone: item.raw['contact_phone'] as String? ?? '', - proofFile: temporaryPath, + proofFile: temporaryFile, ); await widget.drafts.deleteDraft(widget.session.identity, item.identity); if (mounted) setState(() => _future = _load()); @@ -141,9 +159,8 @@ class _WorkDetailPageState extends State { ).showSnackBar(SnackBar(content: Text('签收提交失败,加密草稿已保留:$error'))); } } finally { - if (temporaryPath != null) { - final file = File(temporaryPath); - if (file.existsSync()) await file.delete(); + if (temporaryFile != null) { + await widget.drafts.discardMaterializedAttachment(temporaryFile); } if (mounted) setState(() => _busy = false); } @@ -170,7 +187,10 @@ class _WorkDetailPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item.number, style: const TextStyle(color: Colors.white70)), + Text( + item.number, + style: const TextStyle(color: Colors.white70), + ), const SizedBox(height: 8), Text( item.title, @@ -206,7 +226,10 @@ class _WorkDetailPageState extends State { onPressed: _busy ? null : () => _run( - (value) => widget.repository.start(value, widget.session.roleCode), + (value) => widget.repository.start( + value, + widget.session.roleCode, + ), item, ), child: const Text('开始处理'), @@ -216,7 +239,9 @@ class _WorkDetailPageState extends State { onPressed: _busy ? null : () => _run( - (value) => widget.repository.appendCurrentTrack(value.identity), + (value) => widget.repository.appendCurrentTrack( + value.identity, + ), item, ), child: const Text('上报当前位置'), @@ -225,7 +250,10 @@ class _WorkDetailPageState extends State { ElevatedButton( onPressed: _busy ? null - : () => _run((value) => widget.repository.arrive(value.identity), item), + : () => _run( + (value) => widget.repository.arrive(value.identity), + item, + ), child: const Text('到达并校验围栏'), ), if (item.allowedActions.contains('submit_receipt')) @@ -253,8 +281,11 @@ class _WorkDetailPageState extends State { final reason = await _reason(); if (reason != null && reason.isNotEmpty) { await _run( - (value) => - widget.repository.exception(value, widget.session.roleCode, reason), + (value) => widget.repository.exception( + value, + widget.session.roleCode, + reason, + ), item, ); } @@ -269,8 +300,11 @@ class _WorkDetailPageState extends State { final reason = await _reason(); if (reason != null && reason.isNotEmpty) { await _run( - (value) => - widget.repository.recover(value, widget.session.roleCode, reason), + (value) => widget.repository.recover( + value, + widget.session.roleCode, + reason, + ), item, ); } diff --git a/apps/service_app/pubspec.lock b/apps/service_app/pubspec.lock index cd8da29..2a3756c 100644 --- a/apps/service_app/pubspec.lock +++ b/apps/service_app/pubspec.lock @@ -6,7 +6,7 @@ packages: description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.7.0" async: @@ -14,7 +14,7 @@ packages: description: name: async sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.13.1" boolean_selector: @@ -22,7 +22,7 @@ packages: description: name: boolean_selector sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" characters: @@ -30,7 +30,7 @@ packages: description: name: characters sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" clock: @@ -38,7 +38,7 @@ packages: description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" code_assets: @@ -46,7 +46,7 @@ packages: description: name: code_assets sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.2.1" collection: @@ -54,23 +54,23 @@ packages: description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" connectivity_plus: dependency: "direct main" description: name: connectivity_plus - sha256: "762c99f890ca8bf87f7337236f99edd42793843bc6c3631da294a76653a54bd0" - url: "https://pub.dev" + sha256: "25cebb79dfe304022550e0c3c893ae051ded07183d2607f7b88473cb3ade33cb" + url: "https://pub.flutter-io.cn" source: hosted - version: "7.3.1" + version: "7.3.0" connectivity_plus_platform_interface: dependency: transitive description: name: connectivity_plus_platform_interface sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.0" cross_file: @@ -78,7 +78,7 @@ packages: description: name: cross_file sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.3.5+4" crypto: @@ -86,7 +86,7 @@ packages: description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.7" cryptography: @@ -94,7 +94,7 @@ packages: description: name: cryptography sha256: "3eda3029d34ec9095a27a198ac9785630fe525c0eb6a49f3d575272f8e792ef0" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.9.0" cupertino_icons: @@ -102,7 +102,7 @@ packages: description: name: cupertino_icons sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" dbus: @@ -110,7 +110,7 @@ packages: description: name: dbus sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.7.14" fake_async: @@ -118,7 +118,7 @@ packages: description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.3" ffi: @@ -126,7 +126,7 @@ packages: description: name: ffi sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" ffi_leak_tracker: @@ -134,7 +134,7 @@ packages: description: name: ffi_leak_tracker sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.1.2" file_selector_linux: @@ -142,7 +142,7 @@ packages: description: name: file_selector_linux sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.9.4" file_selector_macos: @@ -150,7 +150,7 @@ packages: description: name: file_selector_macos sha256: "5e0bbe9c312416f1787a68259ea1505b52f258c587f12920422671807c4d618a" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.9.5" file_selector_platform_interface: @@ -158,7 +158,7 @@ packages: description: name: file_selector_platform_interface sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.7.0" file_selector_windows: @@ -166,7 +166,7 @@ packages: description: name: file_selector_windows sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.9.3+5" fixnum: @@ -174,7 +174,7 @@ packages: description: name: fixnum sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" flutter: @@ -187,7 +187,7 @@ packages: description: name: flutter_lints sha256: "3105dc8492f6183fb076ccf1f351ac3d60564bff92e20bfc4af9cc1651f4e7e1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" flutter_plugin_android_lifecycle: @@ -195,7 +195,7 @@ packages: description: name: flutter_plugin_android_lifecycle sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.0.35" flutter_secure_storage: @@ -203,7 +203,7 @@ packages: description: name: flutter_secure_storage sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "10.3.1" flutter_secure_storage_darwin: @@ -211,7 +211,7 @@ packages: description: name: flutter_secure_storage_darwin sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.3.2" flutter_secure_storage_linux: @@ -219,23 +219,23 @@ packages: description: name: flutter_secure_storage_linux sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.1" flutter_secure_storage_platform_interface: dependency: transitive description: name: flutter_secure_storage_platform_interface - sha256: "06686df417f34fe9f963ed217b7fdce250e2f637ddd6c374d7eb054549770633" - url: "https://pub.dev" + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.flutter-io.cn" source: hosted - version: "2.0.2" + version: "2.0.1" flutter_secure_storage_web: dependency: transitive description: name: flutter_secure_storage_web sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.1" flutter_secure_storage_windows: @@ -243,7 +243,7 @@ packages: description: name: flutter_secure_storage_windows sha256: "471951813a97006d899db4948acc654a4f28c440083ea08178935ce20b173ec1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.2.2" flutter_test: @@ -261,7 +261,7 @@ packages: description: name: geoclue sha256: c2a998c77474fc57aa00c6baa2928e58f4b267649057a1c76738656e9dbd2a7f - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.1.1" geolocator: @@ -269,7 +269,7 @@ packages: description: name: geolocator sha256: e146a6d63776582651e97a79cbe459f8e1211b100101fadcd84db83361fa599f - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "14.0.3" geolocator_android: @@ -277,7 +277,7 @@ packages: description: name: geolocator_android sha256: "86ea1654e4f61ff51466848e91c116b422d6010ea269fda0fbe1af7e9e742ce1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "5.0.3" geolocator_apple: @@ -285,7 +285,7 @@ packages: description: name: geolocator_apple sha256: "853803d6bb1713c094e935b4a5ae5f19c0308acf81da13fa9ff84fb4c70c0b73" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.3.14" geolocator_linux: @@ -293,7 +293,7 @@ packages: description: name: geolocator_linux sha256: "3da7420f11c3496511a5bd3c18fd67b88e5659f12e46b7ce00a788f6996e850a" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.2.6" geolocator_platform_interface: @@ -301,7 +301,7 @@ packages: description: name: geolocator_platform_interface sha256: cdb082e4f048b69da244117b7914cc60d2a8897546ffaa4f2529c786ded7aee2 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.2.8" geolocator_web: @@ -309,7 +309,7 @@ packages: description: name: geolocator_web sha256: "19e485a0f8d6a88abcf9c53cba3a4105e14b7435ed8ac1c108c067b938fe8429" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.1.4" geolocator_windows: @@ -317,7 +317,7 @@ packages: description: name: geolocator_windows sha256: "175435404d20278ffd220de83c2ca293b73db95eafbdc8131fe8609be1421eb6" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.2.5" go_router: @@ -325,7 +325,7 @@ packages: description: name: go_router sha256: "5922b2861e2235a3504896f0d6fa07d84141b480cf52eecd2f42cd25585a9e8a" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "17.3.0" gsettings: @@ -333,7 +333,7 @@ packages: description: name: gsettings sha256: "1b0ce661f5436d2db1e51f3c4295a49849f03d304003a7ba177d01e3a858249c" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.2.8" hooks: @@ -341,7 +341,7 @@ packages: description: name: hooks sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.0.2" http: @@ -349,7 +349,7 @@ packages: description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.6.0" http_parser: @@ -357,7 +357,7 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" image_picker: @@ -365,7 +365,7 @@ packages: description: name: image_picker sha256: d8402284df184bc05f4a2210c6c23983b0720f4cd87cbd05c5390a78af602667 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.2.3" image_picker_android: @@ -373,7 +373,7 @@ packages: description: name: image_picker_android sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.8.13+19" image_picker_for_web: @@ -381,7 +381,7 @@ packages: description: name: image_picker_for_web sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.1.1" image_picker_ios: @@ -389,7 +389,7 @@ packages: description: name: image_picker_ios sha256: b9c4a438a9ff4f60808c9cf0039b93a42bb6c2211ef6ebb647394b2b3fa84588 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.8.13+6" image_picker_linux: @@ -397,7 +397,7 @@ packages: description: name: image_picker_linux sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.2.2" image_picker_macos: @@ -405,7 +405,7 @@ packages: description: name: image_picker_macos sha256: "86f0f15a309de7e1a552c12df9ce5b59fe927e71385329355aec4776c6a8ec91" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.2.2+1" image_picker_platform_interface: @@ -413,7 +413,7 @@ packages: description: name: image_picker_platform_interface sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.11.1" image_picker_windows: @@ -421,39 +421,31 @@ packages: description: name: image_picker_windows sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.2.2" jni: dependency: transitive description: name: jni - sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 - url: "https://pub.dev" + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.3" + version: "1.0.0" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: "7b717011ea40d04fd47c2731d3d1d36eb99eba3435c2753d62489e8c3c9991d5" - url: "https://pub.dev" + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.flutter-io.cn" source: hosted - version: "1.0.2" - jni_util: - dependency: transitive - description: - name: jni_util - sha256: "1ba86da04a5f2bf18fde2edb235587e70c5b0fc5bd4ba955f46b00942c3fc35f" - url: "https://pub.dev" - source: hosted - version: "1.0.0" + version: "1.0.1" leak_tracker: dependency: transitive description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "11.0.2" leak_tracker_flutter_testing: @@ -461,7 +453,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.10" leak_tracker_testing: @@ -469,7 +461,7 @@ packages: description: name: leak_tracker_testing sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.2" lints: @@ -477,7 +469,7 @@ packages: description: name: lints sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.1.0" logging: @@ -485,7 +477,7 @@ packages: description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.0" matcher: @@ -493,7 +485,7 @@ packages: description: name: matcher sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.12.19" material_color_utilities: @@ -501,7 +493,7 @@ packages: description: name: material_color_utilities sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.13.0" meta: @@ -509,7 +501,7 @@ packages: description: name: meta sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.18.0" mime: @@ -517,7 +509,7 @@ packages: description: name: mime sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.0.0" nm: @@ -525,31 +517,31 @@ packages: description: name: nm sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.5.0" objective_c: dependency: transitive description: name: objective_c - sha256: b7fb95a6d9a4f009edd63dc5ac69f07420b23a16161c6dd8660290b59c602e8e - url: "https://pub.dev" + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.flutter-io.cn" source: hosted - version: "9.5.0" + version: "9.4.1" package_config: dependency: transitive description: name: package_config - sha256: ffcf4cf3d6c0b74ac43708d9f56625506e8a68aa935abe9d267a7330f320eb5d - url: "https://pub.dev" + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.flutter-io.cn" source: hosted - version: "3.0.0" + version: "2.2.0" package_info_plus: dependency: transitive description: name: package_info_plus sha256: "127e1751e37ffb2ff4658beeaca77bad0c27bf5f932bd3a501c2296926d4b481" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "10.2.1" package_info_plus_platform_interface: @@ -557,7 +549,7 @@ packages: description: name: package_info_plus_platform_interface sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.1.0" path: @@ -565,7 +557,7 @@ packages: description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" path_provider: @@ -573,7 +565,7 @@ packages: description: name: path_provider sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.6" path_provider_android: @@ -581,7 +573,7 @@ packages: description: name: path_provider_android sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.3.1" path_provider_foundation: @@ -589,7 +581,7 @@ packages: description: name: path_provider_foundation sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.6.0" path_provider_linux: @@ -597,7 +589,7 @@ packages: description: name: path_provider_linux sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.2" path_provider_platform_interface: @@ -605,7 +597,7 @@ packages: description: name: path_provider_platform_interface sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.3" path_provider_windows: @@ -613,7 +605,7 @@ packages: description: name: path_provider_windows sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.3.0" petitparser: @@ -621,7 +613,7 @@ packages: description: name: petitparser sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.0.2" platform: @@ -629,7 +621,7 @@ packages: description: name: platform sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.1.6" plugin_platform_interface: @@ -637,7 +629,7 @@ packages: description: name: plugin_platform_interface sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.8" pub_semver: @@ -645,7 +637,7 @@ packages: description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" record_use: @@ -653,7 +645,7 @@ packages: description: name: record_use sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.6.0" sky_engine: @@ -666,7 +658,7 @@ packages: description: name: source_span sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.10.2" stack_trace: @@ -674,7 +666,7 @@ packages: description: name: stack_trace sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.12.1" stream_channel: @@ -682,7 +674,7 @@ packages: description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" string_scanner: @@ -690,7 +682,7 @@ packages: description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" term_glyph: @@ -698,7 +690,7 @@ packages: description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.2.2" test_api: @@ -706,7 +698,7 @@ packages: description: name: test_api sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.7.11" typed_data: @@ -714,7 +706,7 @@ packages: description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" uuid: @@ -722,7 +714,7 @@ packages: description: name: uuid sha256: "9b129329f58692f6e6578329498a8fe9fbe98f090beb764ffbb8ee2eadd01dcd" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.6.0" vector_math: @@ -730,7 +722,7 @@ packages: description: name: vector_math sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" vm_service: @@ -738,15 +730,15 @@ packages: description: name: vm_service sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "15.2.0" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" win32: @@ -754,7 +746,7 @@ packages: description: name: win32 sha256: ba6f4bba816c8d7e3c1580e170f3786d216951cc6b94babc3b814c08d2cb2738 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.3.0" xdg_directories: @@ -762,7 +754,7 @@ packages: description: name: xdg_directories sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" xml: @@ -770,7 +762,7 @@ packages: description: name: xml sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.6.1" yaml: @@ -778,7 +770,7 @@ packages: description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.1.3" sdks: diff --git a/apps/service_app/pubspec.yaml b/apps/service_app/pubspec.yaml index 939cd6c..bf44aa2 100644 --- a/apps/service_app/pubspec.yaml +++ b/apps/service_app/pubspec.yaml @@ -42,7 +42,8 @@ dependencies: cryptography: ^2.9.0 image_picker: ^1.2.3 geolocator: ^14.0.3 - connectivity_plus: ^7.3.1 + connectivity_plus: ^7.3.0 + web: ^1.1.1 dev_dependencies: flutter_test: diff --git a/apps/service_app/web/favicon.svg b/apps/service_app/web/favicon.svg new file mode 100644 index 0000000..4d8958b --- /dev/null +++ b/apps/service_app/web/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/service_app/web/icons/app-icon.svg b/apps/service_app/web/icons/app-icon.svg new file mode 100644 index 0000000..f7c514e --- /dev/null +++ b/apps/service_app/web/icons/app-icon.svg @@ -0,0 +1 @@ + diff --git a/apps/service_app/web/index.html b/apps/service_app/web/index.html new file mode 100644 index 0000000..0fa0747 --- /dev/null +++ b/apps/service_app/web/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + 和气瓶安服务工作台 + + + + + diff --git a/apps/service_app/web/manifest.json b/apps/service_app/web/manifest.json new file mode 100644 index 0000000..c324ca0 --- /dev/null +++ b/apps/service_app/web/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "和气瓶安服务工作台", + "short_name": "服务工作台", + "start_url": ".", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#bf360c", + "description": "和气瓶安服务工作台 Web 应用。", + "icons": [ + { + "src": "icons/app-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/apps/user_app/README.md b/apps/user_app/README.md index ea7dd5b..3ba3177 100644 --- a/apps/user_app/README.md +++ b/apps/user_app/README.md @@ -1,6 +1,6 @@ # 瓶安芯用户端 -Android/iOS Flutter 客户端。首期只接入 `/heqi/client/v1/user` 的真实能力:首页安全内容与服务归属、商城、订单、合同、工单、钱包、地址和个人资料。 +Android/iOS/Web Flutter 客户端。首期只接入 `/heqi/client/v1/user` 的真实能力:首页安全内容与服务归属、商城、订单、合同、工单、钱包、地址和个人资料。 ## 本地运行 @@ -18,6 +18,9 @@ flutter analyze flutter test flutter build apk --debug flutter build ios --simulator --no-codesign +flutter build web --release --dart-define=API_BASE_URL=https://api.example.com ``` +仓库根目录可使用 `API_BASE_URL=https://api.example.com ./scripts/build-apps.sh user_app all` 同时生成 Android 和 Web 产物。Web 本地调试使用 `flutter run -d chrome --dart-define=API_BASE_URL=http://127.0.0.1:12426`;生产环境必须使用 HTTPS API 地址。 + 充值 Mock 确认、设备控制、收藏、押金、消息和发票均不在 Release UI 暴露。 diff --git a/apps/user_app/lib/data/services/api_client.dart b/apps/user_app/lib/data/services/api_client.dart index e159eaf..fb2837c 100644 --- a/apps/user_app/lib/data/services/api_client.dart +++ b/apps/user_app/lib/data/services/api_client.dart @@ -1,5 +1,4 @@ import 'dart:convert'; -import 'dart:io'; import 'package:http/http.dart' as http; @@ -51,13 +50,13 @@ class ApiClient { bool authenticated = true, }) async { final request = http.Request(method, Uri.parse('$baseUrl$path')); - request.headers[HttpHeaders.acceptHeader] = 'application/json'; + request.headers['accept'] = 'application/json'; if (authenticated) { final token = _tokenProvider(); - if (token.isNotEmpty) request.headers[HttpHeaders.authorizationHeader] = token; + if (token.isNotEmpty) request.headers['authorization'] = token; } if (body != null) { - request.headers[HttpHeaders.contentTypeHeader] = 'application/json; charset=UTF-8'; + request.headers['content-type'] = 'application/json; charset=UTF-8'; request.body = jsonEncode(body); } final streamed = await _client.send(request); diff --git a/apps/user_app/web/favicon.svg b/apps/user_app/web/favicon.svg new file mode 100644 index 0000000..252b534 --- /dev/null +++ b/apps/user_app/web/favicon.svg @@ -0,0 +1 @@ + diff --git a/apps/user_app/web/icons/app-icon.svg b/apps/user_app/web/icons/app-icon.svg new file mode 100644 index 0000000..876d907 --- /dev/null +++ b/apps/user_app/web/icons/app-icon.svg @@ -0,0 +1 @@ + diff --git a/apps/user_app/web/index.html b/apps/user_app/web/index.html new file mode 100644 index 0000000..9ac6e8a --- /dev/null +++ b/apps/user_app/web/index.html @@ -0,0 +1,16 @@ + + + + + + + + + + + 和气瓶安 + + + + + diff --git a/apps/user_app/web/manifest.json b/apps/user_app/web/manifest.json new file mode 100644 index 0000000..3b40395 --- /dev/null +++ b/apps/user_app/web/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "和气瓶安", + "short_name": "和气瓶安", + "start_url": ".", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#00695c", + "description": "和气瓶安用户端 Web 应用。", + "icons": [ + { + "src": "icons/app-icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/docs/03-用户端App需求.md b/docs/03-用户端App需求.md index 061de32..fcac87d 100644 --- a/docs/03-用户端App需求.md +++ b/docs/03-用户端App需求.md @@ -103,9 +103,9 @@ ### 7.1 平台、工程与原型边界 -- 用户端只交付 Android、iOS,不建设 Flutter Web、桌面端或小程序兼容层。平台差异通过适配器隔离,不在业务页面散落 `Platform.isAndroid`、`Platform.isIOS` 判断。 +- 用户端交付 Android、iOS、Web;Web 作为浏览器入口,不延伸为桌面端或小程序兼容层。平台差异通过适配器隔离,不在业务页面散落 `Platform.isAndroid`、`Platform.isIOS` 判断。Web 生产部署必须使用 HTTPS,并通过 `--dart-define=API_BASE_URL=...` 注入 API 地址。 - 生产工程按规划放在 `apps/user_app`;当前 `ui` 目录是基于产品设计图制作的交互原型,仅用于视觉、信息架构和流程确认,不得把其中的演示数据或模拟成功状态当作业务实现。 -- Flutter 与 Dart 版本由工程根目录的版本管理文件和 CI 固定;升级 SDK、Gradle、Kotlin、Xcode、CocoaPods 或插件时必须单独验证 Android/iOS 构建、权限和深链。 +- Flutter 与 Dart 版本由工程根目录的版本管理文件和 CI 固定;升级 SDK、Gradle、Kotlin、Xcode、CocoaPods 或插件时必须单独验证 Android/iOS/Web 构建、权限和深链。 - 应用令牌的 client claim 固定为 `user_app`,API 根路径固定为 `/heqi/client/v1/user`;不得复用 `service_app` 或任何管理后台会话。 ### 7.2 分层结构与依赖方向 @@ -163,7 +163,7 @@ apps/user_app/lib/ ### 7.5 本地数据、安全与平台能力 -- 访问令牌、刷新令牌和支付相关临时凭据只进入 Android Keystore / iOS Keychain 支持的安全存储;日志、崩溃报告、埋点和剪贴板不得记录令牌、支付密码、完整手机号、地址或定位。 +- 访问令牌、刷新令牌和支付相关临时凭据只进入 Android Keystore / iOS Keychain 支持的安全存储,Web 仅可在 HTTPS 域名使用浏览器安全存储;日志、崩溃报告、埋点和剪贴板不得记录令牌、支付密码、完整手机号、地址或定位。 - 普通缓存只保存可恢复数据并设置版本与过期时间。安全事件、资金、订单和设备命令的服务端事实不能由本地缓存覆盖;退出登录时按数据分类清理。 - Android/iOS 的相机、相册、蓝牙、定位、通知权限均采用使用时申请和拒绝后降级。定位失败提供手动地址入口;蓝牙失败提供扫码或手动设备码入口。 - 推送点击必须先恢复会话并重新向服务端读取对象状态;通知载荷不得包含完整地址、手机号、支付信息或可直接执行设备控制的凭证。 @@ -174,4 +174,4 @@ apps/user_app/lib/ - 设计基线以 `doc/用户端APP-产品设计` 和 `ui/?app=user` 为准:安全蓝为主色,瓶阀状态、告警与命令回执优先于营销内容。危险、警告、成功不能只靠颜色表达。 - 使用统一 ThemeExtension 管理颜色、圆角、间距、阴影和状态色;正文最小字号、动态字体缩放、44×44 logical pixels 触控目标、屏幕阅读器语义和对比度必须在 Android/iOS 真机验证。 - ViewModel、Use Case、Repository 覆盖单元测试;瓶阀状态、支付、登录守卫和错误恢复覆盖 Widget 测试;扫码绑定、开关阀回执、下单支付、订单轨迹覆盖集成测试。 -- 每次合并至少执行 `flutter analyze`、`flutter test`、Android debug 构建和 iOS Simulator 构建;涉及相机、蓝牙、推送、支付、Universal Links/App Links 的改动还须执行对应真机回归。 +- 每次合并至少执行 `flutter analyze`、`flutter test`、Android debug 构建、iOS Simulator 构建和 Web release 构建;涉及相机、蓝牙、推送、支付、Universal Links/App Links 的改动还须执行对应真机或浏览器回归。 diff --git a/docs/04-服务端App需求.md b/docs/04-服务端App需求.md index e34635f..e362c3f 100644 --- a/docs/04-服务端App需求.md +++ b/docs/04-服务端App需求.md @@ -130,7 +130,7 @@ ### 9.1 平台、角色与工程边界 -- 服务人员端只交付 Android、iOS。生产工程按规划放在 `apps/service_app`;当前 `ui` 目录是配送、安装维修、安检三类原型的交互实现,不连接真实定位、相机、蓝牙、支付或业务 API。 +- 服务人员端交付 Android、iOS、Web。生产工程按规划放在 `apps/service_app`;Web 只支持浏览器前台授权的位置与相机能力,不作为后台定位或后台任务的替代方案。当前 `ui` 目录是配送、安装维修、安检三类原型的交互实现,不连接真实定位、相机、蓝牙、支付或业务 API。 - 三类岗位共用一个 Flutter 应用和登录体系,每个账号仅有一个服务岗位,令牌 client claim 固定为 `service_app` 并携带唯一 `role_code`。岗位变更必须由后台完成并重新登录换取令牌;客户端不提供角色切换。 - 工作人员 API 根路径固定为 `/heqi/client/v1/staff`。客户端不得访问用户端、平台后台、气站后台或配送点后台的令牌与接口。 - 首期后端不提供工作人员自助注册时,Flutter 注册页面不得伪造成功;若未来开放申请,只能创建待审核账户,不能直接授予岗位能力。 @@ -203,6 +203,8 @@ apps/service_app/lib/ ### 9.6 Android/iOS 平台适配 +Web 生产部署必须使用 HTTPS,并通过 `--dart-define=API_BASE_URL=...` 注入 API 地址。浏览器草稿和附件只保存 AES-GCM 密文;不支持 Android/iOS 的持续后台定位与后台任务保障。 + | 能力 | Android | iOS | | --- | --- | --- | | 相机/相册 | 运行时按用途申请 Camera/Photo Picker,使用系统选择器优先 | 使用相机和 Photos 限定访问,解释用途并处理 Limited 状态 | @@ -221,4 +223,4 @@ apps/service_app/lib/ - 任务详情优先展示任务号、类型、预约/SLA、地址、脱敏联系人、风险与允许动作。固定底部操作按钮不得遮挡检查项、签名或系统安全区。 - 长清单使用惰性列表和分段保存;照片视频缩略图解码、压缩和上传移出 UI isolate。后台轨迹、上传和补传必须受电量、网络与系统调度约束,不用常驻无限循环。 - ViewModel、Use Case、Repository、离线队列和冲突处理覆盖单元测试;角色/组织守卫、步骤前置、风险结论和弱网状态覆盖 Widget 测试;三角色主闭环覆盖 Android/iOS 集成测试。 -- 每次合并至少执行 `flutter analyze`、`flutter test`、Android debug 构建和 iOS Simulator 构建;定位、相机、蓝牙、推送、后台任务、App Links/Universal Links 和安全存储改动必须真机回归。 +- 每次合并至少执行 `flutter analyze`、`flutter test`、Android debug 构建、iOS Simulator 构建和 Web release 构建;定位、相机、蓝牙、推送、后台任务、App Links/Universal Links 和安全存储改动必须真机或浏览器回归。 diff --git a/docs/10-技术实现规划.md b/docs/10-技术实现规划.md index 689542f..29b26a9 100644 --- a/docs/10-技术实现规划.md +++ b/docs/10-技术实现规划.md @@ -74,9 +74,9 @@ flowchart LR ### 4.2 Flutter 工程落地基线 -- 生产工程已落在 `apps/user_app` 与 `apps/service_app`,只生成 Android、iOS 平台目录;`ui` 继续作为视觉和交互原型,不作为运行时依赖。 +- 生产工程已落在 `apps/user_app` 与 `apps/service_app`,生成 Android、iOS、Web 平台目录;`ui` 继续作为视觉和交互原型,不作为运行时依赖。Web 构建通过仓库根目录 `scripts/build-apps.sh` 输出,生产环境必须注入 HTTPS `API_BASE_URL`。 - 两个 App 使用 `MaterialApp.router`、`go_router`、MVVM + Repository 与注入的平台 Service。HTTP 根地址通过 `--dart-define=API_BASE_URL=...` 注入;用户端和工作人员端分别固定访问 `/heqi/client/v1/user` 与 `/heqi/client/v1/staff`,JWT 请求头沿用当前服务端原始令牌契约。 -- 访问令牌保存在 Android Keystore / iOS Keychain 支持的安全存储。服务端 App 的现场草稿和附件按账号使用 AES-GCM 加密;恢复网络后才上传并执行最终业务提交。 +- 访问令牌保存在 Android Keystore / iOS Keychain 支持的安全存储;Web 使用浏览器安全存储,且只能部署在 HTTPS 域名。服务端 App 的现场草稿和附件按账号使用 AES-GCM 加密;Web 仅保存密文,恢复网络后才上传并执行最终业务提交。浏览器端不承担 Android/iOS 的后台定位保障。 - 充值 Mock 确认只允许 Debug/开发联调,Release UI 不注册该入口;未落地的设备控制、收藏、押金、消息和发票能力不得以静态成功状态替代。 ## 5. 研发目录规划(建议) diff --git a/scripts/build-apps.sh b/scripts/build-apps.sh new file mode 100644 index 0000000..02e5b71 --- /dev/null +++ b/scripts/build-apps.sh @@ -0,0 +1,90 @@ +#!/usr/bin/env bash + +set -Eeuo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +OUTPUT_ROOT="${PROJECT_ROOT}/output/apps" +TARGET="${1:-all}" +PLATFORM="${2:-all}" + +require_command() { + local command_name="$1" + if ! command -v "${command_name}" >/dev/null 2>&1; then + echo "缺少命令:${command_name}" >&2 + exit 1 + fi +} + +require_value() { + local value_name="$1" + local value="${!value_name:-}" + if [[ -z "${value}" ]]; then + echo "缺少环境变量:${value_name}" >&2 + exit 1 + fi +} + +copy_output() { + local source="$1" + local target="$2" + if [[ "${target}" != "${OUTPUT_ROOT}/"* ]]; then + echo "拒绝清理非预期输出目录:${target}" >&2 + exit 1 + fi + rm -rf "${target}" + mkdir -p "${target}" + cp -R "${source}/." "${target}/" +} + +build_app() { + local app_name="$1" + local app_dir="${PROJECT_ROOT}/apps/${app_name}" + local app_output="${OUTPUT_ROOT}/${app_name}" + + echo "开始构建:${app_name}" + ( + cd "${app_dir}" + flutter pub get + flutter analyze + flutter test + + if [[ "${PLATFORM}" == "all" || "${PLATFORM}" == "android" ]]; then + flutter build apk --release --no-pub --dart-define="API_BASE_URL=${API_BASE_URL}" + mkdir -p "${app_output}" + cp "build/app/outputs/flutter-apk/app-release.apk" "${app_output}/${app_name}-release.apk" + fi + + if [[ "${PLATFORM}" == "all" || "${PLATFORM}" == "web" ]]; then + flutter build web --release --no-pub --dart-define="API_BASE_URL=${API_BASE_URL}" + copy_output "${app_dir}/build/web" "${app_output}/web" + fi + ) + echo "构建完成:${app_name}" +} + +if [[ "${TARGET}" != "all" && "${TARGET}" != "user_app" && "${TARGET}" != "service_app" ]]; then + echo "应用参数只能是 all、user_app 或 service_app。" >&2 + exit 1 +fi +if [[ "${PLATFORM}" != "all" && "${PLATFORM}" != "android" && "${PLATFORM}" != "web" ]]; then + echo "平台参数只能是 all、android 或 web。" >&2 + exit 1 +fi + +require_command flutter +require_value API_BASE_URL + +if [[ "${PLATFORM}" == "all" || "${PLATFORM}" == "web" ]]; then + flutter config --enable-web +fi + +mkdir -p "${OUTPUT_ROOT}" +if [[ "${TARGET}" == "all" || "${TARGET}" == "user_app" ]]; then + build_app user_app +fi +if [[ "${TARGET}" == "all" || "${TARGET}" == "service_app" ]]; then + build_app service_app +fi + +echo "产物目录:${OUTPUT_ROOT}"