From f89d9d0057005563ec3cde11aebbe6b714a69ab0 Mon Sep 17 00:00:00 2001 From: yuqei Date: Thu, 21 May 2026 21:33:28 +0800 Subject: [PATCH] feat: add contract delivery export diff --- backend/app/api/contracts.py | 76 +++++ .../services/contract_generation_service.py | 293 +++++++++++++++++- backend/tests/test_contracts_api.py | 160 ++++++++++ frontend/src/app/documents/[id]/edit/page.tsx | 137 +++++++- frontend/src/app/globals.css | 47 +++ frontend/src/services/contract.ts | 81 +++++ ...µ·草与审查助手-当前工作进度保存-2026-05-11.md | 39 +++ ...法律文书能力吸收开发方案-代码对齐检查-2026-05-21.md | 20 +- 8 files changed, 838 insertions(+), 15 deletions(-) diff --git a/backend/app/api/contracts.py b/backend/app/api/contracts.py index 41a199e1..fc851754 100644 --- a/backend/app/api/contracts.py +++ b/backend/app/api/contracts.py @@ -24,6 +24,8 @@ ROUTE_PERMISSION_ACTIONS = { "versions": "contracts:versions:list", "version_detail": "contracts:versions:detail", "version_create": "contracts:versions:create", + "version_diff": "contracts:versions:diff", + "version_download": "contracts:versions:download", "lifecycle_update": "contracts:lifecycle:update", "lifecycle_tasks": "contracts:lifecycle-tasks:list", "lifecycle_task_sync": "contracts:lifecycle-tasks:sync", @@ -462,6 +464,80 @@ def list_generated_contract_versions( return success_response(contract_generation_service.list_generated_contract_versions(current_user, contract_id)) +@router.get("/{contract_id}/versions/diff") +def diff_generated_contract_versions( + contract_id: str, + request: Request, + from_version_id: Optional[str] = None, + to_version_id: Optional[str] = None, + contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + result = contract_generation_service.diff_generated_contract_versions( + current_user=current_user, + contract_id=contract_id, + from_version_id=from_version_id, + to_version_id=to_version_id, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="contract_version_diff", + resource_type="contract", + resource_id=contract_id, + detail_json={ + "from_version": result.get("from_version", {}).get("version_label"), + "to_version": result.get("to_version", {}).get("version_label"), + "change_count": result.get("summary", {}).get("change_count"), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + return success_response(result) + + +@router.get("/{contract_id}/versions/{version_id}/download") +def download_generated_contract_version( + contract_id: str, + version_id: str, + request: Request, + export_format: str = "docx", + contract_generation_service: ContractGenerationService = Depends(get_contract_generation_service), + audit_log_service: AuditLogService = Depends(get_audit_log_service), + current_user: dict = Depends(require_current_user), +): + try: + export_format_enum = ExportFormat(export_format.lower()) + except ValueError: + raise HTTPException(status_code=400, detail=f"不支持的导出格式: {export_format}") + + result = contract_generation_service.export_generated_contract_version( + current_user=current_user, + contract_id=contract_id, + version_id=version_id, + export_format=export_format_enum, + ) + audit_log_service.record_event( + tenant_id=current_user.get("tenant_id"), + user_id=current_user.get("id"), + action="download_contract_version", + resource_type="contract_version", + resource_id=version_id, + detail_json={ + "contract_id": contract_id, + "version_label": result.get("version", {}).get("version_label"), + "file_name": result["file_name"], + "mime_type": result["mime_type"], + "export_format": result.get("export_format"), + }, + ip_address=request.client.host if request.client else None, + user_agent=request.headers.get("user-agent"), + ) + headers = {"Content-Disposition": f"attachment; filename*=UTF-8''{quote(result['file_name'])}"} + return Response(content=result["content"], media_type=result["mime_type"], headers=headers) + + @router.get("/{contract_id}/versions/{version_id}") def get_generated_contract_version( contract_id: str, diff --git a/backend/app/services/contract_generation_service.py b/backend/app/services/contract_generation_service.py index 52b06759..53d31d92 100644 --- a/backend/app/services/contract_generation_service.py +++ b/backend/app/services/contract_generation_service.py @@ -2,6 +2,7 @@ import json import re import io import base64 +import difflib from html import escape from typing import Any, Dict, List, Optional, Tuple from datetime import datetime, timedelta, timezone @@ -659,6 +660,31 @@ class ContractGenerationService: "total": len(versions), } + def diff_generated_contract_versions( + self, + current_user: Dict[str, Any], + contract_id: str, + *, + from_version_id: Optional[str] = None, + to_version_id: Optional[str] = None, + ) -> Dict[str, Any]: + tenant_id = current_user["tenant_id"] + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + from_snapshot, to_snapshot = self._resolve_diff_version_pair( + tenant_id=tenant_id, + generated_contract=generated_contract, + from_version_id=from_version_id, + to_version_id=to_version_id, + ) + diff_result = self._build_contract_version_diff(from_snapshot["content"], to_snapshot["content"]) + return { + "contract_id": contract_id, + "from_version": self._public_version_snapshot(from_snapshot), + "to_version": self._public_version_snapshot(to_snapshot), + "summary": diff_result["summary"], + "items": diff_result["items"], + } + def get_generated_contract_version(self, current_user: Dict[str, Any], contract_id: str, version_id: str) -> Dict[str, Any]: tenant_id = current_user["tenant_id"] generated_contract = self.generated_contract_repository.get_by_id(contract_id, tenant_id) @@ -669,6 +695,51 @@ class ContractGenerationService: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract version not found") return self._to_generated_contract_version_item(version) + def export_generated_contract_version( + self, + current_user: Dict[str, Any], + contract_id: str, + *, + version_id: Optional[str] = None, + export_format: ExportFormat = ExportFormat.DOCX, + ) -> Dict[str, Any]: + if export_format != ExportFormat.DOCX: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="only docx export is supported for version delivery") + tenant_id = current_user["tenant_id"] + generated_contract = self._get_generated_contract_for_lifecycle_task(current_user, contract_id) + snapshot = self._resolve_contract_version_snapshot( + tenant_id=tenant_id, + generated_contract=generated_contract, + version_id=version_id or "latest", + ) + timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") + options = dict(generated_contract.export_options_json or {}) + options.update( + { + "document_title": generated_contract.template_name, + "version_label": snapshot["version_label"], + "export_kind": "editable_contract_version", + "generated_by_name": generated_contract.generated_by_name, + } + ) + content, file_name = self._export_contract( + snapshot["content"], + f"{generated_contract.template_name}_{snapshot['version_label']}", + timestamp, + export_format, + options, + ) + return { + "id": generated_contract.id, + "contract_id": generated_contract.id, + "version": self._public_version_snapshot(snapshot), + "file_name": file_name, + "mime_type": self._get_mime_type(export_format), + "content": content, + "file_size": len(content), + "export_format": export_format.value, + } + def create_generated_contract_version( self, current_user: Dict[str, Any], @@ -1176,6 +1247,182 @@ class ContractGenerationService: "detail_url": f"/contracts/{version.contract_id}?version={version.id}", } + def _base_contract_version_snapshot(self, generated_contract: GeneratedContract) -> Dict[str, Any]: + return { + "id": "base", + "version_number": 1, + "version_label": "v1", + "source_version_id": None, + "change_type": "generated", + "change_summary": "原始生成版本", + "content": generated_contract.generated_content, + "created_at": generated_contract.created_at, + } + + @classmethod + def _version_model_snapshot(cls, version: GeneratedContractVersion) -> Dict[str, Any]: + return { + "id": version.id, + "version_number": version.version_number, + "version_label": version.version_label, + "source_version_id": version.source_version_id, + "change_type": version.change_type, + "change_summary": version.change_summary, + "content": version.content, + "created_at": version.created_at, + } + + @staticmethod + def _public_version_snapshot(snapshot: Dict[str, Any]) -> Dict[str, Any]: + created_at = snapshot.get("created_at") + return { + "id": snapshot.get("id"), + "version_number": snapshot.get("version_number"), + "version_label": snapshot.get("version_label"), + "source_version_id": snapshot.get("source_version_id"), + "change_type": snapshot.get("change_type"), + "change_summary": snapshot.get("change_summary"), + "created_at": created_at.isoformat() if hasattr(created_at, "isoformat") else created_at, + } + + def _resolve_contract_version_snapshot( + self, + *, + tenant_id: str, + generated_contract: GeneratedContract, + version_id: Optional[str], + ) -> Dict[str, Any]: + normalized = str(version_id or "latest").strip() + if normalized.lower() in {"base", "v1", "original"}: + return self._base_contract_version_snapshot(generated_contract) + if normalized.lower() in {"latest", "current"}: + latest_version = self.generated_contract_version_repository.get_latest_by_contract(tenant_id, generated_contract.id) + return self._version_model_snapshot(latest_version) if latest_version is not None else self._base_contract_version_snapshot(generated_contract) + + version = self.generated_contract_version_repository.get_by_id(tenant_id, generated_contract.id, normalized) + if not version: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="contract version not found") + return self._version_model_snapshot(version) + + def _resolve_diff_version_pair( + self, + *, + tenant_id: str, + generated_contract: GeneratedContract, + from_version_id: Optional[str], + to_version_id: Optional[str], + ) -> Tuple[Dict[str, Any], Dict[str, Any]]: + if from_version_id: + from_snapshot = self._resolve_contract_version_snapshot( + tenant_id=tenant_id, + generated_contract=generated_contract, + version_id=from_version_id, + ) + else: + from_snapshot = None + + if to_version_id: + to_snapshot = self._resolve_contract_version_snapshot( + tenant_id=tenant_id, + generated_contract=generated_contract, + version_id=to_version_id, + ) + else: + to_snapshot = None + + if from_snapshot is not None and to_snapshot is not None: + return from_snapshot, to_snapshot + + base_snapshot = self._base_contract_version_snapshot(generated_contract) + versions = [ + self._version_model_snapshot(item) + for item in self.generated_contract_version_repository.list_by_contract(tenant_id, generated_contract.id) + ] + versions_desc = sorted(versions, key=lambda item: (int(item.get("version_number") or 0), str(item.get("created_at") or "")), reverse=True) + + if to_snapshot is None: + to_snapshot = versions_desc[0] if versions_desc else base_snapshot + if from_snapshot is None: + from_snapshot = self._find_previous_version_snapshot(base_snapshot, versions_desc, to_snapshot) + return from_snapshot, to_snapshot + + @staticmethod + def _find_previous_version_snapshot( + base_snapshot: Dict[str, Any], + versions_desc: List[Dict[str, Any]], + to_snapshot: Dict[str, Any], + ) -> Dict[str, Any]: + to_id = to_snapshot.get("id") + if to_id in {"base", None}: + return base_snapshot + for index, item in enumerate(versions_desc): + if item.get("id") == to_id: + return versions_desc[index + 1] if index + 1 < len(versions_desc) else base_snapshot + to_number = int(to_snapshot.get("version_number") or 1) + older = [item for item in versions_desc if int(item.get("version_number") or 0) < to_number] + return older[0] if older else base_snapshot + + @classmethod + def _split_contract_diff_units(cls, content: str) -> List[str]: + normalized = str(content or "").replace("\r\n", "\n").replace("\r", "\n").strip() + if not normalized: + return [] + paragraphs = [item.strip() for item in re.split(r"\n+", normalized) if item.strip()] + if len(paragraphs) > 1: + return paragraphs + clause_parts = [item.strip() for item in re.split(r"(?=第[一二三四五六七八九十百千万零〇0-9]+[章节条])", normalized) if item.strip()] + return clause_parts if len(clause_parts) > 1 else paragraphs + + @classmethod + def _build_contract_version_diff(cls, old_content: str, new_content: str) -> Dict[str, Any]: + old_units = cls._split_contract_diff_units(old_content) + new_units = cls._split_contract_diff_units(new_content) + matcher = difflib.SequenceMatcher(a=old_units, b=new_units, autojunk=False) + items: List[Dict[str, Any]] = [] + unchanged_count = 0 + counters = {"added": 0, "removed": 0, "modified": 0} + + for tag, old_start, old_end, new_start, new_end in matcher.get_opcodes(): + if tag == "equal": + unchanged_count += old_end - old_start + continue + + old_slice = old_units[old_start:old_end] + new_slice = new_units[new_start:new_end] + pair_count = max(len(old_slice), len(new_slice)) + for offset in range(pair_count): + old_text = old_slice[offset] if offset < len(old_slice) else None + new_text = new_slice[offset] if offset < len(new_slice) else None + if old_text is not None and new_text is not None: + change_type = "modified" + elif new_text is not None: + change_type = "added" + else: + change_type = "removed" + counters[change_type] += 1 + items.append( + { + "change_type": change_type, + "old_paragraph_index": old_start + offset + 1 if old_text is not None else None, + "new_paragraph_index": new_start + offset + 1 if new_text is not None else None, + "old_text": old_text, + "new_text": new_text, + } + ) + + return { + "summary": { + "added": counters["added"], + "removed": counters["removed"], + "modified": counters["modified"], + "unchanged": unchanged_count, + "change_count": len(items), + "from_paragraph_count": len(old_units), + "to_paragraph_count": len(new_units), + }, + "items": items, + } + @staticmethod def _normalize_optional_datetime(value: Optional[datetime]) -> Optional[datetime]: if value is None: @@ -1889,14 +2136,28 @@ class ContractGenerationService: style = doc.styles['Normal'] style.font.name = '宋体' style.font.size = Pt(12) + section = doc.sections[0] + section.top_margin = Inches(0.85) + section.bottom_margin = Inches(0.85) + section.left_margin = Inches(0.85) + section.right_margin = Inches(0.85) + + document_title = str(options.get("document_title") or "合同文件").strip() or "合同文件" + version_label = str(options.get("version_label") or "").strip() + generated_by_name = str(options.get("generated_by_name") or "").strip() # 添加标题 - title = doc.add_heading('合同文件', 0) + title = doc.add_heading(document_title, 0) title.alignment = WD_ALIGN_PARAGRAPH.CENTER # 添加生成信息 info_para = doc.add_paragraph() - info_para.add_run(f'生成时间: {timestamp}').italic = True + info_parts = [f"导出时间: {timestamp}"] + if version_label: + info_parts.insert(0, f"版本: {version_label}") + if generated_by_name: + info_parts.append(f"经办人: {generated_by_name}") + info_para.add_run(" | ".join(info_parts)).italic = True info_para.alignment = WD_ALIGN_PARAGRAPH.RIGHT # 添加分隔线 @@ -1906,15 +2167,33 @@ class ContractGenerationService: # 按段落分割内容 paragraphs = content.split('\n') for para_text in paragraphs: - if para_text.strip(): - para = doc.add_paragraph(para_text.strip()) - para.style = doc.styles['Normal'] + normalized_para = para_text.strip() + if not normalized_para: + continue + if re.match(r"^第[一二三四五六七八九十百千万零〇0-9]+[章节条]", normalized_para): + para = doc.add_paragraph() + run = para.add_run(normalized_para) + run.bold = True + run.font.size = Pt(12) + para.paragraph_format.space_before = Pt(8) + para.paragraph_format.space_after = Pt(4) + continue + if re.match(r"^[一二三四五六七八九十0-9]+[、..]", normalized_para): + para = doc.add_paragraph() + run = para.add_run(normalized_para) + run.bold = True + para.paragraph_format.space_before = Pt(6) + para.paragraph_format.space_after = Pt(3) + continue + para = doc.add_paragraph(normalized_para) + para.style = doc.styles['Normal'] + para.paragraph_format.first_line_indent = Pt(24) + para.paragraph_format.space_after = Pt(4) # 添加页脚 - section = doc.sections[0] footer = section.footer footer_para = footer.paragraphs[0] - footer_para.text = f"AI合同平台生成 - {safe_name}" + footer_para.text = f"AI合同平台导出 - {document_title}{f' - {version_label}' if version_label else ''}" footer_para.alignment = WD_ALIGN_PARAGRAPH.CENTER # 保存到内存 diff --git a/backend/tests/test_contracts_api.py b/backend/tests/test_contracts_api.py index fbf016e8..d45a7b53 100644 --- a/backend/tests/test_contracts_api.py +++ b/backend/tests/test_contracts_api.py @@ -339,6 +339,40 @@ class StubContractGenerationService: return item raise HTTPException(status_code=404, detail="contract version not found") + def diff_generated_contract_versions(self, current_user, contract_id, from_version_id=None, to_version_id=None): + self.get_generated_contract(current_user, contract_id) + return { + "contract_id": contract_id, + "from_version": {"id": "base", "version_number": 1, "version_label": "v1"}, + "to_version": {"id": to_version_id or "gcv-001", "version_number": 2, "version_label": "v2"}, + "summary": { + "added": 1, + "removed": 0, + "modified": 1, + "unchanged": 1, + "change_count": 2, + "from_paragraph_count": 2, + "to_paragraph_count": 3, + }, + "items": [ + {"change_type": "modified", "old_paragraph_index": 1, "new_paragraph_index": 1, "old_text": "Old", "new_text": "New"}, + {"change_type": "added", "old_paragraph_index": None, "new_paragraph_index": 3, "old_text": None, "new_text": "Payment deadline added."}, + ], + } + + def export_generated_contract_version(self, current_user, contract_id, version_id=None, export_format=ExportFormat.DOCX): + self.get_generated_contract(current_user, contract_id) + return { + "id": contract_id, + "contract_id": contract_id, + "version": {"id": version_id or "latest", "version_number": 2, "version_label": "v2"}, + "file_name": "contract-v2.docx", + "mime_type": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "content": b"docx-bytes", + "file_size": 10, + "export_format": export_format.value, + } + def create_generated_contract_version( self, current_user, @@ -614,6 +648,24 @@ class ContractsApiTestCase(unittest.TestCase): self.assertEqual(detail.status_code, 200) self.assertEqual(detail.json()["data"]["id"], "gcv-001") + def test_user_can_diff_and_download_generated_contract_version(self): + self._login() + diff_response = self.client.get("/api/v1/contracts/contract-123/versions/diff") + self.assertEqual(diff_response.status_code, 200) + diff_body = diff_response.json()["data"] + self.assertEqual(diff_body["summary"]["change_count"], 2) + self.assertEqual(diff_body["items"][0]["change_type"], "modified") + self.assertEqual(self.audit_log_service.operations[-1]["action"], "contract_version_diff") + + download_response = self.client.get("/api/v1/contracts/contract-123/versions/gcv-001/download") + self.assertEqual(download_response.status_code, 200) + self.assertEqual( + download_response.headers["content-type"], + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + self.assertEqual(download_response.content, b"docx-bytes") + self.assertEqual(self.audit_log_service.operations[-1]["action"], "download_contract_version") + def test_user_can_create_generated_contract_version_and_write_audit_log(self): self._login() response = self.client.post( @@ -725,6 +777,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase): from app.models.contract_template import ContractTemplate from app.models.file import File from app.models.generated_contract import GeneratedContract + from app.models.generated_contract_version import GeneratedContractVersion from app.models.legal_article import LegalArticle from app.models.legal_document_element_schema import LegalDocumentElementSchema from app.models.legal_document_type import LegalDocumentType @@ -738,6 +791,7 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.ContractLifecycleTask = ContractLifecycleTask self.File = File self.GeneratedContract = GeneratedContract + self.GeneratedContractVersion = GeneratedContractVersion self.LegalArticle = LegalArticle self.LegalDocumentType = LegalDocumentType self.LegalDocumentElementSchema = LegalDocumentElementSchema @@ -1082,6 +1136,112 @@ class ContractGenerationServiceTestCase(unittest.TestCase): self.assertEqual(ctx.exception.status_code, 409) + def test_diff_generated_contract_versions_returns_paragraph_changes(self): + now = datetime.now(timezone.utc) + self.db.add( + self.GeneratedContract( + id="contract-diff-001", + tenant_id="tenant-demo-001", + template_id="tpl-diff", + template_name="Diff Template", + contract_type="procurement", + document_type_code="procurement_goods", + generated_by="user-admin", + generated_by_name="Admin", + export_format="docx", + file_id=None, + file_name="diff.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="第一条 标的\n第二条 价款", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ) + ) + self.db.add( + self.GeneratedContractVersion( + id="gcv-diff-002", + tenant_id="tenant-demo-001", + contract_id="contract-diff-001", + version_number=2, + version_label="v2", + change_type="editor_save", + change_summary="补充验收", + content="第一条 标的\n第二条 价款\n第三条 验收", + locate_status="applied", + created_by="user-admin", + created_at=now + timedelta(minutes=1), + ) + ) + self.db.commit() + + result = self.service.diff_generated_contract_versions( + current_user=self.current_user, + contract_id="contract-diff-001", + ) + + self.assertEqual(result["from_version"]["version_label"], "v1") + self.assertEqual(result["to_version"]["version_label"], "v2") + self.assertEqual(result["summary"]["added"], 1) + self.assertEqual(result["items"][0]["change_type"], "added") + self.assertIn("第三条 验收", result["items"][0]["new_text"]) + + def test_export_generated_contract_version_returns_docx_payload(self): + now = datetime.now(timezone.utc) + self.db.add( + self.GeneratedContract( + id="contract-export-version-001", + tenant_id="tenant-demo-001", + template_id="tpl-export", + template_name="Export Template", + contract_type="procurement", + document_type_code="procurement_goods", + generated_by="user-admin", + generated_by_name="Admin", + export_format="docx", + file_id=None, + file_name="export.docx", + mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", + file_size=10, + generated_content="第一条 标的\n第二条 价款", + variable_values_json={}, + export_options_json={}, + file_content_base64="", + status="generated", + created_at=now, + ) + ) + self.db.add( + self.GeneratedContractVersion( + id="gcv-export-002", + tenant_id="tenant-demo-001", + contract_id="contract-export-version-001", + version_number=2, + version_label="v2", + change_type="editor_save", + change_summary="补充交付", + content="第一条 标的\n第二条 价款\n第三条 交付", + locate_status="applied", + created_by="user-admin", + created_at=now + timedelta(minutes=1), + ) + ) + self.db.commit() + + result = self.service.export_generated_contract_version( + current_user=self.current_user, + contract_id="contract-export-version-001", + version_id="gcv-export-002", + ) + + self.assertEqual(result["export_format"], "docx") + self.assertEqual(result["version"]["version_label"], "v2") + self.assertTrue(result["file_name"].endswith(".docx")) + self.assertTrue(result["content"].startswith(b"PK")) + def test_update_generated_contract_lifecycle_tracks_expiry_and_owner(self): now = datetime.now(timezone.utc) self.db.add( diff --git a/frontend/src/app/documents/[id]/edit/page.tsx b/frontend/src/app/documents/[id]/edit/page.tsx index f9af2c32..c57563f3 100644 --- a/frontend/src/app/documents/[id]/edit/page.tsx +++ b/frontend/src/app/documents/[id]/edit/page.tsx @@ -3,6 +3,8 @@ import { BulbOutlined, CheckCircleOutlined, + DiffOutlined, + DownloadOutlined, EditOutlined, HistoryOutlined, LinkOutlined, @@ -27,9 +29,12 @@ import { useRouter } from "next/navigation"; import { createGeneratedContractVersion, + diffGeneratedContractVersions, + downloadGeneratedContractVersion, getGeneratedContract, listGeneratedContractVersions, type GeneratedContract, + type GeneratedContractVersionDiffResponse, type GeneratedContractVersionItem, } from "@/services/contract"; import { @@ -67,6 +72,18 @@ const recommendationKindColorMap: Record = { industry_practice: "purple", }; +const diffChangeTypeLabelMap: Record = { + added: "新增", + removed: "删除", + modified: "修改", +}; + +const diffChangeTypeColorMap: Record = { + added: "green", + removed: "red", + modified: "gold", +}; + function formatDateTime(value?: string | null) { if (!value) { return "-"; @@ -148,6 +165,9 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis const [rewriteMode, setRewriteMode] = useState("professional"); const [rewriteLoading, setRewriteLoading] = useState(false); const [rewriteResult, setRewriteResult] = useState(null); + const [versionDiff, setVersionDiff] = useState(null); + const [diffLoading, setDiffLoading] = useState(false); + const [exportingVersion, setExportingVersion] = useState(false); const [loadError, setLoadError] = useState(null); const paragraphs = useMemo(() => splitParagraphs(editorContent), [editorContent]); @@ -166,6 +186,24 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis return versionsResponse.data; }, []); + const refreshVersionDiff = useCallback(async (id: string, toVersionId?: string | null) => { + setDiffLoading(true); + try { + const diffResponse = await diffGeneratedContractVersions(id, { to_version_id: toVersionId ?? undefined }); + if (diffResponse.code !== 0) { + messageApi.warning(diffResponse.message || "版本对比加载失败"); + return null; + } + setVersionDiff(diffResponse.data); + return diffResponse.data; + } catch (error) { + messageApi.warning(getErrorMessage(error, "版本对比加载失败")); + return null; + } finally { + setDiffLoading(false); + } + }, [messageApi]); + const loadPage = useCallback(async () => { setLoading(true); setLoadError(null); @@ -192,12 +230,13 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis currentVersionIdRef.current = latest?.id ?? null; setSelectedParagraphIndex(null); setAutoSaveStatus(latest ? `已加载 ${latest.version_label}` : "已加载 v1"); + void refreshVersionDiff(id, latest?.id); } catch (error) { setLoadError(getErrorMessage(error, "合同编辑器加载失败")); } finally { setLoading(false); } - }, [params]); + }, [params, refreshVersionDiff]); useEffect(() => { void loadPage(); @@ -265,6 +304,7 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis currentVersionIdRef.current = response.data.id; setAutoSaveStatus(`已保存 ${response.data.version_label}`); await refreshVersions(contractId); + await refreshVersionDiff(contractId, response.data.id); if (!options.quiet) { messageApi.success(`已生成新版本 ${response.data.version_label}`); } @@ -282,7 +322,7 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis } finally { setSaving(false); } - }, [contractId, messageApi, refreshVersions]); + }, [contractId, messageApi, refreshVersionDiff, refreshVersions]); const scheduleAutoSave = useCallback((nextContent: string, summary = "自动保存编辑内容") => { pendingChangeSummaryRef.current = summary; @@ -313,6 +353,43 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis setAutoSaveStatus(version ? `已切换 ${version.version_label}` : "已切换 v1"); setSelectedParagraphIndex(null); setRewriteResult(null); + if (contractId) { + void refreshVersionDiff(contractId, version?.id ?? "base"); + } + }; + + const handleExportCurrentVersion = async () => { + if (!contractId || !contract) { + return; + } + let versionId = currentVersionIdRef.current ?? "base"; + let versionLabel = currentVersionIdRef.current + ? versions.find((item) => item.id === currentVersionIdRef.current)?.version_label ?? "current" + : "v1"; + setExportingVersion(true); + try { + if (normalizeEditorText(editorContent) !== persistedContentRef.current) { + const saved = await saveVersion(editorContent, { + changeType: "editor_save", + changeSummary: "导出前保存当前正文", + quiet: true, + }); + if (!saved && normalizeEditorText(editorContent) !== persistedContentRef.current) { + messageApi.warning("当前正文尚未保存,已暂停导出。请先保存为新版本。"); + return; + } + if (saved) { + versionId = saved.id; + versionLabel = saved.version_label; + } + } + await downloadGeneratedContractVersion(contractId, versionId, `${contract.template_name}_${versionLabel}.docx`); + messageApi.success("已导出当前版本 Word 文件"); + } catch (error) { + messageApi.error(getErrorMessage(error, "当前版本导出失败")); + } finally { + setExportingVersion(false); + } }; const handleRecommend = async () => { @@ -471,6 +548,9 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis + @@ -493,6 +573,59 @@ export default function GeneratedContractEditorPage({ params }: { params: Promis