diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index 7f7a91af..c724763b 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -1026,6 +1026,10 @@ class ContractGenerationService: "sourceDocument": source_document, } + detail_url = f"/reviews/{review_id}" if review_detail else None + report_download_url = review_report.get("download_url") if review_report else None + report_file_name = review_report.get("file_name") if review_report else None + chat_url = f"/chat?review={quote(json.dumps(chat_context, ensure_ascii=False), safe='')}" if review_detail else None return { "review_id": review_id, "review_title": generated_contract.template_name, @@ -1045,10 +1049,10 @@ class ContractGenerationService: "source_file_name": generated_contract.file_name, "source_text_length": review_detail.get("source_text_length"), "processing_time_ms": review_detail.get("processing_time_ms"), - "detail_url": f"/reviews/{review_id}", - "report_download_url": review_report.get("download_url") if review_report else f"/api/v1/reviews/{review_id}/report/download", - "report_file_name": review_report.get("file_name") if review_report else f"review-report-{review_id}.pdf", - "chat_url": f"/chat?review={quote(json.dumps(chat_context, ensure_ascii=False), safe='')}", + "detail_url": detail_url, + "report_download_url": report_download_url, + "report_file_name": report_file_name, + "chat_url": chat_url, "prompt_runtime": prompt_runtime, } @@ -1551,7 +1555,9 @@ class ContractGenerationService: generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) if not generated_contract: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found") - if current_user.get("role") != "admin" and generated_contract.generated_by != current_user.get("id"): + current_user_id = str(current_user.get("id") or "") + generated_by = str(generated_contract.generated_by or "") + if current_user.get("role") != "admin" and generated_by != current_user_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract not found") return generated_contract diff --git a/backend/app/services/file_service.py b/backend/app/services/file_service.py index e1337c2f..c3adf4fa 100644 --- a/backend/app/services/file_service.py +++ b/backend/app/services/file_service.py @@ -346,7 +346,9 @@ class FileService: ) if current_user.get("role") == "admin": return file_record - if allow_owner_access and file_record.uploaded_by == current_user.get("id"): + current_user_id = str(current_user.get("id") or "") + uploaded_by = str(file_record.uploaded_by or "") + if allow_owner_access and uploaded_by == current_user_id: return file_record raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, diff --git a/mobile/README.md b/mobile/README.md index f974ed17..39da15a8 100644 --- a/mobile/README.md +++ b/mobile/README.md @@ -80,6 +80,18 @@ The summary prints: - `TAXONOMY_TARGET_CARD_OK` - `TAXONOMY_FILTER_ACTIVE_OK` - `TAXONOMY_VISIBLE_KEYS` +- `CONTRACT_RESULT_PROOF_OK` +- `CONTRACT_RESULT_DOWNLOAD_OK` +- `CONTRACT_RESULT_SHARE_OK` +- `CONTRACT_RESULT_CONTINUE_OK` +- `CONTRACT_VERSION_CHAIN_PROOF_OK` +- `CONTRACT_VERSION_CHAIN_ENTRY_OK` +- `CONTRACT_VERSION_CHAIN_DOWNLOAD_OK` +- `CONTRACT_VERSION_CHAIN_DIFF_VISIBLE` +- `CONTRACT_VERSION_CHAIN_SAVE_OK` +- `CONTRACT_VERSION_CHAIN_CREATED_VISIBLE` +- `CONTRACT_VERSION_CHAIN_SAVE_MARKER` +- `CONTRACT_VERSION_CHAIN_VISIBLE_LABELS` - `DOCUMENTS_PROOF_OK` - `DOCUMENTS_WORKSPACE_READY` - `DOCUMENTS_TYPE_LIST_OK` @@ -121,6 +133,14 @@ Smoke depth options: - starts from the signed-in shell - opens `AI tools -> contract workbench`, applies the compliance/general-enterprise template filters, and verifies the filtered template keys - intended for deeper proof that template-category and industry-entry filtering still works on the proven X60PRO device profile +- `contract-result-actions` + - starts from the signed-in shell + - opens `AI tools -> contract workbench -> generated result`, then verifies download, share, and continue-editing actions + - intended for proof that generated contract delivery actions remain reachable after result-page changes +- `contract-version-chain` + - starts from the signed-in shell + - opens a generated contract result, enters the lightweight editor, appends a smoke marker, saves a new version, and verifies the version-chain entry, diff entry, download entry, and created version visibility + - intended for proof that generated contracts can complete a real mobile version-management save loop on device - `documents-workspace` - starts from the signed-in shell - opens `AI tools -> legal document workspace`, verifies the document type list, and continues toward the builder surface @@ -142,6 +162,12 @@ powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\re # Deeper proof that contract template taxonomy filters work on device powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -ConfigureDevice -SkipInstall -SmokeDepth contract-taxonomy +# Deeper proof that generated-contract result actions remain reachable +powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -ConfigureDevice -SkipInstall -SmokeDepth contract-result-actions + +# Deeper proof that generated-contract version-chain can create and surface a saved version +powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -ConfigureDevice -SkipInstall -SmokeDepth contract-version-chain + # Deeper proof that legal-document drafting remains reachable on device powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\real-device-smoke.ps1 -ExpectedIp 192.168.3. -SkipInstall -SmokeDepth documents-workspace @@ -161,6 +187,8 @@ powershell -ExecutionPolicy Bypass -File D:\ai-contract-platform\mobile\tools\re - Report download is validated - Contract download is validated - Contract template taxonomy filtering is validated through `contract-taxonomy` smoke depth +- Generated-contract result download/share/continue-editing is validated through `contract-result-actions` +- Generated-contract version-chain entry, editor save, diff affordance, version download affordance, and created-version visibility are validated through `contract-version-chain` - Legal-document types, schema loading, and draft persistence are now wired into the mobile app; the `documents-workspace` smoke depth now proves the workspace, type list, and builder entry path on the signed-in X60PRO session - The `AI tools` surface now exposes a first-class business-auxiliary launch strip for obligation extraction, filing-materials checklists, negotiation strategy, and legal-opinion boundary fallback, and the embedded legal chat keeps the same four quick prompts available for continuity - External open/share behavior is OEM-dependent and may route into vendor apps such as the default browser or WeChat diff --git a/mobile/STATUS.md b/mobile/STATUS.md index 683aba47..ea2c5866 100644 --- a/mobile/STATUS.md +++ b/mobile/STATUS.md @@ -673,3 +673,69 @@ Created on 2026-04-18. - `mobile/tools/run-real-device-product-smoke-suite.ps1 -DeviceSerial 192.168.3.93:43767 -SkipInitialInstall` returned `SUITE_OK=True` - passed steps: `shell-only`, `share-proof`, `contract-taxonomy`, `documents-workspace`, `review-legal-document`, `dashboard-review-preview`, `business-auxiliary` - suite artifact root: `mobile/qa-artifacts/real-device-product-smoke-suite/2026-05-26T08-24-01-233/` + +## Generated contract result actions upgrade (2026-05-26) + +- The mobile contract-generation result flow now has stable automation anchors for the production-facing actions: + - contract workspace template cards and generated-contract history actions + - contract builder preview / generate actions + - generated-contract result download, open, share, and continue-editing actions +- `mobile/tools/real-device-smoke.ps1` now supports a dedicated `contract-result-actions` depth. +- `mobile/tools/run-real-device-product-smoke-suite.ps1` now includes `contract-result-actions` in the default suite between `contract-taxonomy` and `documents-workspace`. +- Validation: + - PowerShell parser checks passed for both smoke scripts. + - `flutter analyze --no-pub` + - `flutter test --no-pub test/features/contracts/presentation/contract_screens_test.dart test/features/contracts/application/contract_template_controller_test.dart test/features/contracts/application/generated_contract_history_controller_test.dart` + - `flutter test --no-pub` passed with 61 tests. + - `flutter build apk --debug --no-pub --dart-define=API_BASE_URL=http://192.168.3.90:8001/api/v1 --dart-define=APP_ENV=server-dogfood` +- Real-device smoke status: + - the current wireless-debug serial `192.168.3.93:45347` is connected and usable for real-device smoke. + - `mobile/tools/real-device-smoke.ps1 -DeviceSerial 192.168.3.93:45347 -ConfigureDevice -SkipInstall -SmokeDepth contract-result-actions -LoginAccount cyuqei@gmail.com -LoginPassword ` returned `SMOKE_OK=True`. + - the full product smoke suite now passes on the same device: + - `mobile/tools/run-real-device-product-smoke-suite.ps1 -DeviceSerial 192.168.3.93:45347 -ConfigureDevice -SkipInitialInstall -LoginAccount cyuqei@gmail.com -LoginPassword ` returned `SUITE_OK=True` + - passed steps: `shell-only`, `share-proof`, `contract-taxonomy`, `contract-result-actions`, `documents-workspace`, `review-legal-document`, `dashboard-review-preview`, `business-auxiliary` + - latest artifact root: `mobile/qa-artifacts/real-device-product-smoke-suite/2026-05-26T10-30-31-688/` + +## Generated contract version-chain mobile closure (2026-05-26) + +- The generated-contract result page now exposes the backend version-chain capability on Android: + - version list with base/generated version and saved editor versions + - mobile diff preview between generated-contract versions + - version download entry + - lightweight editor entry that saves edited contract content as a new backend version +- Mobile API/client coverage now includes: + - `GET /contracts/{contract_id}/versions` + - `GET /contracts/{contract_id}/versions/diff` + - `POST /contracts/{contract_id}/versions` + - `GET /contracts/{contract_id}/versions/{version_id}/download` +- `mobile/tools/real-device-smoke.ps1` now supports `contract-version-chain`. +- `mobile/tools/run-real-device-product-smoke-suite.ps1` now includes `contract-version-chain` in the default suite between `contract-result-actions` and `documents-workspace`. +- Validation: + - PowerShell parser checks passed for both smoke scripts. + - `flutter analyze --no-pub` + - `flutter test --no-pub test/features/contracts` + - `flutter build apk --debug --no-pub --dart-define=API_BASE_URL=http://192.168.3.90:8001/api/v1 --dart-define=APP_ENV=server-dogfood` + - `mobile/tools/real-device-smoke.ps1 -DeviceSerial 192.168.3.93:45347 -ConfigureDevice -SmokeDepth contract-version-chain -LoginAccount cyuqei@gmail.com -LoginPassword ` returned `SMOKE_OK=True` + - `mobile/tools/run-real-device-product-smoke-suite.ps1 -DeviceSerial 192.168.3.93:45347 -ConfigureDevice -SkipInitialInstall -LoginAccount cyuqei@gmail.com -LoginPassword ` returned `SUITE_OK=True` + - passed suite steps: `shell-only`, `share-proof`, `contract-taxonomy`, `contract-result-actions`, `contract-version-chain`, `documents-workspace`, `review-legal-document`, `dashboard-review-preview`, `business-auxiliary` + - latest artifact root: `mobile/qa-artifacts/real-device-product-smoke-suite/2026-05-26T11-23-24-023/` + +## Generated contract version save-loop hardening (2026-05-26) + +- The Android version editor now exposes stable automation anchors for: + - change-summary input + - contract-content input + - bottom save action +- The `contract-version-chain` smoke depth is no longer display-only: + - it enters the editor from a generated contract result + - appends a timestamped smoke marker to the summary/content + - saves a new backend version + - verifies the app returned to the result page and the created version marker is visible in the version list +- Additional result fields now exist in `summary.txt` for the save loop: + - `CONTRACT_VERSION_CHAIN_SAVE_OK` + - `CONTRACT_VERSION_CHAIN_CREATED_VISIBLE` + - `CONTRACT_VERSION_CHAIN_SAVE_MARKER` +- Local validation for this hardening slice: + - `flutter analyze --no-pub` + - `flutter test --no-pub test/features/contracts` + - PowerShell parser check for `mobile/tools/real-device-smoke.ps1` diff --git a/mobile/lib/features/contracts/application/generated_contract_version_controller.dart b/mobile/lib/features/contracts/application/generated_contract_version_controller.dart new file mode 100644 index 00000000..cc220549 --- /dev/null +++ b/mobile/lib/features/contracts/application/generated_contract_version_controller.dart @@ -0,0 +1,241 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../../../shared/network/api_client.dart'; +import '../../../shared/storage/session_store.dart'; +import '../domain/contract_models.dart'; +import '../domain/contract_version_models.dart'; + +class GeneratedContractVersionController { + GeneratedContractVersionController({ApiClient? apiClient}) + : _apiClient = apiClient ?? const ApiClient(); + + final ApiClient _apiClient; + + final ValueNotifier isLoading = ValueNotifier(false); + final ValueNotifier isSaving = ValueNotifier(false); + final ValueNotifier isDownloading = ValueNotifier(false); + final ValueNotifier errorMessage = ValueNotifier(null); + final ValueNotifier versions = + ValueNotifier(null); + final ValueNotifier diff = + ValueNotifier(null); + final ValueNotifier localFilePath = ValueNotifier(null); + + Future loadVersions(String contractId) async { + final session = SessionStore.current; + if (session == null || !session.isAuthenticated) { + errorMessage.value = '请先登录后查看版本链'; + versions.value = null; + return; + } + + isLoading.value = true; + errorMessage.value = null; + try { + versions.value = await _apiClient.listGeneratedContractVersions( + session.accessToken, + contractId, + ); + } on ApiException catch (error) { + if (error.isUnauthorized) { + await SessionStore.clear(); + errorMessage.value = '登录已过期,请重新登录'; + } else { + errorMessage.value = error.message; + } + versions.value = null; + } catch (_) { + errorMessage.value = '获取合同版本链失败,请稍后重试'; + versions.value = null; + } finally { + isLoading.value = false; + } + } + + Future loadDiff( + String contractId, { + String? fromVersionId, + String? toVersionId, + }) async { + final session = SessionStore.current; + if (session == null || !session.isAuthenticated) { + errorMessage.value = '请先登录后查看版本差异'; + diff.value = null; + return; + } + + isLoading.value = true; + errorMessage.value = null; + try { + diff.value = await _apiClient.diffGeneratedContractVersions( + session.accessToken, + contractId, + fromVersionId: fromVersionId, + toVersionId: toVersionId, + ); + } on ApiException catch (error) { + if (error.isUnauthorized) { + await SessionStore.clear(); + errorMessage.value = '登录已过期,请重新登录'; + } else { + errorMessage.value = error.message; + } + diff.value = null; + } catch (_) { + errorMessage.value = '获取合同版本差异失败,请稍后重试'; + diff.value = null; + } finally { + isLoading.value = false; + } + } + + Future saveVersion({ + required String contractId, + required String content, + String changeType = 'editor_save', + String? changeSummary, + String? sourceVersionId, + String? originalText, + String? replacementText, + String locateStatus = 'applied', + }) async { + final session = SessionStore.current; + if (session == null || !session.isAuthenticated) { + throw const GeneratedContractVersionException('请先登录后保存版本'); + } + + isSaving.value = true; + errorMessage.value = null; + try { + final result = await _apiClient.createGeneratedContractVersion( + session.accessToken, + contractId, + content: content, + changeType: changeType, + changeSummary: changeSummary, + sourceVersionId: sourceVersionId, + originalText: originalText, + replacementText: replacementText, + locateStatus: locateStatus, + ); + return result; + } on ApiException catch (error) { + if (error.isUnauthorized) { + await SessionStore.clear(); + errorMessage.value = '登录已过期,请重新登录'; + throw const GeneratedContractVersionException('登录已过期,请重新登录'); + } + errorMessage.value = error.message; + throw GeneratedContractVersionException(error.message); + } catch (_) { + errorMessage.value = '保存合同版本失败,请稍后重试'; + throw const GeneratedContractVersionException('保存合同版本失败,请稍后重试'); + } finally { + isSaving.value = false; + } + } + + Future downloadVersion( + GeneratedContractItem contract, + GeneratedContractVersionItem version, + ) async { + if (isDownloading.value) { + throw const GeneratedContractVersionException('版本文件正在处理,请稍候'); + } + + isDownloading.value = true; + try { + final bytes = await _resolveBytes(contract.id, version); + final directory = await _resolveVersionDirectory(); + final fileName = _buildFileName(contract.fileName, contract.id, version); + final file = File('${directory.path}${Platform.pathSeparator}$fileName'); + await file.writeAsBytes(bytes, flush: true); + localFilePath.value = file.path; + return '版本 ${version.versionLabel} 已下载到本地'; + } finally { + isDownloading.value = false; + } + } + + Future> _resolveBytes( + String contractId, + GeneratedContractVersionItem version, + ) async { + final session = SessionStore.current; + if (session == null || !session.isAuthenticated) { + throw const GeneratedContractVersionException('登录已过期,请重新登录'); + } + + try { + return await _apiClient.downloadGeneratedContractVersionFile( + session.accessToken, + contractId, + version.id, + ); + } on ApiException catch (error) { + if (error.isUnauthorized) { + await SessionStore.clear(); + throw const GeneratedContractVersionException('登录已过期,请重新登录'); + } + throw GeneratedContractVersionException(error.message); + } catch (_) { + throw const GeneratedContractVersionException('版本文件下载失败,请稍后重试'); + } + } + + Future _resolveVersionDirectory() async { + final baseDirectory = await getApplicationDocumentsDirectory(); + final directory = Directory( + '${baseDirectory.path}${Platform.pathSeparator}generated_contract_versions', + ); + if (!directory.existsSync()) { + await directory.create(recursive: true); + } + return directory; + } + + String _buildFileName( + String rawFileName, + String contractId, + GeneratedContractVersionItem version, + ) { + final trimmed = rawFileName.trim(); + final versionSuffix = '_${version.versionLabel}'; + if (trimmed.isEmpty) { + return 'generated_contract_$contractId$versionSuffix.docx'; + } + final dotIndex = trimmed.lastIndexOf('.'); + if (dotIndex > 0) { + final base = trimmed.substring(0, dotIndex); + final ext = trimmed.substring(dotIndex); + return '${_sanitizeFileName(base)}$versionSuffix$ext'; + } + return '${_sanitizeFileName(trimmed)}$versionSuffix.docx'; + } + + String _sanitizeFileName(String rawFileName) { + return rawFileName.trim().replaceAll(RegExp(r'[<>:"/\\|?*]'), '_'); + } + + void dispose() { + isLoading.dispose(); + isSaving.dispose(); + isDownloading.dispose(); + errorMessage.dispose(); + versions.dispose(); + diff.dispose(); + localFilePath.dispose(); + } +} + +class GeneratedContractVersionException implements Exception { + const GeneratedContractVersionException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/mobile/lib/features/contracts/domain/contract_version_models.dart b/mobile/lib/features/contracts/domain/contract_version_models.dart new file mode 100644 index 00000000..7510374e --- /dev/null +++ b/mobile/lib/features/contracts/domain/contract_version_models.dart @@ -0,0 +1,254 @@ +class GeneratedContractVersionItem { + const GeneratedContractVersionItem({ + required this.id, + required this.contractId, + required this.versionNumber, + required this.versionLabel, + required this.sourceVersionId, + required this.changeType, + required this.changeSummary, + required this.originalText, + required this.replacementText, + required this.content, + required this.locateStatus, + required this.createdBy, + required this.createdAt, + required this.detailUrl, + }); + + final String id; + final String contractId; + final int versionNumber; + final String versionLabel; + final String? sourceVersionId; + final String changeType; + final String changeSummary; + final String originalText; + final String replacementText; + final String content; + final String locateStatus; + final String createdBy; + final String createdAt; + final String detailUrl; + + bool get isBase => id == 'base' || versionNumber == 1; + + factory GeneratedContractVersionItem.fromJson( + Map json, { + String? contractId, + }) { + final versionNumber = (json['version_number'] as num?)?.toInt() ?? 1; + final versionLabel = json['version_label']?.toString() ?? 'v$versionNumber'; + return GeneratedContractVersionItem( + id: json['id']?.toString() ?? + (versionNumber == 1 || versionLabel == 'v1' ? 'base' : ''), + contractId: json['contract_id']?.toString() ?? contractId ?? '', + versionNumber: versionNumber, + versionLabel: versionLabel, + sourceVersionId: json['source_version_id']?.toString(), + changeType: json['change_type']?.toString() ?? + (versionNumber == 1 ? 'generated' : 'editor_save'), + changeSummary: json['change_summary']?.toString() ?? + (versionNumber == 1 ? '原始生成版本' : ''), + originalText: json['original_text']?.toString() ?? '', + replacementText: json['replacement_text']?.toString() ?? '', + content: json['content']?.toString() ?? '', + locateStatus: json['locate_status']?.toString() ?? '', + createdBy: json['created_by']?.toString() ?? '', + createdAt: json['created_at']?.toString() ?? '', + detailUrl: json['detail_url']?.toString() ?? '', + ); + } +} + +class GeneratedContractVersionListResponse { + const GeneratedContractVersionListResponse({ + required this.contractId, + required this.baseVersion, + required this.items, + required this.total, + }); + + final String contractId; + final GeneratedContractVersionItem baseVersion; + final List items; + final int total; + + List get displayItems { + return [...items, baseVersion]; + } + + GeneratedContractVersionItem get latestVersion { + if (items.isEmpty) { + return baseVersion; + } + return items.reduce( + (current, next) => + next.versionNumber > current.versionNumber ? next : current, + ); + } + + factory GeneratedContractVersionListResponse.fromJson( + Map json) { + final contractId = json['contract_id']?.toString() ?? ''; + final rawItems = json['items']; + final baseMap = json['base_version'] is Map + ? json['base_version'] as Map + : {'version_number': 1, 'version_label': 'v1'}; + return GeneratedContractVersionListResponse( + contractId: contractId, + baseVersion: GeneratedContractVersionItem.fromJson( + baseMap, + contractId: contractId, + ), + items: rawItems is List + ? rawItems + .whereType>() + .map((item) => GeneratedContractVersionItem.fromJson(item, + contractId: contractId)) + .toList(growable: false) + : const [], + total: (json['total'] as num?)?.toInt() ?? 0, + ); + } +} + +class GeneratedContractVersionSnapshot { + const GeneratedContractVersionSnapshot({ + required this.id, + required this.versionNumber, + required this.versionLabel, + required this.sourceVersionId, + required this.changeType, + required this.changeSummary, + required this.createdAt, + }); + + final String id; + final int versionNumber; + final String versionLabel; + final String? sourceVersionId; + final String changeType; + final String changeSummary; + final String createdAt; + + factory GeneratedContractVersionSnapshot.fromJson(Map json) { + final versionNumber = (json['version_number'] as num?)?.toInt() ?? 1; + return GeneratedContractVersionSnapshot( + id: json['id']?.toString() ?? + (versionNumber == 1 + ? 'base' + : json['version_label']?.toString() ?? ''), + versionNumber: versionNumber, + versionLabel: json['version_label']?.toString() ?? 'v$versionNumber', + sourceVersionId: json['source_version_id']?.toString(), + changeType: json['change_type']?.toString() ?? '', + changeSummary: json['change_summary']?.toString() ?? '', + createdAt: json['created_at']?.toString() ?? '', + ); + } +} + +class GeneratedContractVersionDiffSummary { + const GeneratedContractVersionDiffSummary({ + required this.added, + required this.removed, + required this.modified, + required this.unchanged, + required this.changeCount, + required this.fromParagraphCount, + required this.toParagraphCount, + }); + + final int added; + final int removed; + final int modified; + final int unchanged; + final int changeCount; + final int fromParagraphCount; + final int toParagraphCount; + + factory GeneratedContractVersionDiffSummary.fromJson( + Map json) { + return GeneratedContractVersionDiffSummary( + added: (json['added'] as num?)?.toInt() ?? 0, + removed: (json['removed'] as num?)?.toInt() ?? 0, + modified: (json['modified'] as num?)?.toInt() ?? 0, + unchanged: (json['unchanged'] as num?)?.toInt() ?? 0, + changeCount: (json['change_count'] as num?)?.toInt() ?? 0, + fromParagraphCount: (json['from_paragraph_count'] as num?)?.toInt() ?? 0, + toParagraphCount: (json['to_paragraph_count'] as num?)?.toInt() ?? 0, + ); + } +} + +class GeneratedContractVersionDiffItem { + const GeneratedContractVersionDiffItem({ + required this.changeType, + required this.oldParagraphIndex, + required this.newParagraphIndex, + required this.oldText, + required this.newText, + }); + + final String changeType; + final int? oldParagraphIndex; + final int? newParagraphIndex; + final String oldText; + final String newText; + + factory GeneratedContractVersionDiffItem.fromJson(Map json) { + return GeneratedContractVersionDiffItem( + changeType: json['change_type']?.toString() ?? '', + oldParagraphIndex: (json['old_paragraph_index'] as num?)?.toInt(), + newParagraphIndex: (json['new_paragraph_index'] as num?)?.toInt(), + oldText: json['old_text']?.toString() ?? '', + newText: json['new_text']?.toString() ?? '', + ); + } +} + +class GeneratedContractVersionDiffResult { + const GeneratedContractVersionDiffResult({ + required this.contractId, + required this.fromVersion, + required this.toVersion, + required this.summary, + required this.items, + }); + + final String contractId; + final GeneratedContractVersionSnapshot fromVersion; + final GeneratedContractVersionSnapshot toVersion; + final GeneratedContractVersionDiffSummary summary; + final List items; + + factory GeneratedContractVersionDiffResult.fromJson( + Map json) { + final rawItems = json['items']; + return GeneratedContractVersionDiffResult( + contractId: json['contract_id']?.toString() ?? '', + fromVersion: GeneratedContractVersionSnapshot.fromJson( + json['from_version'] is Map + ? json['from_version'] as Map + : {}, + ), + toVersion: GeneratedContractVersionSnapshot.fromJson( + json['to_version'] is Map + ? json['to_version'] as Map + : {}, + ), + summary: GeneratedContractVersionDiffSummary.fromJson( + json['summary'] is Map + ? json['summary'] as Map + : {}, + ), + items: rawItems is List + ? rawItems + .whereType>() + .map(GeneratedContractVersionDiffItem.fromJson) + .toList(growable: false) + : const [], + ); + } +} diff --git a/mobile/lib/features/contracts/presentation/contract_builder_screen.dart b/mobile/lib/features/contracts/presentation/contract_builder_screen.dart index 3a95a7d5..8169b62e 100644 --- a/mobile/lib/features/contracts/presentation/contract_builder_screen.dart +++ b/mobile/lib/features/contracts/presentation/contract_builder_screen.dart @@ -151,231 +151,254 @@ class _ContractBuilderScreenState extends State { appBar: AppBar( title: Text(template.name), ), - body: ListView( - padding: EdgeInsets.zero, - children: [ - AppScaffoldPage( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppSectionCard( - title: '模板信息', - subtitle: '先确认模板说明,再填写变量并预览生成结果。', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InfoLine(label: '合同类型', value: template.contractType), - const SizedBox(height: 12), - _InfoLine( - label: '模板分类', - value: - formatTemplateCategory(template.templateCategory), - ), - const SizedBox(height: 12), - _InfoLine( - label: '行业入口', - value: - formatIndustryCategory(template.industryCategory), - ), - if (template.description.isNotEmpty) ...[ - const SizedBox(height: 12), - _InfoLine(label: '模板说明', value: template.description), - ], - if (template.previewExcerpt.isNotEmpty) ...[ - const SizedBox(height: 12), - _InfoLine( - label: '预览摘要', value: template.previewExcerpt), - ], - ], - ), - ), - if (widget.reviewContext != null) ...[ - const SizedBox(height: 16), - AppSectionCard( - title: '审查上下文已带入', - subtitle: '系统已尝试把审查意见、风险项和建议动作预填到匹配变量中,你可以继续编辑后再生成。', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InfoLine( - label: '审查任务', - value: widget.reviewContext!.reviewTitle ?? '-'), - const SizedBox(height: 12), - _InfoLine( - label: '合同类型', - value: widget.reviewContext!.contractType ?? '-'), - const SizedBox(height: 12), - _InfoLine( - label: '总体风险', - value: widget.reviewContext!.overallRisk ?? '-'), - if (widget.reviewContext!.reviewOpinion != null && - widget - .reviewContext!.reviewOpinion!.isNotEmpty) ...[ + body: Semantics( + container: true, + label: 'contract-builder-${template.id}', + child: ListView( + padding: EdgeInsets.zero, + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).height, + child: AppScaffoldPage( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppSectionCard( + title: '模板信息', + subtitle: '先确认模板说明,再填写变量并预览生成结果。', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoLine( + label: '合同类型', value: template.contractType), const SizedBox(height: 12), _InfoLine( - label: '审查意见', - value: widget.reviewContext!.reviewOpinion!), - ], - ], - ), - ), - ], - const SizedBox(height: 16), - AppSectionCard( - title: '变量填写', - subtitle: '优先填写必填变量,确认导出格式后再预览和生成。', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (final variable in template.variables) ...[ - TextField( - controller: _fieldControllers[variable.name], - minLines: variable.description.length > 24 ? 2 : 1, - maxLines: variable.description.length > 24 ? 3 : 1, - decoration: InputDecoration( - labelText: variable.required - ? '${variable.label} *' - : variable.label, - hintText: variable.description.isNotEmpty - ? variable.description - : null, + label: '模板分类', + value: formatTemplateCategory( + template.templateCategory), ), - ), - if (variable != template.variables.last) const SizedBox(height: 12), - ], - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: _exportFormat, - decoration: const InputDecoration( - labelText: '导出格式', - ), - items: const >[ - DropdownMenuItem( - value: 'docx', child: Text('Word (.docx)')), - DropdownMenuItem( - value: 'pdf', child: Text('PDF (.pdf)')), - DropdownMenuItem( - value: 'html', child: Text('HTML (.html)')), - DropdownMenuItem( - value: 'txt', child: Text('文本 (.txt)')), + _InfoLine( + label: '行业入口', + value: formatIndustryCategory( + template.industryCategory), + ), + if (template.description.isNotEmpty) ...[ + const SizedBox(height: 12), + _InfoLine( + label: '模板说明', value: template.description), + ], + if (template.previewExcerpt.isNotEmpty) ...[ + const SizedBox(height: 12), + _InfoLine( + label: '预览摘要', value: template.previewExcerpt), + ], ], - onChanged: (value) { - if (value == null) { - return; - } - setState(() { - _exportFormat = value; - }); - }, + ), + ), + if (widget.reviewContext != null) ...[ + const SizedBox(height: 16), + AppSectionCard( + title: '审查上下文已带入', + subtitle: '系统已尝试把审查意见、风险项和建议动作预填到匹配变量中,你可以继续编辑后再生成。', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoLine( + label: '审查任务', + value: + widget.reviewContext!.reviewTitle ?? '-'), + const SizedBox(height: 12), + _InfoLine( + label: '合同类型', + value: + widget.reviewContext!.contractType ?? '-'), + const SizedBox(height: 12), + _InfoLine( + label: '总体风险', + value: + widget.reviewContext!.overallRisk ?? '-'), + if (widget.reviewContext!.reviewOpinion != null && + widget.reviewContext!.reviewOpinion! + .isNotEmpty) ...[ + const SizedBox(height: 12), + _InfoLine( + label: '审查意见', + value: widget.reviewContext!.reviewOpinion!), + ], + ], + ), ), ], - ), - ), - const SizedBox(height: 16), - ValueListenableBuilder( - valueListenable: _controller.preview, - builder: (context, preview, _) { - if (preview == null) { - return const SizedBox.shrink(); - } - return Column( - children: [ - AppSectionCard( - title: '合同预览', - subtitle: '这里只是生成前预览,不会写入正式合同记录。', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (preview.variableSummary.isNotEmpty) ...[ - for (final item in preview.variableSummary) ...[ - _InfoLine( - label: item.label, - value: item.valuePreview.isEmpty - ? '(空)' - : item.valuePreview, - ), - if (item != preview.variableSummary.last) - const SizedBox(height: 12), - ], - const SizedBox(height: 16), - ], - Text(preview.previewContent), - const SizedBox(height: 16), - _InfoLine( - label: '当前导出格式', - value: _exportFormat.toUpperCase()), + const SizedBox(height: 16), + AppSectionCard( + title: '变量填写', + subtitle: '优先填写必填变量,确认导出格式后再预览和生成。', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final variable in template.variables) ...[ + TextField( + controller: _fieldControllers[variable.name], + minLines: + variable.description.length > 24 ? 2 : 1, + maxLines: + variable.description.length > 24 ? 3 : 1, + decoration: InputDecoration( + labelText: variable.required + ? '${variable.label} *' + : variable.label, + hintText: variable.description.isNotEmpty + ? variable.description + : null, + ), + ), + if (variable != template.variables.last) + const SizedBox(height: 12), + ], + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: _exportFormat, + decoration: const InputDecoration( + labelText: '导出格式', + ), + items: const >[ + DropdownMenuItem( + value: 'docx', child: Text('Word (.docx)')), + DropdownMenuItem( + value: 'pdf', child: Text('PDF (.pdf)')), + DropdownMenuItem( + value: 'html', child: Text('HTML (.html)')), + DropdownMenuItem( + value: 'txt', child: Text('文本 (.txt)')), ], + onChanged: (value) { + if (value == null) { + return; + } + setState(() { + _exportFormat = value; + }); + }, ), - ), - const SizedBox(height: 16), - ], - ); - }, - ), - ValueListenableBuilder( - valueListenable: _controller.errorMessage, - builder: (context, message, _) { - if (message == null || message.isEmpty) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(bottom: 16), - child: Text( - message, - style: TextStyle( - color: Theme.of(context).colorScheme.error), - ), - ); - }, - ), - Row( - children: [ - Expanded( - child: ValueListenableBuilder( - valueListenable: _controller.isPreviewing, - builder: (context, isPreviewing, _) { - return OutlinedButton.icon( - onPressed: isPreviewing ? null : _preview, - icon: isPreviewing - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2), - ) - : const Icon(Icons.visibility_outlined), - label: Text(isPreviewing ? '预览中...' : '预览合同'), - ); - }, + ], ), ), - const SizedBox(width: 12), - Expanded( - child: ValueListenableBuilder( - valueListenable: _controller.isGenerating, - builder: (context, isGenerating, _) { - return FilledButton.icon( - onPressed: isGenerating ? null : _generate, - icon: isGenerating - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2), - ) - : const Icon(Icons.auto_awesome), - label: Text(isGenerating ? '生成中...' : '生成合同'), - ); - }, - ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: _controller.preview, + builder: (context, preview, _) { + if (preview == null) { + return const SizedBox.shrink(); + } + return Column( + children: [ + AppSectionCard( + title: '合同预览', + subtitle: '这里只是生成前预览,不会写入正式合同记录。', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (preview.variableSummary.isNotEmpty) ...[ + for (final item + in preview.variableSummary) ...[ + _InfoLine( + label: item.label, + value: item.valuePreview.isEmpty + ? '(空)' + : item.valuePreview, + ), + if (item != preview.variableSummary.last) + const SizedBox(height: 12), + ], + const SizedBox(height: 16), + ], + Text(preview.previewContent), + const SizedBox(height: 16), + _InfoLine( + label: '当前导出格式', + value: _exportFormat.toUpperCase()), + ], + ), + ), + const SizedBox(height: 16), + ], + ); + }, + ), + ValueListenableBuilder( + valueListenable: _controller.errorMessage, + builder: (context, message, _) { + if (message == null || message.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: Text( + message, + style: TextStyle( + color: Theme.of(context).colorScheme.error), + ), + ); + }, + ), + Row( + children: [ + Expanded( + child: ValueListenableBuilder( + valueListenable: _controller.isPreviewing, + builder: (context, isPreviewing, _) { + return Semantics( + label: 'preview-contract', + button: true, + child: OutlinedButton.icon( + onPressed: isPreviewing ? null : _preview, + icon: isPreviewing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Icon(Icons.visibility_outlined), + label: Text(isPreviewing ? '预览中...' : '预览合同'), + ), + ); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: ValueListenableBuilder( + valueListenable: _controller.isGenerating, + builder: (context, isGenerating, _) { + return Semantics( + label: 'generate-contract', + button: true, + child: FilledButton.icon( + onPressed: isGenerating ? null : _generate, + icon: isGenerating + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome), + label: Text(isGenerating ? '生成中...' : '生成合同'), + ), + ); + }, + ), + ), + ], ), ], ), - ], + ), ), - ), - ], + ], + ), ), ); } diff --git a/mobile/lib/features/contracts/presentation/contract_template_list_screen.dart b/mobile/lib/features/contracts/presentation/contract_template_list_screen.dart index bb4026b1..edf2679b 100644 --- a/mobile/lib/features/contracts/presentation/contract_template_list_screen.dart +++ b/mobile/lib/features/contracts/presentation/contract_template_list_screen.dart @@ -18,10 +18,14 @@ class ContractTemplateListScreen extends StatefulWidget { super.key, this.reviewContext, this.embedded = false, + this.controller, + this.historyController, }); final ContractReviewContext? reviewContext; final bool embedded; + final ContractTemplateController? controller; + final GeneratedContractHistoryController? historyController; @override State createState() => @@ -30,9 +34,10 @@ class ContractTemplateListScreen extends StatefulWidget { class _ContractTemplateListScreenState extends State with SingleTickerProviderStateMixin { - final ContractTemplateController _controller = ContractTemplateController(); - final GeneratedContractHistoryController _historyController = - GeneratedContractHistoryController(); + late final ContractTemplateController _controller; + late final GeneratedContractHistoryController _historyController; + late final bool _ownsController; + late final bool _ownsHistoryController; final TextEditingController _keywordController = TextEditingController(); final TextEditingController _templateKeywordController = TextEditingController(); @@ -46,6 +51,11 @@ class _ContractTemplateListScreenState extends State @override void initState() { super.initState(); + _controller = widget.controller ?? ContractTemplateController(); + _historyController = + widget.historyController ?? GeneratedContractHistoryController(); + _ownsController = widget.controller == null; + _ownsHistoryController = widget.historyController == null; _tabController = TabController(length: 2, vsync: this); _controller.load(); _historyController.load(); @@ -54,8 +64,12 @@ class _ContractTemplateListScreenState extends State @override void dispose() { _tabController.dispose(); - _controller.dispose(); - _historyController.dispose(); + if (_ownsController) { + _controller.dispose(); + } + if (_ownsHistoryController) { + _historyController.dispose(); + } _keywordController.dispose(); _templateKeywordController.dispose(); super.dispose(); @@ -461,14 +475,22 @@ class _ContractTemplateListScreenState extends State ); if (widget.embedded) { - return body; + return Semantics( + container: true, + label: 'contract-workspace', + child: body, + ); } - return Scaffold( - appBar: AppBar( - title: const Text('合同生成'), + return Semantics( + container: true, + label: 'contract-workspace', + child: Scaffold( + appBar: AppBar( + title: const Text('合同生成'), + ), + body: body, ), - body: body, ); } @@ -795,9 +817,21 @@ class _WorkspaceTopBar extends StatelessWidget { unselectedLabelColor: BrandAssets.muted, indicatorColor: BrandAssets.accentLime, dividerColor: Colors.transparent, - tabs: const [ - Tab(text: '模板'), - Tab(text: '历史结果'), + tabs: [ + Tab( + child: Semantics( + label: 'contract-template-tab', + button: true, + child: Text('模板'), + ), + ), + Tab( + child: Semantics( + label: 'contract-history-tab', + button: true, + child: Text('历史结果'), + ), + ), ], ), ], @@ -947,81 +981,89 @@ class _TemplateListTile extends StatelessWidget { ? template.description : template.previewExcerpt; - return Container( - decoration: BoxDecoration( - color: BrandAssets.surface, - borderRadius: BorderRadius.circular(BrandAssets.radius), - border: Border.all(color: BrandAssets.line), - boxShadow: const [BrandAssets.cardShadow], - ), - child: ListTile( - onTap: onTap, - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: BrandAssets.surfaceTinted, - borderRadius: BorderRadius.circular(BrandAssets.radius), + return Semantics( + label: 'open-contract-template-${template.id}', + button: true, + child: Container( + decoration: BoxDecoration( + color: BrandAssets.surface, + borderRadius: BorderRadius.circular(BrandAssets.radius), + border: Border.all(color: BrandAssets.line), + boxShadow: const [BrandAssets.cardShadow], + ), + child: ListTile( + onTap: onTap, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: BrandAssets.surfaceTinted, + borderRadius: BorderRadius.circular(BrandAssets.radius), + ), + child: const Icon(Icons.description_outlined, + color: BrandAssets.brand), ), - child: - const Icon(Icons.description_outlined, color: BrandAssets.brand), - ), - title: Row( - children: [ - Expanded( - child: Text( - template.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, + title: Row( + children: [ + Expanded( + child: Text( + template.name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), ), - ), - const SizedBox(width: 8), - FilledButton.tonal( - onPressed: onTap, - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - minimumSize: const Size(0, 34), - ), - child: const Text('使用'), - ), - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 4), - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - _MetaChip( - label: template.contractType, - color: BrandAssets.accentCyan), - _MetaChip( - label: formatTemplateCategory(template.templateCategory), - color: BrandAssets.accentAmber), - _MetaChip( - label: formatIndustryCategory(template.industryCategory), - color: BrandAssets.primaryDark), - _MetaChip( - label: '${template.variables.length} 个变量', - color: BrandAssets.brand), - ], - ), - if (summary.isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - summary, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, + const SizedBox(width: 8), + Semantics( + label: 'use-contract-template-${template.id}', + button: true, + child: FilledButton.tonal( + onPressed: onTap, + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: + const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + minimumSize: const Size(0, 34), + ), + child: const Text('使用'), + ), ), ], - ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 4), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _MetaChip( + label: template.contractType, + color: BrandAssets.accentCyan), + _MetaChip( + label: formatTemplateCategory(template.templateCategory), + color: BrandAssets.accentAmber), + _MetaChip( + label: formatIndustryCategory(template.industryCategory), + color: BrandAssets.primaryDark), + _MetaChip( + label: '${template.variables.length} 个变量', + color: BrandAssets.brand), + ], + ), + if (summary.isNotEmpty) ...[ + const SizedBox(height: 8), + Text( + summary, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ], + ), ), ), ); @@ -1126,26 +1168,34 @@ class _GeneratedContractResultTile extends StatelessWidget { style: Theme.of(context).textTheme.bodySmall, ), ), - TextButton( - onPressed: onTap, - style: TextButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: - const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - minimumSize: const Size(0, 32), + Semantics( + label: 'open-generated-contract-${item.id}', + button: true, + child: TextButton( + onPressed: onTap, + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 6), + minimumSize: const Size(0, 32), + ), + child: const Text('结果'), ), - child: const Text('结果'), ), const SizedBox(width: 8), - FilledButton.tonal( - onPressed: onEdit, - style: FilledButton.styleFrom( - visualDensity: VisualDensity.compact, - padding: - const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - minimumSize: const Size(0, 34), + Semantics( + label: 'continue-generated-contract-${item.id}', + button: true, + child: FilledButton.tonal( + onPressed: onEdit, + style: FilledButton.styleFrom( + visualDensity: VisualDensity.compact, + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + minimumSize: const Size(0, 34), + ), + child: const Text('继续编辑'), ), - child: const Text('继续编辑'), ), ], ), diff --git a/mobile/lib/features/contracts/presentation/generated_contract_result_screen.dart b/mobile/lib/features/contracts/presentation/generated_contract_result_screen.dart index 71d698df..d240765f 100644 --- a/mobile/lib/features/contracts/presentation/generated_contract_result_screen.dart +++ b/mobile/lib/features/contracts/presentation/generated_contract_result_screen.dart @@ -6,8 +6,10 @@ import '../../../shared/storage/session_store.dart'; import '../../../shared/widgets/app_scaffold_page.dart'; import '../../../shared/widgets/app_section_card.dart'; import '../application/generated_contract_file_controller.dart'; +import '../application/generated_contract_version_controller.dart'; import '../domain/contract_models.dart'; import '../domain/contract_review_context.dart'; +import '../domain/contract_version_models.dart'; import 'contract_builder_screen.dart'; class GeneratedContractResultScreen extends StatefulWidget { @@ -15,10 +17,12 @@ class GeneratedContractResultScreen extends StatefulWidget { super.key, required this.contract, this.reviewContext, + this.versionController, }); final GeneratedContractItem contract; final ContractReviewContext? reviewContext; + final GeneratedContractVersionController? versionController; @override State createState() => @@ -30,10 +34,26 @@ class _GeneratedContractResultScreenState final GeneratedContractFileController _fileController = GeneratedContractFileController(); final ApiClient _apiClient = const ApiClient(); + late final GeneratedContractVersionController _versionController; + late final bool _ownsVersionController; + + @override + void initState() { + super.initState(); + _ownsVersionController = widget.versionController == null; + _versionController = + widget.versionController ?? GeneratedContractVersionController(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadVersionChain(); + }); + } @override void dispose() { _fileController.dispose(); + if (_ownsVersionController) { + _versionController.dispose(); + } super.dispose(); } @@ -114,6 +134,81 @@ class _GeneratedContractResultScreenState } } + Future _loadVersionChain() async { + await _versionController.loadVersions(widget.contract.id); + if (!mounted || _versionController.errorMessage.value != null) { + return; + } + final versionList = _versionController.versions.value; + if (versionList == null || versionList.items.isEmpty) { + _versionController.diff.value = null; + return; + } + await _versionController.loadDiff(widget.contract.id); + } + + Future _showDiffForVersion(GeneratedContractVersionItem version) async { + await _versionController.loadDiff( + widget.contract.id, + toVersionId: version.id, + ); + if (!mounted) { + return; + } + final error = _versionController.errorMessage.value; + if (error != null && error.isNotEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error)), + ); + } + } + + Future _downloadVersion(GeneratedContractVersionItem version) async { + try { + final message = + await _versionController.downloadVersion(widget.contract, version); + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message)), + ); + } on GeneratedContractVersionException catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error.message)), + ); + } + } + + Future _openVersionEditor() async { + final versionList = _versionController.versions.value; + final latestVersion = versionList?.latestVersion; + final saved = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => _GeneratedContractVersionEditorScreen( + contract: widget.contract, + controller: _versionController, + initialContent: latestVersion?.content.trim().isNotEmpty == true + ? latestVersion!.content + : widget.contract.generatedContent, + sourceVersionId: latestVersion == null || latestVersion.isBase + ? null + : latestVersion.id, + ), + ), + ); + if (saved != true || !mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('合同新版本已保存')), + ); + await _loadVersionChain(); + } + @override Widget build(BuildContext context) { final contract = widget.contract; @@ -122,195 +217,467 @@ class _GeneratedContractResultScreenState appBar: AppBar( title: const Text('生成结果'), ), - body: ListView( - padding: EdgeInsets.zero, - children: [ - AppScaffoldPage( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - AppSectionCard( - title: '合同文件', - subtitle: '生成完成后可以直接下载到本地、打开查看或系统分享。', - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _InfoLine(label: '模板名称', value: contract.templateName), - const SizedBox(height: 12), - _InfoLine(label: '合同类型', value: contract.contractType), - if (contract.documentTypeCode.isNotEmpty) ...[ - const SizedBox(height: 12), - _InfoLine( - label: '文书类型', value: contract.documentTypeCode), - ], - const SizedBox(height: 12), - _InfoLine( - label: '模板分类', - value: - formatTemplateCategory(contract.templateCategory), + body: Semantics( + container: true, + label: 'generated-contract-result-${contract.id}', + child: ListView( + padding: EdgeInsets.zero, + children: [ + SizedBox( + height: MediaQuery.sizeOf(context).height, + child: AppScaffoldPage( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppSectionCard( + title: '合同文件', + subtitle: '生成完成后可以直接下载到本地、打开查看或系统分享。', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoLine( + label: '模板名称', value: contract.templateName), + const SizedBox(height: 12), + _InfoLine( + label: '合同类型', value: contract.contractType), + if (contract.documentTypeCode.isNotEmpty) ...[ + const SizedBox(height: 12), + _InfoLine( + label: '文书类型', + value: contract.documentTypeCode), + ], + const SizedBox(height: 12), + _InfoLine( + label: '模板分类', + value: formatTemplateCategory( + contract.templateCategory), + ), + const SizedBox(height: 12), + _InfoLine( + label: '行业入口', + value: formatIndustryCategory( + contract.industryCategory), + ), + const SizedBox(height: 12), + _InfoLine( + label: '导出格式', + value: contract.exportFormat.toUpperCase()), + const SizedBox(height: 12), + _InfoLine(label: '文件名称', value: contract.fileName), + const SizedBox(height: 12), + _InfoLine( + label: '文件大小', + value: _formatFileSize(contract.fileSize)), + const SizedBox(height: 12), + _InfoLine(label: '生成时间', value: contract.generatedAt), + const SizedBox(height: 12), + ValueListenableBuilder( + valueListenable: _fileController.localFilePath, + builder: (context, localPath, _) { + if (localPath == null || localPath.isEmpty) { + return const SizedBox.shrink(); + } + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Semantics( + label: 'generated-contract-local-file-ready', + child: _InfoLine( + label: '本地文件', value: localPath), + ), + ); + }, + ), + ValueListenableBuilder( + valueListenable: _fileController.isProcessing, + builder: (context, isProcessing, _) { + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + Semantics( + label: + 'continue-generated-contract-editing', + button: true, + child: OutlinedButton.icon( + onPressed: isProcessing + ? null + : _continueEditing, + icon: const Icon(Icons.edit_outlined), + label: const Text('继续编辑'), + ), + ), + Semantics( + label: 'download-generated-contract', + button: true, + child: FilledButton.icon( + onPressed: isProcessing + ? null + : () => _handleAction(() => + _fileController + .download(contract)), + icon: isProcessing + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : const Icon(Icons.download_outlined), + label: Text( + isProcessing ? '处理中...' : '下载合同'), + ), + ), + Semantics( + label: 'open-generated-contract-file', + button: true, + child: OutlinedButton.icon( + onPressed: isProcessing + ? null + : () => _handleAction(() => + _fileController.open(contract)), + icon: const Icon(Icons.open_in_new), + label: const Text('打开文件'), + ), + ), + Semantics( + label: 'share-generated-contract', + button: true, + child: OutlinedButton.icon( + onPressed: isProcessing + ? null + : () => _handleAction(() => + _fileController.share(contract)), + icon: const Icon(Icons.share_outlined), + label: const Text('分享合同'), + ), + ), + ], + ); + }, + ), + ], ), - const SizedBox(height: 12), - _InfoLine( - label: '行业入口', - value: - formatIndustryCategory(contract.industryCategory), - ), - const SizedBox(height: 12), - _InfoLine( - label: '导出格式', - value: contract.exportFormat.toUpperCase()), - const SizedBox(height: 12), - _InfoLine(label: '文件名称', value: contract.fileName), - const SizedBox(height: 12), - _InfoLine( - label: '文件大小', - value: _formatFileSize(contract.fileSize)), - const SizedBox(height: 12), - _InfoLine(label: '生成时间', value: contract.generatedAt), - const SizedBox(height: 12), - ValueListenableBuilder( - valueListenable: _fileController.localFilePath, - builder: (context, localPath, _) { - if (localPath == null || localPath.isEmpty) { - return const SizedBox.shrink(); - } - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: _InfoLine(label: '本地文件', value: localPath), - ); - }, - ), - ValueListenableBuilder( - valueListenable: _fileController.isProcessing, - builder: (context, isProcessing, _) { - return Wrap( - spacing: 12, - runSpacing: 12, - children: [ - OutlinedButton.icon( - onPressed: - isProcessing ? null : _continueEditing, - icon: const Icon(Icons.edit_outlined), - label: const Text('继续编辑'), - ), - FilledButton.icon( - onPressed: isProcessing - ? null - : () => _handleAction(() => - _fileController.download(contract)), - icon: isProcessing - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2), - ) - : const Icon(Icons.download_outlined), - label: Text(isProcessing ? '处理中...' : '下载合同'), - ), - OutlinedButton.icon( - onPressed: isProcessing - ? null - : () => _handleAction( - () => _fileController.open(contract)), - icon: const Icon(Icons.open_in_new), - label: const Text('打开文件'), - ), - OutlinedButton.icon( - onPressed: isProcessing - ? null - : () => _handleAction( - () => _fileController.share(contract)), - icon: const Icon(Icons.share_outlined), - label: const Text('分享合同'), - ), + ), + const SizedBox(height: 16), + _ContractVersionChainCard( + contract: contract, + controller: _versionController, + onRefresh: _loadVersionChain, + onOpenEditor: _openVersionEditor, + onShowDiff: _showDiffForVersion, + onDownload: _downloadVersion, + ), + const SizedBox(height: 16), + AppSectionCard( + title: '内容预览', + subtitle: '这里展示生成后的正文内容,便于快速确认主要变量是否正确替换。', + child: Text(contract.generatedContent), + ), + if (contract.missingFields.isNotEmpty || + contract.aiHints.isNotEmpty || + contract.relatedLegalArticles.isNotEmpty) ...[ + const SizedBox(height: 16), + AppSectionCard( + title: '生成提示', + subtitle: '如果文书来自动态要素流程,这里会显示待补字段、AI 提示和相关法条。', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (contract.missingFields.isNotEmpty) ...[ + Text('待补字段', + style: + Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + for (final field in contract.missingFields) ...[ + _BulletLine( + title: field.label.isNotEmpty + ? field.label + : field.fieldKey, + subtitle: field.message.isNotEmpty + ? field.message + : field.placeholder, + ), + if (field != contract.missingFields.last) + const SizedBox(height: 8), + ], + if (contract.aiHints.isNotEmpty || + contract.relatedLegalArticles.isNotEmpty) + const SizedBox(height: 16), ], - ); - }, + if (contract.aiHints.isNotEmpty) ...[ + Text('AI 提示', + style: + Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + for (final hint in contract.aiHints) ...[ + _BulletLine( + title: hint.message, + subtitle: hint.fieldKeys.isEmpty + ? hint.level + : hint.fieldKeys.join(' · '), + ), + if (hint != contract.aiHints.last) + const SizedBox(height: 8), + ], + if (contract.relatedLegalArticles.isNotEmpty) + const SizedBox(height: 16), + ], + if (contract.relatedLegalArticles.isNotEmpty) ...[ + Text('相关法条', + style: + Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + for (final article + in contract.relatedLegalArticles) ...[ + _BulletLine( + title: article.sourceTitle.isNotEmpty + ? article.sourceTitle + : article.title, + subtitle: [ + if (article.articleNo.isNotEmpty) + article.articleNo, + if (article.authority.isNotEmpty) + article.authority, + ].join(' · '), + ), + if (article != + contract.relatedLegalArticles.last) + const SizedBox(height: 8), + ], + ], + ], + ), ), ], - ), + ], ), - const SizedBox(height: 16), - AppSectionCard( - title: '内容预览', - subtitle: '这里展示生成后的正文内容,便于快速确认主要变量是否正确替换。', - child: Text(contract.generatedContent), - ), - if (contract.missingFields.isNotEmpty || - contract.aiHints.isNotEmpty || - contract.relatedLegalArticles.isNotEmpty) ...[ - const SizedBox(height: 16), - AppSectionCard( - title: '生成提示', - subtitle: '如果文书来自动态要素流程,这里会显示待补字段、AI 提示和相关法条。', - child: Column( + ), + ), + ], + ), + ), + ); + } +} + +class _ContractVersionChainCard extends StatelessWidget { + const _ContractVersionChainCard({ + required this.contract, + required this.controller, + required this.onRefresh, + required this.onOpenEditor, + required this.onShowDiff, + required this.onDownload, + }); + + final GeneratedContractItem contract; + final GeneratedContractVersionController controller; + final Future Function() onRefresh; + final VoidCallback onOpenEditor; + final Future Function(GeneratedContractVersionItem version) onShowDiff; + final Future Function(GeneratedContractVersionItem version) onDownload; + + @override + Widget build(BuildContext context) { + return Semantics( + container: true, + label: 'contract-version-chain', + child: AppSectionCard( + title: '版本链', + subtitle: '保留 ${contract.templateName} 的编辑痕迹、差异对比和可下载交付版本。', + child: ValueListenableBuilder( + valueListenable: controller.isLoading, + builder: (context, isLoading, _) { + return ValueListenableBuilder( + valueListenable: controller.errorMessage, + builder: (context, errorMessage, _) { + return ValueListenableBuilder< + GeneratedContractVersionListResponse?>( + valueListenable: controller.versions, + builder: (context, versionList, _) { + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (contract.missingFields.isNotEmpty) ...[ - Text('待补字段', - style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 8), - for (final field in contract.missingFields) ...[ - _BulletLine( - title: field.label.isNotEmpty - ? field.label - : field.fieldKey, - subtitle: field.message.isNotEmpty - ? field.message - : field.placeholder, + Semantics( + label: 'contract-version-chain', + child: const SizedBox(height: 1, width: 1), + ), + Wrap( + spacing: 12, + runSpacing: 12, + children: [ + Semantics( + label: 'refresh-contract-versions', + button: true, + child: OutlinedButton.icon( + onPressed: isLoading ? null : onRefresh, + icon: const Icon(Icons.refresh), + label: const Text('刷新版本'), + ), ), - if (field != contract.missingFields.last) - const SizedBox(height: 8), - ], - if (contract.aiHints.isNotEmpty || - contract.relatedLegalArticles.isNotEmpty) - const SizedBox(height: 16), - ], - if (contract.aiHints.isNotEmpty) ...[ - Text('AI 提示', - style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 8), - for (final hint in contract.aiHints) ...[ - _BulletLine( - title: hint.message, - subtitle: hint.fieldKeys.isEmpty - ? hint.level - : hint.fieldKeys.join(' · '), + Semantics( + label: 'edit-generated-contract-version', + button: true, + child: FilledButton.icon( + onPressed: onOpenEditor, + icon: const Icon(Icons.edit_note_outlined), + label: const Text('编辑并保存新版本'), + ), ), - if (hint != contract.aiHints.last) - const SizedBox(height: 8), ], - if (contract.relatedLegalArticles.isNotEmpty) - const SizedBox(height: 16), + ), + if (isLoading) ...[ + const SizedBox(height: 16), + const LinearProgressIndicator(), ], - if (contract.relatedLegalArticles.isNotEmpty) ...[ - Text('相关法条', - style: Theme.of(context).textTheme.titleSmall), - const SizedBox(height: 8), - for (final article - in contract.relatedLegalArticles) ...[ - _BulletLine( - title: article.sourceTitle.isNotEmpty - ? article.sourceTitle - : article.title, - subtitle: [ - if (article.articleNo.isNotEmpty) - article.articleNo, - if (article.authority.isNotEmpty) - article.authority, - ].join(' · '), + if (errorMessage != null && + errorMessage.isNotEmpty) ...[ + const SizedBox(height: 12), + Text( + errorMessage, + style: TextStyle( + color: Theme.of(context).colorScheme.error, ), - if (article != contract.relatedLegalArticles.last) - const SizedBox(height: 8), - ], + ), ], + const SizedBox(height: 16), + _VersionList( + versionList: versionList, + onShowDiff: onShowDiff, + onDownload: onDownload, + ), + const SizedBox(height: 16), + _VersionDiffPreview(controller: controller), ], - ), - ), - ], - ], + ); + }, + ); + }, + ); + }, + ), + ), + ); + } +} + +class _VersionList extends StatelessWidget { + const _VersionList({ + required this.versionList, + required this.onShowDiff, + required this.onDownload, + }); + + final GeneratedContractVersionListResponse? versionList; + final Future Function(GeneratedContractVersionItem version) onShowDiff; + final Future Function(GeneratedContractVersionItem version) onDownload; + + @override + Widget build(BuildContext context) { + if (versionList == null) { + return const Text('正在同步合同版本链...'); + } + + final versions = versionList!.displayItems; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('版本记录', style: Theme.of(context).textTheme.titleSmall), + const SizedBox(height: 8), + for (final version in versions) ...[ + _VersionTile( + version: version, + canDiff: !version.isBase || versionList!.items.isNotEmpty, + onShowDiff: () => onShowDiff(version), + onDownload: () => onDownload(version), + ), + if (version != versions.last) const SizedBox(height: 10), + ], + ], + ); + } +} + +class _VersionTile extends StatelessWidget { + const _VersionTile({ + required this.version, + required this.canDiff, + required this.onShowDiff, + required this.onDownload, + }); + + final GeneratedContractVersionItem version; + final bool canDiff; + final VoidCallback onShowDiff; + final VoidCallback onDownload; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.42), + borderRadius: BorderRadius.circular(14), + border: Border.all(color: colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + Text( + version.versionLabel, + style: Theme.of(context).textTheme.titleSmall, + ), + _VersionBadge( + label: version.isBase + ? '原始生成' + : _changeTypeLabel(version.changeType), + ), + if (version.locateStatus.isNotEmpty) + _VersionBadge(label: version.locateStatus), + ], + ), + const SizedBox(height: 8), + Text( + version.changeSummary.isEmpty ? '暂无变更说明' : version.changeSummary, + ), + if (version.createdAt.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + version.createdAt, + style: Theme.of(context).textTheme.bodySmall, ), + ], + const SizedBox(height: 10), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + Semantics( + label: 'diff-contract-version-${version.id}', + button: true, + child: TextButton.icon( + onPressed: canDiff ? onShowDiff : null, + icon: const Icon(Icons.compare_arrows_outlined), + label: const Text('查看差异'), + ), + ), + Semantics( + label: 'download-contract-version-${version.id}', + button: true, + child: TextButton.icon( + onPressed: onDownload, + icon: const Icon(Icons.file_download_outlined), + label: const Text('下载版本'), + ), + ), + ], ), ], ), @@ -318,6 +685,289 @@ class _GeneratedContractResultScreenState } } +class _VersionDiffPreview extends StatelessWidget { + const _VersionDiffPreview({required this.controller}); + + final GeneratedContractVersionController controller; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller.diff, + builder: (context, diff, _) { + if (diff == null) { + return Semantics( + label: 'contract-version-diff-summary', + child: const Text('保存新版本后,可在这里查看段落级差异。'), + ); + } + + final summary = diff.summary; + final visibleItems = diff.items.take(3).toList(growable: false); + return Semantics( + container: true, + label: 'contract-version-diff-summary', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${diff.fromVersion.versionLabel} -> ${diff.toVersion.versionLabel}', + style: Theme.of(context).textTheme.titleSmall, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + _VersionMetricChip(label: '新增', value: summary.added), + _VersionMetricChip(label: '删除', value: summary.removed), + _VersionMetricChip(label: '修改', value: summary.modified), + _VersionMetricChip(label: '未变', value: summary.unchanged), + ], + ), + const SizedBox(height: 10), + if (visibleItems.isEmpty) + const Text('本次对比没有段落级变化。') + else + for (final item in visibleItems) ...[ + _VersionDiffLine(item: item), + if (item != visibleItems.last) const SizedBox(height: 8), + ], + ], + ), + ); + }, + ); + } +} + +class _VersionDiffLine extends StatelessWidget { + const _VersionDiffLine({required this.item}); + + final GeneratedContractVersionDiffItem item; + + @override + Widget build(BuildContext context) { + final text = item.newText.isNotEmpty ? item.newText : item.oldText; + return _BulletLine( + title: _changeTypeLabel(item.changeType), + subtitle: text, + ); + } +} + +class _VersionBadge extends StatelessWidget { + const _VersionBadge({required this.label}); + + final String label; + + @override + Widget build(BuildContext context) { + return Chip( + label: Text(label), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } +} + +class _VersionMetricChip extends StatelessWidget { + const _VersionMetricChip({ + required this.label, + required this.value, + }); + + final String label; + final int value; + + @override + Widget build(BuildContext context) { + return Chip( + label: Text('$label $value'), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } +} + +class _GeneratedContractVersionEditorScreen extends StatefulWidget { + const _GeneratedContractVersionEditorScreen({ + required this.contract, + required this.controller, + required this.initialContent, + required this.sourceVersionId, + }); + + final GeneratedContractItem contract; + final GeneratedContractVersionController controller; + final String initialContent; + final String? sourceVersionId; + + @override + State<_GeneratedContractVersionEditorScreen> createState() => + _GeneratedContractVersionEditorScreenState(); +} + +class _GeneratedContractVersionEditorScreenState + extends State<_GeneratedContractVersionEditorScreen> { + late final TextEditingController _contentController; + late final TextEditingController _summaryController; + + @override + void initState() { + super.initState(); + _contentController = TextEditingController(text: widget.initialContent); + _summaryController = TextEditingController(text: '移动端编辑保存合同正文'); + } + + @override + void dispose() { + _contentController.dispose(); + _summaryController.dispose(); + super.dispose(); + } + + Future _save() async { + final content = _contentController.text.trim(); + if (content.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('合同正文不能为空')), + ); + return; + } + + try { + await widget.controller.saveVersion( + contractId: widget.contract.id, + content: content, + changeSummary: _summaryController.text.trim(), + sourceVersionId: widget.sourceVersionId, + ); + if (!mounted) { + return; + } + Navigator.of(context).pop(true); + } on GeneratedContractVersionException catch (error) { + if (!mounted) { + return; + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error.message)), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('编辑合同版本')), + bottomNavigationBar: SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(16, 8, 16, 16), + child: ValueListenableBuilder( + valueListenable: widget.controller.isSaving, + builder: (context, isSaving, _) { + return SizedBox( + width: double.infinity, + child: Semantics( + label: 'save-contract-version', + button: true, + child: FilledButton.icon( + onPressed: isSaving ? null : _save, + icon: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? '保存中...' : '保存新版本'), + ), + ), + ); + }, + ), + ), + body: Semantics( + container: true, + label: 'contract-version-editor', + child: AppScaffoldPage( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + AppSectionCard( + title: '版本说明', + subtitle: '保存后会生成新的合同版本,可回到结果页查看 diff 并下载交付文件。', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _InfoLine(label: '合同', value: widget.contract.templateName), + const SizedBox(height: 12), + Semantics( + label: 'contract-version-change-summary-input', + textField: true, + child: TextField( + controller: _summaryController, + textInputAction: TextInputAction.done, + onSubmitted: (_) { + _save(); + }, + decoration: const InputDecoration( + labelText: '变更说明', + hintText: '例如:补充验收条款、调整付款节点', + ), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + AppSectionCard( + title: '合同正文', + subtitle: '这里是轻量编辑器,适合小范围条款修订;大篇幅编辑仍建议下载 DOCX 后处理。', + child: Semantics( + label: 'contract-version-content-input', + textField: true, + child: TextField( + controller: _contentController, + minLines: 14, + maxLines: 22, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: '请输入合同正文', + ), + ), + ), + ), + const SizedBox(height: 16), + ValueListenableBuilder( + valueListenable: widget.controller.isSaving, + builder: (context, isSaving, _) { + return Semantics( + label: 'save-contract-version-inline', + button: true, + child: FilledButton.icon( + onPressed: isSaving ? null : _save, + icon: isSaving + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.save_outlined), + label: Text(isSaving ? '保存中...' : '保存新版本'), + ), + ); + }, + ), + ], + ), + ), + ), + ); + } +} + class _InfoLine extends StatelessWidget { const _InfoLine({ required this.label, @@ -388,3 +1038,24 @@ String _formatFileSize(int bytes) { } return '$bytes B'; } + +String _changeTypeLabel(String value) { + switch (value.trim().toLowerCase()) { + case 'generated': + return '原始生成'; + case 'editor_save': + return '编辑保存'; + case 'clause_recommendation': + return '采纳建议'; + case 'manual_edit': + return '人工修订'; + case 'added': + return '新增'; + case 'removed': + return '删除'; + case 'modified': + return '修改'; + default: + return value.isEmpty ? '版本变更' : value; + } +} diff --git a/mobile/lib/shared/network/api_client.dart b/mobile/lib/shared/network/api_client.dart index ac66df15..612da1d8 100644 --- a/mobile/lib/shared/network/api_client.dart +++ b/mobile/lib/shared/network/api_client.dart @@ -8,6 +8,7 @@ import 'package:http_parser/http_parser.dart'; import '../../app/config/app_config.dart'; import '../../features/chat/domain/chat_models.dart'; import '../../features/contracts/domain/contract_models.dart'; +import '../../features/contracts/domain/contract_version_models.dart'; import '../../features/auth/domain/auth_session.dart'; import '../../features/documents/domain/legal_document_models.dart'; import '../../features/profile/domain/admin_snapshot.dart'; @@ -745,6 +746,120 @@ class ApiClient { return response.bodyBytes; } + Future listGeneratedContractVersions( + String accessToken, + String contractId, + ) async { + final response = await http.get( + Uri.parse('$contractsUrl/$contractId/versions'), + headers: {'Authorization': 'Bearer $accessToken'}, + ); + final body = _decodeBody(response.body); + if (response.statusCode != 200) { + throw ApiException(_extractMessage(body, fallback: '获取合同版本失败'), + statusCode: response.statusCode); + } + + final data = _extractMap(body['data'], fallback: '合同版本响应缺少 data'); + return GeneratedContractVersionListResponse.fromJson(data); + } + + Future createGeneratedContractVersion( + String accessToken, + String contractId, { + required String content, + String changeType = 'editor_save', + String? changeSummary, + String? sourceVersionId, + String? originalText, + String? replacementText, + String locateStatus = 'applied', + }) async { + final payload = { + 'content': content, + 'change_type': changeType, + 'locate_status': locateStatus, + if (changeSummary != null && changeSummary.trim().isNotEmpty) + 'change_summary': changeSummary.trim(), + if (sourceVersionId != null && sourceVersionId.trim().isNotEmpty) + 'source_version_id': sourceVersionId.trim(), + if (originalText != null && originalText.trim().isNotEmpty) + 'original_text': originalText.trim(), + if (replacementText != null && replacementText.trim().isNotEmpty) + 'replacement_text': replacementText.trim(), + }; + + final response = await http.post( + Uri.parse('$contractsUrl/$contractId/versions'), + headers: { + 'Authorization': 'Bearer $accessToken', + 'Content-Type': 'application/json', + }, + body: jsonEncode(payload), + ); + final body = _decodeBody(response.body); + if (response.statusCode != 200) { + throw ApiException(_extractMessage(body, fallback: '保存合同版本失败'), + statusCode: response.statusCode); + } + + final data = _extractMap(body['data'], fallback: '保存合同版本响应缺少 data'); + return GeneratedContractVersionItem.fromJson(data, contractId: contractId); + } + + Future diffGeneratedContractVersions( + String accessToken, + String contractId, { + String? fromVersionId, + String? toVersionId, + }) async { + final queryParameters = {}; + if (fromVersionId != null && fromVersionId.trim().isNotEmpty) { + queryParameters['from_version_id'] = fromVersionId.trim(); + } + if (toVersionId != null && toVersionId.trim().isNotEmpty) { + queryParameters['to_version_id'] = toVersionId.trim(); + } + + final uri = Uri.parse('$contractsUrl/$contractId/versions/diff').replace( + queryParameters: queryParameters.isEmpty ? null : queryParameters); + final response = await http.get( + uri, + headers: {'Authorization': 'Bearer $accessToken'}, + ); + final body = _decodeBody(response.body); + if (response.statusCode != 200) { + throw ApiException(_extractMessage(body, fallback: '获取合同版本差异失败'), + statusCode: response.statusCode); + } + + final data = _extractMap(body['data'], fallback: '合同版本差异响应缺少 data'); + return GeneratedContractVersionDiffResult.fromJson(data); + } + + Future downloadGeneratedContractVersionFile( + String accessToken, + String contractId, + String versionId, { + String exportFormat = 'docx', + }) async { + final response = await http.get( + Uri.parse('$contractsUrl/$contractId/versions/$versionId/download') + .replace(queryParameters: {'export_format': exportFormat}), + headers: {'Authorization': 'Bearer $accessToken'}, + ); + if (response.statusCode != 200) { + throw ApiException( + _extractDownloadErrorMessage(response), + statusCode: response.statusCode, + ); + } + if (response.bodyBytes.isEmpty) { + throw const ApiException('合同版本文件为空,请稍后重试'); + } + return response.bodyBytes; + } + Future> submitReview({ required String accessToken, required ReviewDraft draft, diff --git a/mobile/test/features/contracts/application/generated_contract_version_controller_test.dart b/mobile/test/features/contracts/application/generated_contract_version_controller_test.dart new file mode 100644 index 00000000..fc2d3b73 --- /dev/null +++ b/mobile/test/features/contracts/application/generated_contract_version_controller_test.dart @@ -0,0 +1,196 @@ +import 'dart:typed_data'; + +import 'package:ai_contract_mobile/features/auth/domain/auth_session.dart'; +import 'package:ai_contract_mobile/features/contracts/application/generated_contract_version_controller.dart'; +import 'package:ai_contract_mobile/features/contracts/domain/contract_version_models.dart'; +import 'package:ai_contract_mobile/shared/network/api_client.dart'; +import 'package:ai_contract_mobile/shared/storage/session_store.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + setUp(() async { + SessionStore.useStorageDriverForTesting(InMemorySessionStorageDriver()); + await SessionStore.save(_session); + }); + + tearDown(() async { + await SessionStore.clear(); + SessionStore.resetStorageDriverForTesting(); + }); + + test('loads generated contract versions and diff', () async { + final apiClient = _VersionApiClient(); + final controller = GeneratedContractVersionController(apiClient: apiClient); + addTearDown(controller.dispose); + + await controller.loadVersions('contract-1'); + await controller.loadDiff('contract-1', toVersionId: 'gcv-2'); + + expect(controller.errorMessage.value, isNull); + expect(controller.versions.value?.latestVersion.versionLabel, 'v2'); + expect(controller.diff.value?.summary.added, 1); + expect(apiClient.loadedContractId, 'contract-1'); + expect(apiClient.diffToVersionId, 'gcv-2'); + }); + + test('saves edited contract content as a new version', () async { + final apiClient = _VersionApiClient(); + final controller = GeneratedContractVersionController(apiClient: apiClient); + addTearDown(controller.dispose); + + final result = await controller.saveVersion( + contractId: 'contract-1', + content: 'Generated NDA content.\nAcceptance clause added.', + changeSummary: '补充验收条款', + sourceVersionId: 'base', + ); + + expect(result.versionLabel, 'v2'); + expect(apiClient.savedContent, contains('Acceptance clause added')); + expect(apiClient.savedSummary, '补充验收条款'); + expect(controller.errorMessage.value, isNull); + }); +} + +class _VersionApiClient extends ApiClient { + String? loadedContractId; + String? diffToVersionId; + String? savedContent; + String? savedSummary; + + @override + Future listGeneratedContractVersions( + String accessToken, + String contractId, + ) async { + loadedContractId = contractId; + return _versionList; + } + + @override + Future diffGeneratedContractVersions( + String accessToken, + String contractId, { + String? fromVersionId, + String? toVersionId, + }) async { + diffToVersionId = toVersionId; + return _versionDiff; + } + + @override + Future createGeneratedContractVersion( + String accessToken, + String contractId, { + required String content, + String changeType = 'editor_save', + String? changeSummary, + String? sourceVersionId, + String? originalText, + String? replacementText, + String locateStatus = 'applied', + }) async { + savedContent = content; + savedSummary = changeSummary; + return _versionList.items.first; + } + + @override + Future downloadGeneratedContractVersionFile( + String accessToken, + String contractId, + String versionId, { + String exportFormat = 'docx', + }) async { + return Uint8List.fromList([1, 2, 3]); + } +} + +const _session = AuthSession( + accessToken: 'token', + userId: 'user-1', + tenantId: 'tenant-1', + username: 'member@example.com', + userDisplayName: 'Member', + role: 'member', + status: 'active', +); + +final _versionList = GeneratedContractVersionListResponse( + contractId: 'contract-1', + baseVersion: GeneratedContractVersionItem( + id: 'base', + contractId: 'contract-1', + versionNumber: 1, + versionLabel: 'v1', + sourceVersionId: null, + changeType: 'generated', + changeSummary: '原始生成版本', + originalText: '', + replacementText: '', + content: 'Generated NDA content.', + locateStatus: 'applied', + createdBy: 'user-1', + createdAt: '2026-05-26T01:00:00Z', + detailUrl: '', + ), + items: [ + GeneratedContractVersionItem( + id: 'gcv-2', + contractId: 'contract-1', + versionNumber: 2, + versionLabel: 'v2', + sourceVersionId: 'base', + changeType: 'editor_save', + changeSummary: '补充验收条款', + originalText: '', + replacementText: 'Acceptance clause added.', + content: 'Generated NDA content.\nAcceptance clause added.', + locateStatus: 'applied', + createdBy: 'user-1', + createdAt: '2026-05-26T02:00:00Z', + detailUrl: '/contracts/contract-1?version=gcv-2', + ), + ], + total: 1, +); + +final _versionDiff = GeneratedContractVersionDiffResult( + contractId: 'contract-1', + fromVersion: GeneratedContractVersionSnapshot( + id: 'base', + versionNumber: 1, + versionLabel: 'v1', + sourceVersionId: null, + changeType: 'generated', + changeSummary: '原始生成版本', + createdAt: '2026-05-26T01:00:00Z', + ), + toVersion: GeneratedContractVersionSnapshot( + id: 'gcv-2', + versionNumber: 2, + versionLabel: 'v2', + sourceVersionId: 'base', + changeType: 'editor_save', + changeSummary: '补充验收条款', + createdAt: '2026-05-26T02:00:00Z', + ), + summary: GeneratedContractVersionDiffSummary( + added: 1, + removed: 0, + modified: 0, + unchanged: 1, + changeCount: 1, + fromParagraphCount: 1, + toParagraphCount: 2, + ), + items: [ + GeneratedContractVersionDiffItem( + changeType: 'added', + oldParagraphIndex: null, + newParagraphIndex: 2, + oldText: '', + newText: 'Acceptance clause added.', + ), + ], +); diff --git a/mobile/test/features/contracts/domain/contract_version_models_test.dart b/mobile/test/features/contracts/domain/contract_version_models_test.dart new file mode 100644 index 00000000..36a9b2e7 --- /dev/null +++ b/mobile/test/features/contracts/domain/contract_version_models_test.dart @@ -0,0 +1,86 @@ +import 'package:ai_contract_mobile/features/contracts/domain/contract_version_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('parses generated contract version list and diff payloads', () { + final list = + GeneratedContractVersionListResponse.fromJson({ + 'contract_id': 'contract-1', + 'base_version': { + 'version_number': 1, + 'version_label': 'v1', + 'content': 'Base content', + 'created_at': '2026-05-26T01:00:00Z', + }, + 'items': >[ + { + 'id': 'gcv-2', + 'contract_id': 'contract-1', + 'version_number': 2, + 'version_label': 'v2', + 'source_version_id': 'base', + 'change_type': 'editor_save', + 'change_summary': '补充验收条款', + 'content': 'Edited content', + 'locate_status': 'applied', + 'created_by': 'user-1', + 'created_at': '2026-05-26T02:00:00Z', + 'detail_url': '/contracts/contract-1?version=gcv-2', + }, + ], + 'total': 1, + }); + + expect(list.contractId, 'contract-1'); + expect(list.baseVersion.versionLabel, 'v1'); + expect(list.items, hasLength(1)); + expect(list.items.first.id, 'gcv-2'); + expect(list.items.first.changeType, 'editor_save'); + + final diff = GeneratedContractVersionDiffResult.fromJson({ + 'contract_id': 'contract-1', + 'from_version': { + 'id': 'base', + 'version_number': 1, + 'version_label': 'v1', + 'change_type': 'generated', + 'change_summary': '原始生成版本', + 'created_at': '2026-05-26T01:00:00Z', + }, + 'to_version': { + 'id': 'gcv-2', + 'version_number': 2, + 'version_label': 'v2', + 'source_version_id': 'base', + 'change_type': 'editor_save', + 'change_summary': '补充验收条款', + 'created_at': '2026-05-26T02:00:00Z', + }, + 'summary': { + 'added': 1, + 'removed': 0, + 'modified': 1, + 'unchanged': 2, + 'change_count': 2, + 'from_paragraph_count': 3, + 'to_paragraph_count': 4, + }, + 'items': >[ + { + 'change_type': 'added', + 'old_paragraph_index': null, + 'new_paragraph_index': 4, + 'old_text': '', + 'new_text': '新补充条款', + }, + ], + }); + + expect(diff.contractId, 'contract-1'); + expect(diff.fromVersion.versionLabel, 'v1'); + expect(diff.toVersion.versionLabel, 'v2'); + expect(diff.summary.changeCount, 2); + expect(diff.items, hasLength(1)); + expect(diff.items.first.changeType, 'added'); + }); +} diff --git a/mobile/test/features/contracts/presentation/contract_screens_test.dart b/mobile/test/features/contracts/presentation/contract_screens_test.dart new file mode 100644 index 00000000..21cce782 --- /dev/null +++ b/mobile/test/features/contracts/presentation/contract_screens_test.dart @@ -0,0 +1,480 @@ +import 'dart:typed_data'; + +import 'package:ai_contract_mobile/features/auth/domain/auth_session.dart'; +import 'package:ai_contract_mobile/features/contracts/application/contract_template_controller.dart'; +import 'package:ai_contract_mobile/features/contracts/application/generated_contract_history_controller.dart'; +import 'package:ai_contract_mobile/features/contracts/application/generated_contract_version_controller.dart'; +import 'package:ai_contract_mobile/features/contracts/domain/contract_models.dart'; +import 'package:ai_contract_mobile/features/contracts/domain/contract_version_models.dart'; +import 'package:ai_contract_mobile/features/contracts/presentation/contract_builder_screen.dart'; +import 'package:ai_contract_mobile/features/contracts/presentation/contract_template_list_screen.dart'; +import 'package:ai_contract_mobile/features/contracts/presentation/generated_contract_result_screen.dart'; +import 'package:ai_contract_mobile/shared/network/api_client.dart'; +import 'package:ai_contract_mobile/shared/storage/session_store.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + SessionStore.useStorageDriverForTesting(InMemorySessionStorageDriver()); + await SessionStore.save(_session); + }); + + tearDown(() async { + await SessionStore.clear(); + SessionStore.resetStorageDriverForTesting(); + }); + + testWidgets('contract workspace exposes stable automation labels', + (tester) async { + final semantics = tester.ensureSemantics(); + final apiClient = _ContractsPresentationApiClient(); + final templateController = ContractTemplateController(apiClient: apiClient); + final historyController = + GeneratedContractHistoryController(apiClient: apiClient); + addTearDown(templateController.dispose); + addTearDown(historyController.dispose); + + try { + await tester.pumpWidget( + MaterialApp( + home: ContractTemplateListScreen( + controller: templateController, + historyController: historyController, + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('模板'), findsOneWidget); + expect(find.text('历史结果'), findsOneWidget); + expect(find.bySemanticsLabel('open-contract-template-tpl-nda'), + findsOneWidget); + expect(find.bySemanticsLabel('use-contract-template-tpl-nda'), + findsOneWidget); + + await tester.tap(find.text('历史结果')); + await tester.pumpAndSettle(); + + expect(find.bySemanticsLabel('open-generated-contract-contract-1'), + findsOneWidget); + expect(find.bySemanticsLabel('continue-generated-contract-contract-1'), + findsOneWidget); + } finally { + semantics.dispose(); + } + }); + + testWidgets('contract builder exposes stable automation labels', + (tester) async { + final semantics = tester.ensureSemantics(); + try { + await tester.pumpWidget( + const MaterialApp( + home: ContractBuilderScreen(template: _template), + ), + ); + await tester.pumpAndSettle(); + + expect(find.bySemanticsLabel('contract-builder-tpl-nda'), findsOneWidget); + expect(find.bySemanticsLabel('preview-contract'), findsOneWidget); + expect(find.bySemanticsLabel('generate-contract'), findsOneWidget); + } finally { + semantics.dispose(); + } + }); + + testWidgets('generated contract result exposes stable file action labels', + (tester) async { + final semantics = tester.ensureSemantics(); + final apiClient = _ContractsPresentationApiClient(); + try { + await tester.pumpWidget( + MaterialApp( + home: GeneratedContractResultScreen( + contract: _generatedContract, + versionController: GeneratedContractVersionController( + apiClient: apiClient, + ), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.bySemanticsLabel('generated-contract-result-contract-1'), + findsOneWidget); + expect(find.bySemanticsLabel('continue-generated-contract-editing'), + findsOneWidget); + expect( + find.bySemanticsLabel('download-generated-contract'), findsOneWidget); + expect(find.bySemanticsLabel('open-generated-contract-file'), + findsOneWidget); + expect(find.bySemanticsLabel('share-generated-contract'), findsOneWidget); + + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -720), + ); + await tester.pumpAndSettle(); + + expect(find.text('版本链'), findsOneWidget); + expect(find.bySemanticsLabel('edit-generated-contract-version'), + findsOneWidget); + + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -360), + ); + await tester.pumpAndSettle(); + + expect( + find.bySemanticsLabel('diff-contract-version-gcv-2'), findsOneWidget); + expect(find.bySemanticsLabel('download-contract-version-gcv-2'), + findsOneWidget); + + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -320), + ); + await tester.pumpAndSettle(); + + expect(find.text('v1 -> v2'), findsOneWidget); + } finally { + semantics.dispose(); + } + }); + + testWidgets('generated contract version editor saves through stable labels', + (tester) async { + final semantics = tester.ensureSemantics(); + final apiClient = _ContractsPresentationApiClient(); + final versionController = GeneratedContractVersionController( + apiClient: apiClient, + ); + addTearDown(versionController.dispose); + + try { + await tester.pumpWidget( + MaterialApp( + home: GeneratedContractResultScreen( + contract: _generatedContract, + versionController: versionController, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.drag( + find.byType(SingleChildScrollView), + const Offset(0, -720), + ); + await tester.pumpAndSettle(); + await tester + .tap(find.bySemanticsLabel('edit-generated-contract-version')); + await tester.pumpAndSettle(); + + expect(find.bySemanticsLabel('contract-version-editor'), findsOneWidget); + expect(find.bySemanticsLabel('contract-version-change-summary-input'), + findsOneWidget); + expect(find.bySemanticsLabel('contract-version-content-input'), + findsOneWidget); + + final summaryField = find.descendant( + of: find.bySemanticsLabel('contract-version-change-summary-input'), + matching: find.byType(TextField), + ); + await tester.enterText(summaryField, 'Mobile version smoke summary'); + + final contentField = find.descendant( + of: find.bySemanticsLabel('contract-version-content-input'), + matching: find.byType(TextField), + ); + await tester.ensureVisible(contentField); + await tester.enterText( + contentField, + 'Generated NDA content.\nMobile version smoke clause.', + ); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + final saveButton = find.descendant( + of: find.bySemanticsLabel('save-contract-version'), + matching: find.byType(FilledButton), + ); + await tester.scrollUntilVisible( + saveButton, + 200, + scrollable: find.byType(Scrollable).last, + ); + await tester.tapAt(tester.getCenter(saveButton)); + await tester.pumpAndSettle(); + + expect(apiClient.savedContent, contains('Mobile version smoke clause')); + expect(apiClient.savedSummary, 'Mobile version smoke summary'); + expect(find.bySemanticsLabel('generated-contract-result-contract-1'), + findsOneWidget); + } finally { + semantics.dispose(); + } + }); +} + +class _ContractsPresentationApiClient extends ApiClient { + String? savedContent; + String? savedSummary; + + @override + Future> listContractTemplates( + String accessToken, { + String? contractType, + String? documentTypeCode, + String? templateCategory, + String? industryCategory, + }) async { + return const [_template]; + } + + @override + Future listGeneratedContracts( + String accessToken, { + int page = 1, + int pageSize = 10, + String? contractType, + String? keyword, + }) async { + return const GeneratedContractListResponse( + items: [_generatedListItem], + total: 1, + page: 1, + pageSize: 10, + ); + } + + @override + Future getGeneratedContract( + String accessToken, + String contractId, + ) async { + return _generatedContract; + } + + @override + Future listGeneratedContractVersions( + String accessToken, + String contractId, + ) async { + return savedContent == null + ? _generatedVersionList + : _generatedVersionListAfterSave; + } + + @override + Future diffGeneratedContractVersions( + String accessToken, + String contractId, { + String? fromVersionId, + String? toVersionId, + }) async { + return _generatedVersionDiff; + } + + @override + Future createGeneratedContractVersion( + String accessToken, + String contractId, { + required String content, + String changeType = 'editor_save', + String? changeSummary, + String? sourceVersionId, + String? originalText, + String? replacementText, + String locateStatus = 'applied', + }) async { + savedContent = content; + savedSummary = changeSummary; + return _generatedSavedVersion; + } + + @override + Future downloadGeneratedContractVersionFile( + String accessToken, + String contractId, + String versionId, { + String exportFormat = 'docx', + }) async { + return Uint8List.fromList([1, 2, 3]); + } +} + +const _session = AuthSession( + accessToken: 'token', + userId: 'user-1', + tenantId: 'tenant-1', + username: 'member@example.com', + userDisplayName: 'Member', + role: 'member', + status: 'active', +); + +const _template = ContractTemplateItem( + id: 'tpl-nda', + name: 'NDA Template', + contractType: 'nda', + description: 'Confidentiality template.', + variables: [ + ContractTemplateVariable( + name: 'party_a', + label: '甲方', + required: true, + defaultValue: '甲方公司', + description: '请输入甲方名称', + ), + ], + previewExcerpt: 'NDA preview', + templateCategory: 'commercial-compliance', + industryCategory: 'general-enterprise', +); + +const _generatedListItem = GeneratedContractListItem( + id: 'contract-1', + templateName: 'NDA Template', + contractType: 'nda', + fileId: 'file-1', + generatedAt: '2026-05-26T01:00:00Z', + exportFormat: 'docx', + fileName: 'nda.docx', + fileSize: 2048, + generatedByName: 'Member', + templateCategory: 'commercial-compliance', + industryCategory: 'general-enterprise', +); + +const _generatedContract = GeneratedContractItem( + id: 'contract-1', + templateId: 'tpl-nda', + templateName: 'NDA Template', + contractType: 'nda', + documentTypeCode: '', + generatedContent: 'Generated NDA content.', + variableValues: {'party_a': '甲方公司'}, + missingFields: [], + aiHints: [], + relatedLegalArticles: [], + generatedAt: '2026-05-26T01:00:00Z', + exportFormat: 'docx', + fileName: 'nda.docx', + fileSize: 2048, + fileContentBase64: 'ZHVtbXk=', + previewUrl: '', + downloadUrl: '', + templateCategory: 'commercial-compliance', + industryCategory: 'general-enterprise', +); + +final _generatedVersionList = GeneratedContractVersionListResponse( + contractId: 'contract-1', + baseVersion: GeneratedContractVersionItem( + id: 'base', + contractId: 'contract-1', + versionNumber: 1, + versionLabel: 'v1', + sourceVersionId: null, + changeType: 'generated', + changeSummary: '原始生成版本', + originalText: '', + replacementText: '', + content: 'Generated NDA content.', + locateStatus: 'applied', + createdBy: 'user-1', + createdAt: '2026-05-26T01:00:00Z', + detailUrl: '', + ), + items: [ + GeneratedContractVersionItem( + id: 'gcv-2', + contractId: 'contract-1', + versionNumber: 2, + versionLabel: 'v2', + sourceVersionId: 'base', + changeType: 'editor_save', + changeSummary: '补充验收条款', + originalText: 'Acceptance clause', + replacementText: 'Acceptance clause added.', + content: 'Generated NDA content.\nAcceptance clause added.', + locateStatus: 'applied', + createdBy: 'user-1', + createdAt: '2026-05-26T02:00:00Z', + detailUrl: '/contracts/contract-1?version=gcv-2', + ), + ], + total: 1, +); + +final _generatedSavedVersion = GeneratedContractVersionItem( + id: 'gcv-3', + contractId: 'contract-1', + versionNumber: 3, + versionLabel: 'v3', + sourceVersionId: 'gcv-2', + changeType: 'editor_save', + changeSummary: 'Mobile version smoke summary', + originalText: '', + replacementText: 'Mobile version smoke clause.', + content: 'Generated NDA content.\nMobile version smoke clause.', + locateStatus: 'applied', + createdBy: 'user-1', + createdAt: '2026-05-26T03:00:00Z', + detailUrl: '/contracts/contract-1?version=gcv-3', +); + +final _generatedVersionListAfterSave = GeneratedContractVersionListResponse( + contractId: 'contract-1', + baseVersion: _generatedVersionList.baseVersion, + items: [ + _generatedSavedVersion, + ..._generatedVersionList.items, + ], + total: 2, +); + +final _generatedVersionDiff = GeneratedContractVersionDiffResult( + contractId: 'contract-1', + fromVersion: GeneratedContractVersionSnapshot( + id: 'base', + versionNumber: 1, + versionLabel: 'v1', + sourceVersionId: null, + changeType: 'generated', + changeSummary: '原始生成版本', + createdAt: '2026-05-26T01:00:00Z', + ), + toVersion: GeneratedContractVersionSnapshot( + id: 'gcv-2', + versionNumber: 2, + versionLabel: 'v2', + sourceVersionId: 'base', + changeType: 'editor_save', + changeSummary: '补充验收条款', + createdAt: '2026-05-26T02:00:00Z', + ), + summary: GeneratedContractVersionDiffSummary( + added: 1, + removed: 0, + modified: 0, + unchanged: 1, + changeCount: 1, + fromParagraphCount: 1, + toParagraphCount: 2, + ), + items: [ + GeneratedContractVersionDiffItem( + changeType: 'added', + oldParagraphIndex: null, + newParagraphIndex: 2, + oldText: '', + newText: 'Acceptance clause added.', + ), + ], +); diff --git a/mobile/tools/real-device-smoke.ps1 b/mobile/tools/real-device-smoke.ps1 index cdec179f..4ce066af 100644 --- a/mobile/tools/real-device-smoke.ps1 +++ b/mobile/tools/real-device-smoke.ps1 @@ -6,7 +6,7 @@ [string]$DeviceSerial = "", [string]$LoginAccount = "", [string]$LoginPassword = "", - [ValidateSet("login-only", "shell-only", "dashboard-review-preview", "share-proof", "contract-taxonomy", "documents-workspace", "review-legal-document", "business-auxiliary")] + [ValidateSet("login-only", "shell-only", "dashboard-review-preview", "share-proof", "contract-taxonomy", "contract-result-actions", "contract-version-chain", "documents-workspace", "review-legal-document", "business-auxiliary")] [string]$SmokeDepth = "shell-only", [string]$ApiHealthUrl = "http://192.168.3.90:8001/health", [string]$WebUrl = "http://192.168.3.90:3003/", @@ -599,6 +599,439 @@ function Invoke-ContractTaxonomyProof { } } +function Invoke-ContractResultActionsProof { + param( + [string]$Serial, + [string]$ArtifactDir, + [string]$PackageName + ) + + Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 3 -Label "AI工具" -ArtifactLabel "contract-result-ai-tools-tab" -FallbackX 540 -FallbackY 2057 + Start-Sleep -Seconds 2 + $toolsState = Get-SmokeState -Serial $Serial -Label "contract-result-tools" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $contractButton = Get-ContentDescBounds -UiDump $toolsState.UiDump | + Where-Object { $_.ContentDesc -eq "open-contracts-workspace" } | + Select-Object -First 1 + if (-not $contractButton) { + $contractButton = Get-ButtonBounds -UiDump $toolsState.UiDump | + Where-Object { $_.ContentDesc -eq "打开合同工作台" } | + Select-Object -First 1 + } + if ($contractButton) { + $contractCenter = Get-SafeTapPoint -Bounds $contractButton + Invoke-AdbTap -Serial $Serial -X $contractCenter.X -Y $contractCenter.Y + } else { + Invoke-AdbTap -Serial $Serial -X 360 -Y 1865 + } + Start-Sleep -Seconds 3 + $workspaceState = Get-SmokeState -Serial $Serial -Label "contract-result-workspace" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $historyTab = Get-ContentDescBounds -UiDump $workspaceState.UiDump | + Where-Object { $_.ContentDesc -match '^contract-history-tab' } | + Select-Object -First 1 + if (-not $historyTab) { + $historyTab = Get-TextBounds -UiDump $workspaceState.UiDump | + Where-Object { $_.Text -eq "历史结果" } | + Select-Object -First 1 + } + if ($historyTab) { + $historyCenter = Get-SafeTapPoint -Bounds $historyTab + Invoke-AdbTap -Serial $Serial -X $historyCenter.X -Y $historyCenter.Y + } else { + Invoke-AdbTap -Serial $Serial -X 688 -Y 520 + } + Start-Sleep -Seconds 2 + $historyState = Get-SmokeState -Serial $Serial -Label "contract-result-history" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $generatedCard = $null + $generatedResultButton = $null + for ($attempt = 1; $attempt -le 6; $attempt++) { + $generatedResultButton = Get-ContentDescBounds -UiDump $historyState.UiDump | + Where-Object { $_.ContentDesc -match '^open-generated-contract-' } | + Select-Object -First 1 + if ($generatedResultButton) { + break + } + + $generatedTextButton = Get-TextBounds -UiDump $historyState.UiDump | + Where-Object { $_.Text -eq '结果' -and $_.Top -gt 500 } | + Select-Object -First 1 + if ($generatedTextButton) { + $generatedCard = $generatedTextButton + break + } + + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1760 -EndX 540 -EndY 760 -DurationMs 320 + Start-Sleep -Seconds 2 + $historyState = Get-SmokeState -Serial $Serial -Label "contract-result-history-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + } + + if ($generatedResultButton) { + $generatedCard = $generatedResultButton + } + + if ($generatedCard) { + $generatedCenter = Get-SafeTapPoint -Bounds $generatedCard + Invoke-AdbTap -Serial $Serial -X $generatedCenter.X -Y $generatedCenter.Y + } else { + Invoke-AdbTap -Serial $Serial -X 978 -Y 1598 + } + Start-Sleep -Seconds 3 + $resultState = Get-SmokeState -Serial $Serial -Label "contract-result-detail" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $buttonState = $resultState + $downloadButton = $null + $shareButton = $null + $continueButton = $null + for ($attempt = 1; $attempt -le 8; $attempt++) { + $downloadButton = Get-ContentDescBounds -UiDump $buttonState.UiDump | + Where-Object { $_.ContentDesc -match '^download-generated-contract' } | + Select-Object -First 1 + if (-not $downloadButton) { + $downloadButton = Get-TextBounds -UiDump $buttonState.UiDump | + Where-Object { $_.Text -eq "下载合同" } | + Select-Object -First 1 + } + $shareButton = Get-ContentDescBounds -UiDump $buttonState.UiDump | + Where-Object { $_.ContentDesc -match '^share-generated-contract' } | + Select-Object -First 1 + if (-not $shareButton) { + $shareButton = Get-TextBounds -UiDump $buttonState.UiDump | + Where-Object { $_.Text -eq "分享合同" } | + Select-Object -First 1 + } + $continueButton = Get-ContentDescBounds -UiDump $buttonState.UiDump | + Where-Object { $_.ContentDesc -match '^continue-generated-contract-editing' } | + Select-Object -First 1 + if (-not $continueButton) { + $continueButton = Get-TextBounds -UiDump $buttonState.UiDump | + Where-Object { $_.Text -eq "继续编辑" } | + Select-Object -First 1 + } + + if ($downloadButton -or $shareButton -or $continueButton) { + if ($downloadButton -and $shareButton -and $continueButton) { + break + } + } + + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1830 -EndX 540 -EndY 520 -DurationMs 420 + Start-Sleep -Seconds 1 + $buttonState = Get-SmokeState -Serial $Serial -Label "contract-result-detail-scroll-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + } + + $resultState = $buttonState + + $downloadOk = $false + $shareOk = $false + $continueOk = $null -ne $continueButton + + if ($downloadButton) { + $downloadCenter = Get-SafeTapPoint -Bounds $downloadButton + Invoke-AdbTap -Serial $Serial -X $downloadCenter.X -Y $downloadCenter.Y + Start-Sleep -Seconds 2 + $downloadState = Get-SmokeState -Serial $Serial -Label "contract-result-download" -ArtifactDir $ArtifactDir -PackageName $PackageName + $downloadOk = $downloadState.UiDump -match 'generated-contract-local-file-ready' -or $downloadState.UiDump -match '本地文件' + } + + if ($shareButton) { + $shareCenter = Get-SafeTapPoint -Bounds $shareButton + Invoke-AdbTap -Serial $Serial -X $shareCenter.X -Y $shareCenter.Y + Start-Sleep -Seconds 2 + $chooserState = $null + for ($chooserAttempt = 1; $chooserAttempt -le 3; $chooserAttempt++) { + $chooserState = Get-SmokeState -Serial $Serial -Label "contract-result-share-chooser-attempt-$chooserAttempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + if ($chooserState.UiDump -match '电池电量少于10%' -or $chooserState.UiDump -match '请插入充电器') { + $confirmButton = Get-TextBounds -UiDump $chooserState.UiDump | + Where-Object { $_.Text -eq '确定' } | + Select-Object -First 1 + if ($confirmButton) { + $confirmCenter = Get-BoundsCenter -Bounds $confirmButton + Invoke-AdbTap -Serial $Serial -X $confirmCenter.X -Y $confirmCenter.Y + Start-Sleep -Seconds 2 + continue + } + } + break + } + + $shareOk = $chooserState.TopText -match 'ChooserActivity' -or + $chooserState.UiDump -match 'android\.intent\.action\.CHOOSER' -or + $chooserState.UiDump -match '分享' + } + + return [pscustomobject]@{ + ToolsSurface = $toolsState.Surface + WorkspaceSurface = $workspaceState.Surface + HistorySurface = $historyState.Surface + DetailSurface = $resultState.Surface + DownloadButtonFound = ($null -ne $downloadButton) + ShareButtonFound = ($null -ne $shareButton) + ContinueButtonFound = $continueOk + DownloadOk = $downloadOk + ShareOk = $shareOk + SmokeOk = ($downloadOk -and $shareOk -and $continueOk) + } +} + +function Invoke-ContractVersionChainProof { + param( + [string]$Serial, + [string]$ArtifactDir, + [string]$PackageName + ) + + Invoke-BottomTab -Serial $Serial -ArtifactDir $ArtifactDir -PackageName $PackageName -TabIndex 3 -Label "AI工具" -ArtifactLabel "contract-version-chain-ai-tools-tab" -FallbackX 540 -FallbackY 2057 + Start-Sleep -Seconds 2 + $toolsState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-tools" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $contractButton = Get-ContentDescBounds -UiDump $toolsState.UiDump | + Where-Object { $_.ContentDesc -eq "open-contracts-workspace" } | + Select-Object -First 1 + if (-not $contractButton) { + $contractButton = Get-ButtonBounds -UiDump $toolsState.UiDump | + Where-Object { $_.ContentDesc -eq "打开合同工作台" } | + Select-Object -First 1 + } + if ($contractButton) { + $contractCenter = Get-SafeTapPoint -Bounds $contractButton + Invoke-AdbTap -Serial $Serial -X $contractCenter.X -Y $contractCenter.Y + } else { + Invoke-AdbTap -Serial $Serial -X 360 -Y 1865 + } + Start-Sleep -Seconds 3 + $workspaceState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-workspace" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $historyTab = Get-ContentDescBounds -UiDump $workspaceState.UiDump | + Where-Object { $_.ContentDesc -match '^contract-history-tab' } | + Select-Object -First 1 + if (-not $historyTab) { + $historyTab = Get-TextBounds -UiDump $workspaceState.UiDump | + Where-Object { $_.Text -eq "历史结果" } | + Select-Object -First 1 + } + if ($historyTab) { + $historyCenter = Get-SafeTapPoint -Bounds $historyTab + Invoke-AdbTap -Serial $Serial -X $historyCenter.X -Y $historyCenter.Y + } else { + Invoke-AdbTap -Serial $Serial -X 688 -Y 520 + } + Start-Sleep -Seconds 2 + $historyState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-history" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $generatedCard = $null + $generatedResultButton = $null + for ($attempt = 1; $attempt -le 6; $attempt++) { + $generatedResultButton = Get-ContentDescBounds -UiDump $historyState.UiDump | + Where-Object { $_.ContentDesc -match '^open-generated-contract-' } | + Select-Object -First 1 + if ($generatedResultButton) { + break + } + + $generatedTextButton = Get-TextBounds -UiDump $historyState.UiDump | + Where-Object { $_.Text -eq '结果' -and $_.Top -gt 500 } | + Select-Object -First 1 + if ($generatedTextButton) { + $generatedCard = $generatedTextButton + break + } + + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1760 -EndX 540 -EndY 760 -DurationMs 320 + Start-Sleep -Seconds 2 + $historyState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-history-attempt-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + } + + if ($generatedResultButton) { + $generatedCard = $generatedResultButton + } + + if ($generatedCard) { + $generatedCenter = Get-SafeTapPoint -Bounds $generatedCard + Invoke-AdbTap -Serial $Serial -X $generatedCenter.X -Y $generatedCenter.Y + } else { + Invoke-AdbTap -Serial $Serial -X 978 -Y 1598 + } + Start-Sleep -Seconds 3 + $detailState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-detail" -ArtifactDir $ArtifactDir -PackageName $PackageName + + $versionState = $detailState + $versionTitleOk = $false + $versionEditorButton = $null + $versionDownloadButton = $null + $versionDiffButton = $null + $visibleLabels = @() + $editorOpened = $false + $saveOk = $false + $createdVersionVisible = $false + $saveMarker = "MobileVersionSmoke_{0}" -f (Get-Date -Format "yyyyMMddHHmmss") + + for ($attempt = 1; $attempt -le 8; $attempt++) { + $versionTitleOk = $versionState.UiDump -match 'contract-version-chain' -or $versionState.UiDump -match '版本链' + $versionEditorButton = Get-ContentDescBounds -UiDump $versionState.UiDump | + Where-Object { $_.ContentDesc -match '^edit-generated-contract-version' } | + Select-Object -First 1 + if (-not $versionEditorButton) { + $versionEditorButton = Get-TextBounds -UiDump $versionState.UiDump | + Where-Object { $_.Text -eq "编辑并保存新版本" } | + Select-Object -First 1 + } + $versionDownloadButton = Get-ContentDescBounds -UiDump $versionState.UiDump | + Where-Object { $_.ContentDesc -match '^download-contract-version-' } | + Select-Object -First 1 + if (-not $versionDownloadButton) { + $versionDownloadButton = Get-TextBounds -UiDump $versionState.UiDump | + Where-Object { $_.Text -eq "下载版本" } | + Select-Object -First 1 + } + $versionDiffButton = Get-ContentDescBounds -UiDump $versionState.UiDump | + Where-Object { $_.ContentDesc -match '^diff-contract-version-' } | + Select-Object -First 1 + if (-not $versionDiffButton) { + $versionDiffButton = Get-TextBounds -UiDump $versionState.UiDump | + Where-Object { $_.Text -eq "查看差异" } | + Select-Object -First 1 + } + + if ($versionTitleOk) { + $visibleLabels += "version_chain" + } + if ($versionEditorButton) { + $visibleLabels += "editor_entry" + } + if ($versionDownloadButton) { + $visibleLabels += "version_download" + } + if ($versionDiffButton) { + $visibleLabels += "version_diff" + } + + if ($versionTitleOk -and $versionEditorButton -and $versionDownloadButton) { + break + } + + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1830 -EndX 540 -EndY 520 -DurationMs 420 + Start-Sleep -Seconds 1 + $versionState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-scroll-$attempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + } + + $visibleLabels = $visibleLabels | Select-Object -Unique + $entryOk = $versionTitleOk -and ($null -ne $versionEditorButton) + $downloadOk = $null -ne $versionDownloadButton + $diffVisible = $null -ne $versionDiffButton + + if ($versionEditorButton) { + $editorCenter = Get-SafeTapPoint -Bounds $versionEditorButton + Invoke-AdbTap -Serial $Serial -X $editorCenter.X -Y $editorCenter.Y + Start-Sleep -Seconds 3 + + $editorState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-editor" -ArtifactDir $ArtifactDir -PackageName $PackageName + $editorOpened = $editorState.UiDump -match 'contract-version-editor' + + $summaryInput = Get-ContentDescBounds -UiDump $editorState.UiDump | + Where-Object { $_.ContentDesc -eq "contract-version-change-summary-input" } | + Select-Object -First 1 + $contentInput = Get-ContentDescBounds -UiDump $editorState.UiDump | + Where-Object { $_.ContentDesc -eq "contract-version-content-input" } | + Select-Object -First 1 + $editInputs = @(Get-EditTextBounds -UiDump $editorState.UiDump) + if (-not $summaryInput -and $editInputs.Count -ge 1) { + $summaryInput = $editInputs[0] + } + if (-not $contentInput -and $editInputs.Count -ge 2) { + $contentInput = $editInputs[$editInputs.Count - 1] + } + + if ($contentInput) { + $contentCenter = Get-SafeTapPoint -Bounds $contentInput + Invoke-AdbTap -Serial $Serial -X $contentCenter.X -Y $contentCenter.Y + Start-Sleep -Milliseconds 400 + & $script:adb -s $Serial shell input keyevent KEYCODE_MOVE_END | Out-Null + Invoke-AdbTextEntry -Serial $Serial -Value " $saveMarker clause" + & $script:adb -s $Serial shell input keyevent KEYCODE_BACK | Out-Null + Start-Sleep -Seconds 1 + } + if ($summaryInput) { + $summaryCenter = Get-SafeTapPoint -Bounds $summaryInput + Invoke-AdbTap -Serial $Serial -X $summaryCenter.X -Y $summaryCenter.Y + Start-Sleep -Milliseconds 400 + & $script:adb -s $Serial shell input keyevent KEYCODE_MOVE_END | Out-Null + Invoke-AdbTextEntry -Serial $Serial -Value " $saveMarker" + & $script:adb -s $Serial shell input keyevent KEYCODE_ENTER | Out-Null + Start-Sleep -Seconds 6 + $afterEnterSaveState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-after-enter-save" -ArtifactDir $ArtifactDir -PackageName $PackageName + $saveOk = ($afterEnterSaveState.UiDump -match 'contract-version-chain') -and + -not ($afterEnterSaveState.UiDump -match 'contract-version-editor') + $createdVersionVisible = $afterEnterSaveState.UiDump -match [regex]::Escape($saveMarker) + } + + if (-not $saveOk) { + $saveButton = $null + $saveState = $editorState + for ($saveAttempt = 1; $saveAttempt -le 6; $saveAttempt++) { + $saveState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-save-button-$saveAttempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + $saveButton = Get-ButtonBounds -UiDump $saveState.UiDump | + Where-Object { $_.ContentDesc -eq "保存新版本" } | + Select-Object -First 1 + if (-not $saveButton) { + $saveButton = Get-ContentDescBounds -UiDump $saveState.UiDump | + Where-Object { $_.ContentDesc -eq "save-contract-version" } | + Select-Object -First 1 + } + if (-not $saveButton) { + $saveButton = Get-TextBounds -UiDump $saveState.UiDump | + Where-Object { $_.Text -eq "保存新版本" } | + Select-Object -First 1 + } + if ($saveButton) { + break + } + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1830 -EndX 540 -EndY 760 -DurationMs 360 + Start-Sleep -Seconds 1 + } + + if ($saveButton -and $editorOpened) { + $saveCenter = Get-SafeTapPoint -Bounds $saveButton + Invoke-AdbTap -Serial $Serial -X $saveCenter.X -Y $saveCenter.Y + Start-Sleep -Seconds 6 + + $afterSaveState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-after-save" -ArtifactDir $ArtifactDir -PackageName $PackageName + $saveOk = ($afterSaveState.UiDump -match 'contract-version-chain') -and + -not ($afterSaveState.UiDump -match 'contract-version-editor') + $createdVersionVisible = $afterSaveState.UiDump -match [regex]::Escape($saveMarker) + if (-not $createdVersionVisible) { + for ($createdAttempt = 1; $createdAttempt -le 4; $createdAttempt++) { + Invoke-AdbSwipe -Serial $Serial -StartX 540 -StartY 1760 -EndX 540 -EndY 760 -DurationMs 320 + Start-Sleep -Seconds 1 + $createdState = Get-SmokeState -Serial $Serial -Label "contract-version-chain-created-version-$createdAttempt" -ArtifactDir $ArtifactDir -PackageName $PackageName + if ($createdState.UiDump -match [regex]::Escape($saveMarker)) { + $createdVersionVisible = $true + break + } + } + } + } + } + } + + return [pscustomobject]@{ + ToolsSurface = $toolsState.Surface + WorkspaceSurface = $workspaceState.Surface + HistorySurface = $historyState.Surface + DetailSurface = $detailState.Surface + EntryOk = $entryOk + VersionDownloadOk = $downloadOk + VersionDiffVisible = $diffVisible + EditorOpened = $editorOpened + VersionSaveOk = $saveOk + CreatedVersionVisible = $createdVersionVisible + SaveMarker = $saveMarker + VisibleLabels = ($visibleLabels -join ",") + SmokeOk = ($entryOk -and $downloadOk -and $saveOk -and $createdVersionVisible) + } +} + function Invoke-DocumentsWorkspaceProof { param( [string]$Serial, @@ -1063,8 +1496,16 @@ try { } catch { $connectionError = ($_.Exception.Message -replace '\s+', ' ').Trim() $connectionError | Set-Content -Path (Join-Path $artifactDir "reconnect-error.txt") - & $adb mdns services 2>&1 | Set-Content -Path (Join-Path $artifactDir "mdns-services.txt") - & $adb devices -l 2>&1 | Set-Content -Path (Join-Path $artifactDir "adb-devices.txt") + try { + (& $adb mdns services 2>&1) | Set-Content -Path (Join-Path $artifactDir "mdns-services.txt") + } catch { + ($_.Exception.Message -replace '\s+', ' ').Trim() | Set-Content -Path (Join-Path $artifactDir "mdns-services.txt") + } + try { + (& $adb devices -l 2>&1) | Set-Content -Path (Join-Path $artifactDir "adb-devices.txt") + } catch { + ($_.Exception.Message -replace '\s+', ' ').Trim() | Set-Content -Path (Join-Path $artifactDir "adb-devices.txt") + } $blockedApiStatus = $null $blockedWebStatus = $null @@ -1095,6 +1536,14 @@ try { "TAXONOMY_TARGET_CARD_OK=", "TAXONOMY_FILTER_ACTIVE_OK=", "TAXONOMY_VISIBLE_KEYS=", + "CONTRACT_VERSION_CHAIN_PROOF_OK=", + "CONTRACT_VERSION_CHAIN_ENTRY_OK=", + "CONTRACT_VERSION_CHAIN_DOWNLOAD_OK=", + "CONTRACT_VERSION_CHAIN_DIFF_VISIBLE=", + "CONTRACT_VERSION_CHAIN_SAVE_OK=", + "CONTRACT_VERSION_CHAIN_CREATED_VISIBLE=", + "CONTRACT_VERSION_CHAIN_SAVE_MARKER=", + "CONTRACT_VERSION_CHAIN_VISIBLE_LABELS=", "DOCUMENTS_PROOF_OK=", "DOCUMENTS_WORKSPACE_READY=", "DOCUMENTS_TYPE_LIST_OK=", @@ -1184,6 +1633,18 @@ $taxonomyProofOk = "" $taxonomyTargetCardOk = "" $taxonomyFilterActiveOk = "" $taxonomyVisibleKeys = "" +$contractResultProofOk = "" +$contractResultDownloadOk = "" +$contractResultShareOk = "" +$contractResultContinueOk = "" +$contractVersionChainProofOk = "" +$contractVersionChainEntryOk = "" +$contractVersionChainDownloadOk = "" +$contractVersionChainDiffVisible = "" +$contractVersionChainSaveOk = "" +$contractVersionChainCreatedVisible = "" +$contractVersionChainSaveMarker = "" +$contractVersionChainVisibleLabels = "" $documentsProofOk = "" $documentsWorkspaceReady = "" $documentsTypeListOk = "" @@ -1286,6 +1747,66 @@ switch ($SmokeDepth) { $taxonomyVisibleKeys = $taxonomyProof.VisibleKeys $smokeOk = $taxonomyProof.SmokeOk } + "contract-result-actions" { + if ($surface -eq "device_locked") { + $smokeBlocker = "device_locked" + break + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $loginState = Try-AutoLogin -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName -Account $LoginAccount -Password $LoginPassword + if ($loginState -ne $null) { + $initialState = $loginState + $surface = $initialState.Surface + $topActivity = $initialState.TopActivity + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $smokeBlocker = "login_required" + break + } + } + if (-not ($initialState.AppVisible -and $surface -eq "signed_in_shell")) { + throw "contract-result-actions requires the app to relaunch into the signed-in shell. Current surface: $surface" + } + + $contractResultProof = Invoke-ContractResultActionsProof -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName + $contractResultProofOk = $contractResultProof.SmokeOk + $contractResultDownloadOk = $contractResultProof.DownloadOk + $contractResultShareOk = $contractResultProof.ShareOk + $contractResultContinueOk = $contractResultProof.ContinueButtonFound + $smokeOk = $contractResultProof.SmokeOk + } + "contract-version-chain" { + if ($surface -eq "device_locked") { + $smokeBlocker = "device_locked" + break + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $loginState = Try-AutoLogin -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName -Account $LoginAccount -Password $LoginPassword + if ($loginState -ne $null) { + $initialState = $loginState + $surface = $initialState.Surface + $topActivity = $initialState.TopActivity + } + if ($surface -eq "login_form" -or ($initialState.AppVisible -and $surface -eq "app_visible")) { + $smokeBlocker = "login_required" + break + } + } + if (-not ($initialState.AppVisible -and $surface -eq "signed_in_shell")) { + throw "contract-version-chain requires the app to relaunch into the signed-in shell. Current surface: $surface" + } + + $contractVersionProof = Invoke-ContractVersionChainProof -Serial $serial -ArtifactDir $artifactDir -PackageName $PackageName + $contractVersionChainProofOk = $contractVersionProof.SmokeOk + $contractVersionChainEntryOk = $contractVersionProof.EntryOk + $contractVersionChainDownloadOk = $contractVersionProof.VersionDownloadOk + $contractVersionChainDiffVisible = $contractVersionProof.VersionDiffVisible + $contractVersionChainSaveOk = $contractVersionProof.VersionSaveOk + $contractVersionChainCreatedVisible = $contractVersionProof.CreatedVersionVisible + $contractVersionChainSaveMarker = $contractVersionProof.SaveMarker + $contractVersionChainVisibleLabels = $contractVersionProof.VisibleLabels + $smokeOk = $contractVersionProof.SmokeOk + } "documents-workspace" { if ($surface -eq "device_locked") { $smokeBlocker = "device_locked" @@ -1423,6 +1944,18 @@ $summaryLines = @( "TAXONOMY_TARGET_CARD_OK=$taxonomyTargetCardOk", "TAXONOMY_FILTER_ACTIVE_OK=$taxonomyFilterActiveOk", "TAXONOMY_VISIBLE_KEYS=$taxonomyVisibleKeys", + "CONTRACT_RESULT_PROOF_OK=$contractResultProofOk", + "CONTRACT_RESULT_DOWNLOAD_OK=$contractResultDownloadOk", + "CONTRACT_RESULT_SHARE_OK=$contractResultShareOk", + "CONTRACT_RESULT_CONTINUE_OK=$contractResultContinueOk", + "CONTRACT_VERSION_CHAIN_PROOF_OK=$contractVersionChainProofOk", + "CONTRACT_VERSION_CHAIN_ENTRY_OK=$contractVersionChainEntryOk", + "CONTRACT_VERSION_CHAIN_DOWNLOAD_OK=$contractVersionChainDownloadOk", + "CONTRACT_VERSION_CHAIN_DIFF_VISIBLE=$contractVersionChainDiffVisible", + "CONTRACT_VERSION_CHAIN_SAVE_OK=$contractVersionChainSaveOk", + "CONTRACT_VERSION_CHAIN_CREATED_VISIBLE=$contractVersionChainCreatedVisible", + "CONTRACT_VERSION_CHAIN_SAVE_MARKER=$contractVersionChainSaveMarker", + "CONTRACT_VERSION_CHAIN_VISIBLE_LABELS=$contractVersionChainVisibleLabels", "DOCUMENTS_PROOF_OK=$documentsProofOk", "DOCUMENTS_WORKSPACE_READY=$documentsWorkspaceReady", "DOCUMENTS_TYPE_LIST_OK=$documentsTypeListOk", diff --git a/mobile/tools/run-real-device-product-smoke-suite.ps1 b/mobile/tools/run-real-device-product-smoke-suite.ps1 index c575ba0c..8e2cf23b 100644 --- a/mobile/tools/run-real-device-product-smoke-suite.ps1 +++ b/mobile/tools/run-real-device-product-smoke-suite.ps1 @@ -3,11 +3,13 @@ param( [string]$DeviceSerial = "", [string]$LoginAccount = "", [string]$LoginPassword = "", - [ValidateSet("login-only", "shell-only", "dashboard-review-preview", "share-proof", "contract-taxonomy", "documents-workspace", "review-legal-document", "business-auxiliary")] + [ValidateSet("login-only", "shell-only", "dashboard-review-preview", "share-proof", "contract-taxonomy", "contract-result-actions", "contract-version-chain", "documents-workspace", "review-legal-document", "business-auxiliary")] [string[]]$SmokeDepths = @( "shell-only", "share-proof", "contract-taxonomy", + "contract-result-actions", + "contract-version-chain", "documents-workspace", "review-legal-document", "dashboard-review-preview",