Add contract version chain mobile workflow
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <member-password>` 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 <member-password>` 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 <member-password>` 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 <member-password>` 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`
|
||||
|
||||
@@ -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<bool> isLoading = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<bool> isSaving = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<bool> isDownloading = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<String?> errorMessage = ValueNotifier<String?>(null);
|
||||
final ValueNotifier<GeneratedContractVersionListResponse?> versions =
|
||||
ValueNotifier<GeneratedContractVersionListResponse?>(null);
|
||||
final ValueNotifier<GeneratedContractVersionDiffResult?> diff =
|
||||
ValueNotifier<GeneratedContractVersionDiffResult?>(null);
|
||||
final ValueNotifier<String?> localFilePath = ValueNotifier<String?>(null);
|
||||
|
||||
Future<void> 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<void> 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<GeneratedContractVersionItem> 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<String> 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<List<int>> _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<Directory> _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;
|
||||
}
|
||||
@@ -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<String, dynamic> 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<GeneratedContractVersionItem> items;
|
||||
final int total;
|
||||
|
||||
List<GeneratedContractVersionItem> get displayItems {
|
||||
return <GeneratedContractVersionItem>[...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<String, dynamic> json) {
|
||||
final contractId = json['contract_id']?.toString() ?? '';
|
||||
final rawItems = json['items'];
|
||||
final baseMap = json['base_version'] is Map<String, dynamic>
|
||||
? json['base_version'] as Map<String, dynamic>
|
||||
: <String, dynamic>{'version_number': 1, 'version_label': 'v1'};
|
||||
return GeneratedContractVersionListResponse(
|
||||
contractId: contractId,
|
||||
baseVersion: GeneratedContractVersionItem.fromJson(
|
||||
baseMap,
|
||||
contractId: contractId,
|
||||
),
|
||||
items: rawItems is List
|
||||
? rawItems
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map((item) => GeneratedContractVersionItem.fromJson(item,
|
||||
contractId: contractId))
|
||||
.toList(growable: false)
|
||||
: const <GeneratedContractVersionItem>[],
|
||||
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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<GeneratedContractVersionDiffItem> items;
|
||||
|
||||
factory GeneratedContractVersionDiffResult.fromJson(
|
||||
Map<String, dynamic> json) {
|
||||
final rawItems = json['items'];
|
||||
return GeneratedContractVersionDiffResult(
|
||||
contractId: json['contract_id']?.toString() ?? '',
|
||||
fromVersion: GeneratedContractVersionSnapshot.fromJson(
|
||||
json['from_version'] is Map<String, dynamic>
|
||||
? json['from_version'] as Map<String, dynamic>
|
||||
: <String, dynamic>{},
|
||||
),
|
||||
toVersion: GeneratedContractVersionSnapshot.fromJson(
|
||||
json['to_version'] is Map<String, dynamic>
|
||||
? json['to_version'] as Map<String, dynamic>
|
||||
: <String, dynamic>{},
|
||||
),
|
||||
summary: GeneratedContractVersionDiffSummary.fromJson(
|
||||
json['summary'] is Map<String, dynamic>
|
||||
? json['summary'] as Map<String, dynamic>
|
||||
: <String, dynamic>{},
|
||||
),
|
||||
items: rawItems is List
|
||||
? rawItems
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(GeneratedContractVersionDiffItem.fromJson)
|
||||
.toList(growable: false)
|
||||
: const <GeneratedContractVersionDiffItem>[],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -151,231 +151,254 @@ class _ContractBuilderScreenState extends State<ContractBuilderScreen> {
|
||||
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<String>(
|
||||
initialValue: _exportFormat,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '导出格式',
|
||||
),
|
||||
items: const <DropdownMenuItem<String>>[
|
||||
DropdownMenuItem<String>(
|
||||
value: 'docx', child: Text('Word (.docx)')),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'pdf', child: Text('PDF (.pdf)')),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'html', child: Text('HTML (.html)')),
|
||||
DropdownMenuItem<String>(
|
||||
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<ContractPreviewItem?>(
|
||||
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<String>(
|
||||
initialValue: _exportFormat,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '导出格式',
|
||||
),
|
||||
items: const <DropdownMenuItem<String>>[
|
||||
DropdownMenuItem<String>(
|
||||
value: 'docx', child: Text('Word (.docx)')),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'pdf', child: Text('PDF (.pdf)')),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'html', child: Text('HTML (.html)')),
|
||||
DropdownMenuItem<String>(
|
||||
value: 'txt', child: Text('文本 (.txt)')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
if (value == null) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_exportFormat = value;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
ValueListenableBuilder<String?>(
|
||||
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<bool>(
|
||||
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<bool>(
|
||||
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<ContractPreviewItem?>(
|
||||
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<String?>(
|
||||
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<bool>(
|
||||
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<bool>(
|
||||
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 ? '生成中...' : '生成合同'),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ContractTemplateListScreen> createState() =>
|
||||
@@ -30,9 +34,10 @@ class ContractTemplateListScreen extends StatefulWidget {
|
||||
|
||||
class _ContractTemplateListScreenState extends State<ContractTemplateListScreen>
|
||||
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<ContractTemplateListScreen>
|
||||
@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<ContractTemplateListScreen>
|
||||
@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<ContractTemplateListScreen>
|
||||
);
|
||||
|
||||
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 <BoxShadow>[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 <BoxShadow>[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('继续编辑'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+845
-174
File diff suppressed because it is too large
Load Diff
@@ -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<GeneratedContractVersionListResponse> 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<GeneratedContractVersionItem> 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 = <String, dynamic>{
|
||||
'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<GeneratedContractVersionDiffResult> diffGeneratedContractVersions(
|
||||
String accessToken,
|
||||
String contractId, {
|
||||
String? fromVersionId,
|
||||
String? toVersionId,
|
||||
}) async {
|
||||
final queryParameters = <String, String>{};
|
||||
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<Uint8List> 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<Map<String, dynamic>> submitReview({
|
||||
required String accessToken,
|
||||
required ReviewDraft draft,
|
||||
|
||||
+196
@@ -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<GeneratedContractVersionListResponse> listGeneratedContractVersions(
|
||||
String accessToken,
|
||||
String contractId,
|
||||
) async {
|
||||
loadedContractId = contractId;
|
||||
return _versionList;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GeneratedContractVersionDiffResult> diffGeneratedContractVersions(
|
||||
String accessToken,
|
||||
String contractId, {
|
||||
String? fromVersionId,
|
||||
String? toVersionId,
|
||||
}) async {
|
||||
diffToVersionId = toVersionId;
|
||||
return _versionDiff;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GeneratedContractVersionItem> 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<Uint8List> downloadGeneratedContractVersionFile(
|
||||
String accessToken,
|
||||
String contractId,
|
||||
String versionId, {
|
||||
String exportFormat = 'docx',
|
||||
}) async {
|
||||
return Uint8List.fromList(<int>[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>[
|
||||
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>[
|
||||
GeneratedContractVersionDiffItem(
|
||||
changeType: 'added',
|
||||
oldParagraphIndex: null,
|
||||
newParagraphIndex: 2,
|
||||
oldText: '',
|
||||
newText: 'Acceptance clause added.',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -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(<String, dynamic>{
|
||||
'contract_id': 'contract-1',
|
||||
'base_version': <String, dynamic>{
|
||||
'version_number': 1,
|
||||
'version_label': 'v1',
|
||||
'content': 'Base content',
|
||||
'created_at': '2026-05-26T01:00:00Z',
|
||||
},
|
||||
'items': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'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(<String, dynamic>{
|
||||
'contract_id': 'contract-1',
|
||||
'from_version': <String, dynamic>{
|
||||
'id': 'base',
|
||||
'version_number': 1,
|
||||
'version_label': 'v1',
|
||||
'change_type': 'generated',
|
||||
'change_summary': '原始生成版本',
|
||||
'created_at': '2026-05-26T01:00:00Z',
|
||||
},
|
||||
'to_version': <String, dynamic>{
|
||||
'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': <String, dynamic>{
|
||||
'added': 1,
|
||||
'removed': 0,
|
||||
'modified': 1,
|
||||
'unchanged': 2,
|
||||
'change_count': 2,
|
||||
'from_paragraph_count': 3,
|
||||
'to_paragraph_count': 4,
|
||||
},
|
||||
'items': <Map<String, dynamic>>[
|
||||
<String, dynamic>{
|
||||
'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');
|
||||
});
|
||||
}
|
||||
@@ -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<List<ContractTemplateItem>> listContractTemplates(
|
||||
String accessToken, {
|
||||
String? contractType,
|
||||
String? documentTypeCode,
|
||||
String? templateCategory,
|
||||
String? industryCategory,
|
||||
}) async {
|
||||
return const <ContractTemplateItem>[_template];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GeneratedContractListResponse> listGeneratedContracts(
|
||||
String accessToken, {
|
||||
int page = 1,
|
||||
int pageSize = 10,
|
||||
String? contractType,
|
||||
String? keyword,
|
||||
}) async {
|
||||
return const GeneratedContractListResponse(
|
||||
items: <GeneratedContractListItem>[_generatedListItem],
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GeneratedContractItem> getGeneratedContract(
|
||||
String accessToken,
|
||||
String contractId,
|
||||
) async {
|
||||
return _generatedContract;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GeneratedContractVersionListResponse> listGeneratedContractVersions(
|
||||
String accessToken,
|
||||
String contractId,
|
||||
) async {
|
||||
return savedContent == null
|
||||
? _generatedVersionList
|
||||
: _generatedVersionListAfterSave;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GeneratedContractVersionDiffResult> diffGeneratedContractVersions(
|
||||
String accessToken,
|
||||
String contractId, {
|
||||
String? fromVersionId,
|
||||
String? toVersionId,
|
||||
}) async {
|
||||
return _generatedVersionDiff;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<GeneratedContractVersionItem> 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<Uint8List> downloadGeneratedContractVersionFile(
|
||||
String accessToken,
|
||||
String contractId,
|
||||
String versionId, {
|
||||
String exportFormat = 'docx',
|
||||
}) async {
|
||||
return Uint8List.fromList(<int>[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>[
|
||||
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: <String, String>{'party_a': '甲方公司'},
|
||||
missingFields: <GeneratedContractMissingField>[],
|
||||
aiHints: <GeneratedContractAiHint>[],
|
||||
relatedLegalArticles: <GeneratedContractLegalArticle>[],
|
||||
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>[
|
||||
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: <GeneratedContractVersionItem>[
|
||||
_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>[
|
||||
GeneratedContractVersionDiffItem(
|
||||
changeType: 'added',
|
||||
oldParagraphIndex: null,
|
||||
newParagraphIndex: 2,
|
||||
oldText: '',
|
||||
newText: 'Acceptance clause added.',
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user