diff --git a/server/src/app/api/v1/endpoints/agent_assets.py b/server/src/app/api/v1/endpoints/agent_assets.py index 07f49db..217b53f 100644 --- a/server/src/app/api/v1/endpoints/agent_assets.py +++ b/server/src/app/api/v1/endpoints/agent_assets.py @@ -23,6 +23,7 @@ from app.schemas.agent_asset import ( AgentAssetRead, AgentAssetReviewCreate, AgentAssetReviewRead, + AgentAssetSpreadsheetChangeRecordRead, AgentAssetVersionCompareRead, AgentAssetUpdate, AgentAssetVersionCreate, @@ -276,12 +277,17 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback( str, Query(min_length=1, description="打开编辑器时对应的规则版本号。"), ], + actor_name: Annotated[ + str | None, + Query(description="发起编辑的用户显示名。"), + ] = None, ) -> AgentAssetOnlyOfficeCallbackRead: try: AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback( asset_id, version=version, payload=payload.model_dump(), + actor_name=actor_name, ) except Exception as exc: _handle_asset_error(exc) @@ -289,6 +295,24 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback( return AgentAssetOnlyOfficeCallbackRead() +@router.get( + "/{asset_id}/spreadsheet/change-records", + response_model=list[AgentAssetSpreadsheetChangeRecordRead], + summary="读取规则表最近修改记录", + description="返回最近 30 次 ONLYOFFICE 保存级修改记录,用于展示操作者、时间和具体差异。", +) +def list_agent_asset_spreadsheet_change_records( + asset_id: str, + _: CurrentUser, + db: DbSession, + limit: Annotated[int, Query(ge=1, le=30, description="返回条数,最多 30 条。")] = 30, +) -> list[AgentAssetSpreadsheetChangeRecordRead]: + try: + return AgentAssetService(db).list_spreadsheet_change_records(asset_id, limit=limit) + except Exception as exc: + _handle_asset_error(exc) + + @router.post( "", response_model=AgentAssetRead, diff --git a/server/src/app/schemas/agent_asset.py b/server/src/app/schemas/agent_asset.py index c7b5366..0311d20 100644 --- a/server/src/app/schemas/agent_asset.py +++ b/server/src/app/schemas/agent_asset.py @@ -128,6 +128,16 @@ class AgentAssetVersionCompareRead(BaseModel): cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list) +class AgentAssetSpreadsheetChangeRecordRead(BaseModel): + actor: str + changed_at: datetime + summary: str + sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list) + cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list) + changed_sheet_count: int = 0 + changed_cell_count: int = 0 + + class AgentAssetVersionRead(BaseModel): model_config = ConfigDict(from_attributes=True) diff --git a/server/src/app/services/agent_assets.py b/server/src/app/services/agent_assets.py index eca7aa9..aacd9f8 100644 --- a/server/src/app/services/agent_assets.py +++ b/server/src/app/services/agent_assets.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any +from urllib.parse import quote from urllib.request import Request, urlopen import jwt @@ -29,6 +30,7 @@ from app.schemas.agent_asset import ( AgentAssetRead, AgentAssetReviewCreate, AgentAssetReviewRead, + AgentAssetSpreadsheetChangeRecordRead, AgentAssetSpreadsheetDiffCellRead, AgentAssetSpreadsheetDiffSheetRead, AgentAssetUpdate, @@ -300,6 +302,19 @@ class AgentAssetService: asset = self.repository.get(asset_id) if asset is None: raise LookupError("Asset not found") + if ( + asset.asset_type == AgentAssetType.RULE.value + and payload.review_status == AgentReviewStatus.PENDING + and payload.version != self._resolve_working_version(asset) + ): + if self.repository.get_version(asset_id, payload.version) is not None: + raise ValueError(f"版本 {payload.version} 已存在,不能重复送审。") + asset = self._create_named_working_copy_for_review( + asset, + target_version=payload.version, + actor=actor, + request_id=request_id, + ) if self.repository.get_version(asset_id, payload.version) is None: raise LookupError(f"版本 {payload.version} 不存在") if asset.asset_type == AgentAssetType.RULE.value: @@ -352,6 +367,82 @@ class AgentAssetService: ) return AgentAssetReviewRead.model_validate(created) + def _create_named_working_copy_for_review( + self, + asset: AgentAsset, + *, + target_version: str, + actor: str, + request_id: str | None = None, + ) -> AgentAsset: + working_version = self._resolve_working_version(asset) + if not working_version: + raise ValueError("当前规则尚未配置工作版本,无法提交审核。") + + source = self.repository.get_version(asset.id, working_version) + if source is None: + raise LookupError(f"版本 {working_version} 不存在") + + is_spreadsheet_rule = ( + asset.asset_type == AgentAssetType.RULE.value + and str((asset.config_json or {}).get("detail_mode") or "").strip().lower() + == "spreadsheet" + ) + if is_spreadsheet_rule: + _, metadata = self._resolve_spreadsheet_version_meta(asset, version=working_version) + file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key) + if not file_path.exists(): + raise FileNotFoundError(metadata.file_name) + snapshot_meta = self.spreadsheet_manager.store_spreadsheet( + asset_id=asset.id, + version=target_version, + file_name=metadata.file_name, + content=file_path.read_bytes(), + actor_name=actor, + source="review-submit", + ) + next_content = self.spreadsheet_manager.build_version_markdown( + rule_name=asset.name, + version=target_version, + metadata=snapshot_meta, + ) + next_content_type = AgentAssetContentType.MARKDOWN + else: + next_content = self._deserialize_content(source) + next_content_type = AgentAssetContentType(source.content_type) + + self.create_version( + asset.id, + AgentAssetVersionCreate( + version=target_version, + content=next_content, + content_type=next_content_type, + change_note=f"提交审核前固化工作稿为 {target_version}", + created_by=actor, + ), + actor=actor, + request_id=request_id, + ) + + refreshed = self.repository.get(asset.id) + if refreshed is None: + raise LookupError("Asset not found") + + if is_spreadsheet_rule: + config_json = dict(refreshed.config_json or {}) + current_document_meta = self._read_current_rule_document_meta(refreshed) + if current_document_meta is not None: + rule_document = self.spreadsheet_manager.build_rule_document_config( + current_document_meta, + asset_version=target_version, + ) + rule_document["storage_key"] = current_document_meta.storage_key + config_json["rule_document"] = rule_document + refreshed.config_json = config_json + self.repository.save_asset(refreshed) + + return refreshed + def activate_asset( self, asset_id: str, @@ -576,6 +667,7 @@ class AgentAssetService: *, version: str, payload: dict[str, Any], + actor_name: str | None = None, ) -> None: self._ensure_ready() if asset_id == PREVIEW_RULE_ASSET_ID: @@ -603,15 +695,57 @@ class AgentAssetService: if current_metadata.checksum and current_metadata.checksum == self._hash_bytes(content): return - actor_name = callback.users[0] if callback.users else "ONLYOFFICE" + from io import BytesIO + from openpyxl import load_workbook + try: + base_workbook = self._load_spreadsheet_for_compare(current_metadata) + target_workbook = load_workbook(BytesIO(content), read_only=False, data_only=False) + sheet_changes, cell_changes = self._collect_workbook_changes( + base_workbook, target_workbook + ) + changed_sheet_count = len( + {item.sheet_name for item in sheet_changes} + | {item.sheet_name for item in cell_changes} + ) + changed_cell_count = len(cell_changes) + + if changed_cell_count > 0 or changed_sheet_count > 0: + change_note = f"ONLYOFFICE 在线编辑:涉及 {changed_sheet_count} 个 Sheet,共 {changed_cell_count} 处改动。" + else: + change_note = "ONLYOFFICE 在线编辑保存。" + except Exception: + sheet_changes = [] + cell_changes = [] + changed_sheet_count = 0 + changed_cell_count = 0 + change_note = "ONLYOFFICE 在线编辑保存。" + + resolved_actor_name = str(actor_name or "").strip() or ( + callback.users[0] if callback.users else "ONLYOFFICE" + ) self.upload_rule_spreadsheet( asset.id, filename=current_metadata.file_name, content=content, - actor=actor_name, - change_note="ONLYOFFICE 编辑保存规则表。", + actor=resolved_actor_name, + change_note=change_note, source="onlyoffice", ) + if changed_sheet_count > 0 or changed_cell_count > 0: + self.audit_service.log_action( + actor=resolved_actor_name, + action="edit_rule_spreadsheet", + resource_type=asset.asset_type, + resource_id=asset.id, + before_json={"version": version}, + after_json={ + "summary": change_note, + "changed_sheet_count": changed_sheet_count, + "changed_cell_count": changed_cell_count, + "sheet_changes": [item.model_dump() for item in sheet_changes], + "cell_changes": [item.model_dump() for item in cell_changes[:500]], + }, + ) def _ensure_ready(self) -> None: AgentFoundationService(self.db).ensure_foundation_ready() @@ -853,6 +987,39 @@ class AgentAssetService: cell_changes=cell_changes[:500], ) + def list_spreadsheet_change_records( + self, + asset_id: str, + *, + limit: int = 30, + ) -> list[AgentAssetSpreadsheetChangeRecordRead]: + self._ensure_ready() + asset = self._require_spreadsheet_rule(asset_id) + logs = self.audit_service.repository.list( + resource_type=asset.asset_type, + resource_id=asset.id, + action="edit_rule_spreadsheet", + limit=min(max(limit, 1), 30), + ) + return [ + AgentAssetSpreadsheetChangeRecordRead( + actor=log.actor, + changed_at=log.created_at, + summary=str((log.after_json or {}).get("summary") or "ONLYOFFICE 在线编辑保存。"), + sheet_changes=[ + AgentAssetSpreadsheetDiffSheetRead.model_validate(item) + for item in ((log.after_json or {}).get("sheet_changes") or []) + ], + cell_changes=[ + AgentAssetSpreadsheetDiffCellRead.model_validate(item) + for item in ((log.after_json or {}).get("cell_changes") or []) + ], + changed_sheet_count=int((log.after_json or {}).get("changed_sheet_count") or 0), + changed_cell_count=int((log.after_json or {}).get("changed_cell_count") or 0), + ) + for log in logs + ] + def _serialize_version( self, version: AgentAssetVersion, asset: AgentAsset ) -> AgentAssetVersionRead: @@ -964,7 +1131,7 @@ class AgentAssetService: ) callback_url = ( f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/callback" - f"?version={resolved_version}" + f"?version={resolved_version}&actor_name={quote(current_user.name)}" ) config: dict[str, Any] = { @@ -994,7 +1161,7 @@ class AgentAssetService: "compactToolbar": False, "toolbarNoTabs": False, "autosave": False, - "forcesave": False, + "forcesave": editable, }, }, "width": "100%", @@ -1207,6 +1374,52 @@ class AgentAssetService: raise FileNotFoundError(metadata.file_name) return load_workbook(BytesIO(file_path.read_bytes()), read_only=False, data_only=False) + def _collect_workbook_changes( + self, base_workbook, target_workbook + ) -> tuple[list[AgentAssetSpreadsheetDiffSheetRead], list[AgentAssetSpreadsheetDiffCellRead]]: + base_sheet_names = set(base_workbook.sheetnames) + target_sheet_names = set(target_workbook.sheetnames) + sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = [] + for sheet_name in sorted(target_sheet_names - base_sheet_names): + sheet_changes.append( + AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="added") + ) + for sheet_name in sorted(base_sheet_names - target_sheet_names): + sheet_changes.append( + AgentAssetSpreadsheetDiffSheetRead(sheet_name=sheet_name, change_type="removed") + ) + + cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = [] + + for sheet_name in sorted(base_sheet_names & target_sheet_names): + base_sheet = base_workbook[sheet_name] + target_sheet = target_workbook[sheet_name] + max_row = max(base_sheet.max_row, target_sheet.max_row) + max_column = max(base_sheet.max_column, target_sheet.max_column) + for row_index in range(1, max_row + 1): + for column_index in range(1, max_column + 1): + before_value = base_sheet.cell(row=row_index, column=column_index).value + after_value = target_sheet.cell(row=row_index, column=column_index).value + if before_value == after_value: + continue + if before_value in (None, ""): + change_type = "added" + elif after_value in (None, ""): + change_type = "removed" + else: + change_type = "modified" + cell_changes.append( + AgentAssetSpreadsheetDiffCellRead( + sheet_name=sheet_name, + cell=target_sheet.cell(row=row_index, column=column_index).coordinate, + change_type=change_type, + before_value=before_value, + after_value=after_value, + ) + ) + + return sheet_changes, cell_changes + @staticmethod def _extract_restore_source_version(change_note: str | None) -> str | None: normalized = str(change_note or "").strip() diff --git a/web/src/assets/styles/views/audit-view.css b/web/src/assets/styles/views/audit-view.css index 353f46e..278d994 100644 --- a/web/src/assets/styles/views/audit-view.css +++ b/web/src/assets/styles/views/audit-view.css @@ -947,7 +947,7 @@ tbody tr.spotlight { height: 100%; align-self: stretch; display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto; + grid-template-rows: auto minmax(0, 1fr) auto; gap: 12px; padding: 14px; border: 1px solid #dbe4ee; @@ -1101,6 +1101,10 @@ tbody tr.spotlight { cursor: pointer; } +.version-center-item > button:disabled { + cursor: default; +} + .version-center-item > button div { display: flex; align-items: center; @@ -1115,7 +1119,8 @@ tbody tr.spotlight { } .version-center-item > button span, -.version-center-item > button p { +.version-center-item > button p, +.version-center-item > button small { color: #64748b; font-size: 11px; } @@ -1125,70 +1130,129 @@ tbody tr.spotlight { line-height: 1.45; } -.version-center-item footer { - display: flex; - flex-wrap: wrap; - gap: 6px; -} - -.version-center-item footer button { - min-height: 24px; - padding: 0 9px; - border: 0; - border-radius: 999px; - background: #f1f5f9; - color: #475569; +.change-record-summary-empty { + color: #64748b; font-size: 11px; - font-weight: 850; + line-height: 1.5; +} + +.change-record-item { + gap: 10px; + width: 100%; + border: 1px solid #e2e8f0; + background: #fff; + color: inherit; + text-align: left; cursor: pointer; + transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease; } -.version-center-item footer button:nth-child(2) { - background: #eef2ff; - color: #4f46e5; +.change-record-item:hover { + border-color: rgba(16, 185, 129, 0.35); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08); + transform: translateY(-1px); } -.version-center-item footer button:nth-child(3) { - background: #fff7ed; - color: #ea580c; +.change-record-item:focus-visible { + outline: 3px solid rgba(16, 185, 129, 0.18); + outline-offset: 1px; } -.version-flow-preview { +.change-record-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 10px; +} + +.change-record-head > div { display: grid; - gap: 8px; + gap: 3px; } -.version-flow-preview article { - display: grid; - grid-template-columns: 22px minmax(0, 1fr); - gap: 8px; - align-items: start; -} - -.version-flow-preview i { - color: #2563eb; - font-size: 16px; -} - -.version-flow-preview div { - display: grid; - gap: 2px; -} - -.version-flow-preview strong { +.change-record-head strong { color: #0f172a; - font-size: 12px; + font-size: 13px; + font-weight: 900; } -.version-flow-preview span, -.version-flow-preview small, -.version-flow-empty { +.change-record-head span, +.change-record-item p, +.change-record-more { color: #64748b; font-size: 11px; } -.version-flow-preview small { - grid-column: 2; +.change-record-head b { + min-height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border-radius: 999px; + background: #eef2ff; + color: #4f46e5; + font-size: 11px; + font-weight: 850; +} + +.change-record-item p { + margin: 0; + line-height: 1.5; +} + +.change-record-preview { + display: grid; + gap: 6px; +} + +.change-record-preview span { + display: block; + padding: 7px 8px; + border-radius: 10px; + background: #f8fafc; + color: #334155; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; +} + +.change-record-more { + font-weight: 800; +} + +.change-detail-content { + gap: 14px; +} + +.change-detail-meta { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.change-detail-meta article { + display: grid; + gap: 4px; + min-height: 0; + padding: 10px 12px; + border-radius: 14px; + background: #f8fafc; +} + +.change-detail-meta span { + color: #64748b; + font-size: 12px; +} + +.change-detail-meta strong { + color: #0f172a; + font-size: 15px; + font-weight: 900; +} + +.version-flow-empty { + color: #64748b; + font-size: 11px; } .rule-drawer-backdrop { @@ -1381,6 +1445,13 @@ tbody tr.spotlight { color: #94a3b8; } +.compare-content { + min-height: 0; + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 18px; +} + .compare-summary-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -1390,8 +1461,9 @@ tbody tr.spotlight { .compare-summary-grid article { display: grid; gap: 4px; - min-height: 76px; - padding: 12px; + align-content: center; + min-height: 0; + padding: 10px 12px; border-radius: 14px; background: #f8fafc; } @@ -1403,8 +1475,9 @@ tbody tr.spotlight { .compare-summary-grid strong { color: #0f172a; - font-size: 24px; + font-size: 22px; font-weight: 950; + line-height: 1.1; } .compare-panel { @@ -1412,6 +1485,11 @@ tbody tr.spotlight { gap: 10px; } +.compare-cell-panel { + min-height: 0; + grid-template-rows: auto minmax(0, 1fr); +} + .compare-panel header { display: flex; align-items: center; @@ -1475,6 +1553,7 @@ tbody tr.spotlight { } .compare-table-wrap { + min-height: 0; overflow: auto; border: 1px solid #e2e8f0; border-radius: 14px; @@ -1528,6 +1607,51 @@ tbody tr.spotlight { color: #dc2626; } +.review-submit-form { + display: grid; + gap: 12px; +} + +.review-submit-form label { + display: grid; + gap: 7px; +} + +.review-submit-form label span { + color: #475569; + font-size: 12px; + font-weight: 850; +} + +.review-submit-form input, +.review-submit-form select { + width: 100%; + min-height: 42px; + padding: 0 12px; + border: 1px solid #cbd5e1; + border-radius: 12px; + background: #fff; + color: #0f172a; + font-size: 14px; +} + +.review-submit-form input:focus, +.review-submit-form select:focus { + outline: 0; + border-color: rgba(16, 185, 129, 0.5); + box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.12); +} + +.review-submit-hint { + margin: 0; + padding: 10px 12px; + border-radius: 12px; + background: #fff7ed; + color: #c2410c; + font-size: 12px; + line-height: 1.6; +} + @media (max-width: 1280px) { .spreadsheet-editor-body { grid-template-columns: 1fr; @@ -1543,6 +1667,10 @@ tbody tr.spotlight { .compare-summary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + + .change-detail-meta { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } } .rule-spreadsheet-toolbar { @@ -1924,8 +2052,8 @@ tbody tr.spotlight { padding: 0 9px; border: 0; border-radius: 999px; - background: #eef2ff; - color: #4f46e5; + background: #fff7ed; + color: #ea580c; font-size: 11px; font-weight: 850; cursor: pointer; diff --git a/web/src/services/agentAssets.js b/web/src/services/agentAssets.js index ac00232..7ab2d75 100644 --- a/web/src/services/agentAssets.js +++ b/web/src/services/agentAssets.js @@ -144,6 +144,12 @@ export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targe return apiRequest(`/agent-assets/${assetId}/versions/compare?${query.toString()}`) } +export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) { + return apiRequest( + `/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}` + ) +} + export function updateAgentAsset(assetId, payload, options = {}) { return apiRequest(`/agent-assets/${assetId}`, { method: 'PATCH', diff --git a/web/src/views/scripts/AuditView.js b/web/src/views/scripts/AuditView.js index c80d271..2604c66 100644 --- a/web/src/views/scripts/AuditView.js +++ b/web/src/views/scripts/AuditView.js @@ -1,6 +1,7 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' +import { fetchEmployees } from '../../services/employees.js' import TableEmptyState from '../../components/shared/TableEmptyState.vue' import { useSystemState } from '../../composables/useSystemState.js' import { useToast } from '../../composables/useToast.js' @@ -12,6 +13,7 @@ import { fetchAgentAssetDetail, fetchAgentAssets, fetchAgentAssetSpreadsheetBlob, + fetchAgentAssetSpreadsheetChangeRecords, fetchAgentAssetSpreadsheetOnlyOfficeConfig, fetchAgentAssetVersionTimeline, fetchAgentRuns, @@ -1381,6 +1383,11 @@ export default { const detailLoading = ref(false) const detailError = ref('') const actionState = ref('') + const reviewSubmitOpen = ref(false) + const reviewSubmitVersion = ref('') + const reviewSubmitReviewer = ref('') + const reviewSubmitReviewerLoading = ref(false) + const reviewSubmitReviewerOptions = ref([]) const runLoading = ref(false) const runs = ref([]) const spreadsheetUploadInput = ref(null) @@ -1399,6 +1406,15 @@ export default { const versionComparePayload = ref(null) const compareBaseVersion = ref('') const compareTargetVersion = ref('') + const spreadsheetChangeRecordsByAsset = ref({}) + const spreadsheetChangeDetailOpen = ref(false) + const selectedSpreadsheetChangeRecord = ref(null) + let spreadsheetOnlyOfficeMountSeq = 0 + let spreadsheetOnlyOfficeLoadTimer = null + let spreadsheetOnlyOfficeHadLocalEdits = false + let spreadsheetOnlyOfficeSyncSeq = 0 + let spreadsheetOnlyOfficeVersionPollTimer = null + let spreadsheetOnlyOfficeRefreshTimer = null const assetBuckets = ref({ financialRules: [], riskRules: [], @@ -1437,6 +1453,7 @@ export default { const canSubmitReview = computed( () => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value ) + const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0) const canReviewSelected = computed( () => canManageSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value ) @@ -1471,11 +1488,11 @@ export default { ) const selectedSpreadsheetVersionModeLabel = computed(() => { if (selectedSkill.value?.isPreviewMock) { - return canEditSpreadsheetInline.value ? 'ONLYOFFICE 可编辑预览' : 'ONLYOFFICE 历史预览' + return canEditSpreadsheetInline.value ? 'ONLYOFFICE 可编辑' : 'ONLYOFFICE 预览' } return selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion - ? '当前工作版本' - : '历史预览版本' + ? '在线可编辑' + : '只读预览' }) const selectedVersionTimelineItems = computed(() => versionTimelineItems.value.map((item) => ({ @@ -1484,8 +1501,34 @@ export default { timeLabel: formatDateTime(item.event_time) })) ) - const recentVersionTimelineItems = computed(() => - [...selectedVersionTimelineItems.value].slice(-3).reverse() + const selectedSpreadsheetChangeRecords = computed(() => { + if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id) { + return [] + } + return (spreadsheetChangeRecordsByAsset.value[selectedSkill.value.id] || []) + .filter((item) => item?.changed_at) + .map((item) => ({ + ...item, + time: formatDateTime(item.changed_at), + previewChanges: Array.isArray(item.cell_changes) ? item.cell_changes.slice(0, 3) : [], + remainingChangeCount: Math.max((item.changed_cell_count || 0) - 3, 0) + })) + }) + const selectedSpreadsheetChangeSheetRows = computed(() => + Array.isArray(selectedSpreadsheetChangeRecord.value?.sheet_changes) + ? selectedSpreadsheetChangeRecord.value.sheet_changes.map((item) => ({ + ...item, + meta: resolveDiffChangeMeta(item.change_type) + })) + : [] + ) + const selectedSpreadsheetChangeCellRows = computed(() => + Array.isArray(selectedSpreadsheetChangeRecord.value?.cell_changes) + ? selectedSpreadsheetChangeRecord.value.cell_changes.map((item) => ({ + ...item, + meta: resolveDiffChangeMeta(item.change_type) + })) + : [] ) const versionCompareCellRows = computed(() => Array.isArray(versionComparePayload.value?.cell_changes) @@ -1663,6 +1706,7 @@ export default { ) watch(activeType, () => { + stopSpreadsheetOnlyOfficeDeferredRefresh() destroySpreadsheetOnlyOfficeEditor() selectedSkill.value = null versionSwitchTarget.value = null @@ -1750,6 +1794,14 @@ export default { } function destroySpreadsheetOnlyOfficeEditor() { + if (spreadsheetOnlyOfficeLoadTimer) { + window.clearTimeout(spreadsheetOnlyOfficeLoadTimer) + spreadsheetOnlyOfficeLoadTimer = null + } + stopSpreadsheetOnlyOfficeVersionSync() + clearSpreadsheetPendingChangeRecord(selectedSkill.value?.id, selectedSkill.value?.displayVersion) + spreadsheetOnlyOfficeHadLocalEdits = false + spreadsheetOnlyOfficeSyncSeq += 1 if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) { spreadsheetOnlyOfficeEditor.value.destroyEditor() } @@ -1757,43 +1809,281 @@ export default { spreadsheetOnlyOfficeReady.value = false } - async function mountSpreadsheetOnlyOfficeEditor() { + function appendSpreadsheetChangeRecord(record) { + const assetId = normalizeText(record?.assetId) + const version = normalizeText(record?.version) + if (!assetId || !version) { + return + } + + const nextRecord = { + version, + operationLabel: normalizeText(record?.operationLabel) || '表格修改', + operationActor: normalizeText(record?.operationActor) || resolveActor(), + note: normalizeText(record?.note) || '用户修改了表格内容。', + time: record?.time || new Date().toISOString(), + isWorking: record?.isWorking !== false, + isPendingLocalEdit: Boolean(record?.isPendingLocalEdit), + disabledReason: normalizeText(record?.disabledReason) + } + + const current = spreadsheetChangeRecordsByAsset.value[assetId] || [] + const deduped = current.filter( + (item) => + !( + item.version === nextRecord.version && + item.operationLabel === nextRecord.operationLabel && + item.note === nextRecord.note + ) + ) + spreadsheetChangeRecordsByAsset.value = { + ...spreadsheetChangeRecordsByAsset.value, + [assetId]: [nextRecord, ...deduped].slice(0, 5) + } + } + + function clearSpreadsheetPendingChangeRecord(assetId, version) { + const normalizedAssetId = normalizeText(assetId) + const normalizedVersion = normalizeText(version) + if (!normalizedAssetId) { + return + } + + const current = spreadsheetChangeRecordsByAsset.value[normalizedAssetId] || [] + spreadsheetChangeRecordsByAsset.value = { + ...spreadsheetChangeRecordsByAsset.value, + [normalizedAssetId]: current.filter( + (item) => !(item.isPendingLocalEdit && (!normalizedVersion || item.version === normalizedVersion)) + ) + } + } + + function markSpreadsheetPendingChange(assetId, version) { + const normalizedAssetId = normalizeText(assetId) + const normalizedVersion = normalizeText(version) + if (!normalizedAssetId || !normalizedVersion) { + return + } + + clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion) + appendSpreadsheetChangeRecord({ + assetId: normalizedAssetId, + version: normalizedVersion, + operationLabel: '编辑中', + operationActor: resolveActor(), + note: '检测到未保存的表格改动,保存后会生成新版本并可查看差异。', + time: new Date().toISOString(), + isWorking: true, + isPendingLocalEdit: true, + disabledReason: '当前是本地未保存修改,保存后才会生成可对比的版本。' + }) + } + + function stopSpreadsheetOnlyOfficeVersionSync() { + if (spreadsheetOnlyOfficeVersionPollTimer) { + window.clearTimeout(spreadsheetOnlyOfficeVersionPollTimer) + spreadsheetOnlyOfficeVersionPollTimer = null + } + } + + function stopSpreadsheetOnlyOfficeDeferredRefresh() { + if (spreadsheetOnlyOfficeRefreshTimer) { + window.clearTimeout(spreadsheetOnlyOfficeRefreshTimer) + spreadsheetOnlyOfficeRefreshTimer = null + } + } + + function getLatestSpreadsheetChangeKey(assetId) { + const records = spreadsheetChangeRecordsByAsset.value[assetId] || [] + const latest = records.find((item) => item?.changed_at) + return latest ? `${latest.changed_at}-${latest.actor}-${latest.summary}` : '' + } + + async function refreshSpreadsheetChangeRecordsAfterSave(assetId, previousLatestKey = '', attempt = 0) { + const normalizedAssetId = normalizeText(assetId) + if (!normalizedAssetId || selectedSkill.value?.id !== normalizedAssetId) { + return + } + + await loadSpreadsheetChangeRecords(normalizedAssetId) + const nextLatestKey = getLatestSpreadsheetChangeKey(normalizedAssetId) + if (nextLatestKey && nextLatestKey !== previousLatestKey) { + return + } + if (attempt >= 9) { + return + } + await new Promise((resolve) => window.setTimeout(resolve, 800)) + return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1) + } + + function scheduleSpreadsheetEditorRefreshAfterSave(assetId, savedVersion) { + const normalizedAssetId = normalizeText(assetId) + const normalizedSavedVersion = normalizeText(savedVersion) + if (!normalizedAssetId || !normalizedSavedVersion) { + return + } + + stopSpreadsheetOnlyOfficeDeferredRefresh() + spreadsheetOnlyOfficeRefreshTimer = window.setTimeout(async () => { + spreadsheetOnlyOfficeRefreshTimer = null + if ( + selectedSkill.value?.id !== normalizedAssetId || + selectedSkill.value?.displayVersion === normalizedSavedVersion + ) { + return + } + + await loadSelectedAssetDetail(normalizedAssetId) + }, 3200) + } + + function scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version, attempt = 0) { + const normalizedAssetId = normalizeText(assetId) + const normalizedVersion = normalizeText(version) + if (!normalizedAssetId || !normalizedVersion) { + return + } + + const syncSeq = ++spreadsheetOnlyOfficeSyncSeq + stopSpreadsheetOnlyOfficeVersionSync() + const previousLatestChangeKey = getLatestSpreadsheetChangeKey(normalizedAssetId) + + const runSync = async () => { + if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) { + return + } + + try { + const detail = await fetchAgentAssetDetail(normalizedAssetId) + const nextWorkingVersion = normalizeText(detail?.working_version || detail?.current_version) + if (nextWorkingVersion && nextWorkingVersion !== normalizedVersion) { + clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion) + await refreshCurrentAssets() + await refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestChangeKey) + if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) { + return + } + // ONLYOFFICE 的保存回调刚结束时立即销毁并重挂编辑器,偶发会让新文档会话 + // 还没完全就绪就被再次打开,表现为“加载超时”。先刷新右侧修改记录,再留 + // 一个很短的缓冲窗口后切换到新工作版本,用户无需退出重进。 + scheduleSpreadsheetEditorRefreshAfterSave(normalizedAssetId, nextWorkingVersion) + stopSpreadsheetOnlyOfficeVersionSync() + return + } + } catch { + // Ignore transient polling failures and continue retrying within the window. + } + + if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) { + return + } + if (attempt >= 29) { + return + } + spreadsheetOnlyOfficeVersionPollTimer = window.setTimeout(() => { + scheduleSpreadsheetOnlyOfficeVersionSync(normalizedAssetId, normalizedVersion, attempt + 1) + }, 2000) + } + + spreadsheetOnlyOfficeVersionPollTimer = window.setTimeout(() => { + runSync().catch(() => {}) + }, attempt === 0 ? 800 : 2000) + } + + function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version) { + return ( + mountSeq !== spreadsheetOnlyOfficeMountSeq || + !selectedSkillUsesSpreadsheet.value || + selectedSkill.value?.id !== assetId || + selectedSkill.value?.displayVersion !== version || + selectedSkill.value?.loading + ) + } + + async function mountSpreadsheetOnlyOfficeEditor(retryAttempt = 0) { if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id || selectedSkill.value?.loading) { destroySpreadsheetOnlyOfficeEditor() return } + const mountSeq = ++spreadsheetOnlyOfficeMountSeq + const assetId = selectedSkill.value.id + const version = selectedSkill.value.displayVersion + const editable = canEditSpreadsheetInline.value + spreadsheetOnlyOfficeLoading.value = true spreadsheetOnlyOfficeError.value = '' spreadsheetOnlyOfficeReady.value = false destroySpreadsheetOnlyOfficeEditor() try { - const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig( - selectedSkill.value.id, - selectedSkill.value.displayVersion - ) + const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version) + if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) { + return + } + await loadOnlyOfficeApi(payload.documentServerUrl) + if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) { + return + } if (!window.DocsAPI?.DocEditor) { throw new Error('ONLYOFFICE 编辑器未正确加载。') } - spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${selectedSkill.value.id}-${selectedSkill.value.displayVersion}` + // Host id must be unique for every mount. ONLYOFFICE mutates its host DOM + // during lifecycle teardown; reusing the same element can leave the next + // DocEditor instance with a dead container even though config loading succeeds. + spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${version}-${mountSeq}` await nextTick() + if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) { + return + } + const config = buildOnlyOfficeEditorConfig(payload.config, { viewportHeight: window.innerHeight, - editable: canEditSpreadsheetInline.value, + editable, fillContainer: true }) const upstreamEvents = config.events || {} + spreadsheetOnlyOfficeLoadTimer = window.setTimeout(() => { + if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) { + return + } + if (retryAttempt < 1) { + destroySpreadsheetOnlyOfficeEditor() + spreadsheetOnlyOfficeLoading.value = true + window.setTimeout(() => { + mountSpreadsheetOnlyOfficeEditor(retryAttempt + 1).catch(() => {}) + }, 600) + return + } + spreadsheetOnlyOfficeError.value = 'ONLYOFFICE 加载超时,请重新切换版本后重试。' + spreadsheetOnlyOfficeLoading.value = false + destroySpreadsheetOnlyOfficeEditor() + }, 15000) config.events = { ...upstreamEvents, onAppReady(event) { + if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) { + return + } + if (spreadsheetOnlyOfficeLoadTimer) { + window.clearTimeout(spreadsheetOnlyOfficeLoadTimer) + spreadsheetOnlyOfficeLoadTimer = null + } spreadsheetOnlyOfficeReady.value = true spreadsheetOnlyOfficeLoading.value = false upstreamEvents.onAppReady?.(event) }, onError(event) { + if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) { + return + } + if (spreadsheetOnlyOfficeLoadTimer) { + window.clearTimeout(spreadsheetOnlyOfficeLoadTimer) + spreadsheetOnlyOfficeLoadTimer = null + } const errorCode = event?.data?.errorCode const errorDescription = event?.data?.errorDescription spreadsheetOnlyOfficeError.value = errorDescription @@ -1801,13 +2091,37 @@ export default { : `ONLYOFFICE 加载失败${errorCode ? `(错误码 ${errorCode})` : '。'}` spreadsheetOnlyOfficeLoading.value = false upstreamEvents.onError?.(event) + }, + onDocumentStateChange(event) { + const hasChanges = Boolean(event?.data) + if (hasChanges) { + spreadsheetOnlyOfficeHadLocalEdits = true + markSpreadsheetPendingChange(assetId, version) + if (!spreadsheetOnlyOfficeVersionPollTimer) { + scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version) + } + } else if ( + spreadsheetOnlyOfficeHadLocalEdits && + editable && + !isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version) + ) { + spreadsheetOnlyOfficeHadLocalEdits = false + scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version) + } + upstreamEvents.onDocumentStateChange?.(event) } } spreadsheetOnlyOfficeEditor.value = new window.DocsAPI.DocEditor( spreadsheetOnlyOfficeHostId.value, config ) + if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) { + destroySpreadsheetOnlyOfficeEditor() + } } catch (error) { + if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) { + return + } spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。' spreadsheetOnlyOfficeLoading.value = false toast(spreadsheetOnlyOfficeError.value) @@ -1860,6 +2174,7 @@ export default { }) await refreshCurrentAssets() await loadSelectedAssetDetail(selectedSkill.value.id) + await loadSpreadsheetChangeRecords(selectedSkill.value.id) toast(`已导入 ${file.name} 的表格内容,并生成新版本。`) } catch (error) { toast(error?.message || '规则表内容导入失败,请稍后重试。') @@ -1959,6 +2274,9 @@ export default { selectedSkill.value = buildDetailViewModel(detail, runs.value) if (selectedSkill.value?.type === 'rules') { loadVersionTimeline(assetId, { silent: true }).catch(() => {}) + if (selectedSkill.value.usesSpreadsheetRule) { + loadSpreadsheetChangeRecords(assetId).catch(() => {}) + } } } catch (error) { detailError.value = error?.message || '资产详情加载失败,请稍后重试。' @@ -1968,7 +2286,31 @@ export default { } } + async function loadSpreadsheetChangeRecords(assetId) { + if (!assetId) { + return + } + const payload = await fetchAgentAssetSpreadsheetChangeRecords(assetId, 30) + spreadsheetChangeRecordsByAsset.value = { + ...spreadsheetChangeRecordsByAsset.value, + [assetId]: Array.isArray(payload) ? payload : [] + } + } + + function openSpreadsheetChangeDetail(item) { + if (!item?.changed_at) { + return + } + selectedSpreadsheetChangeRecord.value = item + spreadsheetChangeDetailOpen.value = true + } + + function closeSpreadsheetChangeDetail() { + spreadsheetChangeDetailOpen.value = false + } + function openAssetDetail(asset) { + stopSpreadsheetOnlyOfficeDeferredRefresh() destroySpreadsheetOnlyOfficeEditor() spreadsheetOnlyOfficeError.value = '' spreadsheetOnlyOfficeLoading.value = false @@ -2009,6 +2351,7 @@ export default { } function closeDetail() { + stopSpreadsheetOnlyOfficeDeferredRefresh() destroySpreadsheetOnlyOfficeEditor() spreadsheetOnlyOfficeError.value = '' spreadsheetOnlyOfficeLoading.value = false @@ -2187,6 +2530,91 @@ export default { } } + async function loadReviewSubmitReviewers() { + reviewSubmitReviewerLoading.value = true + try { + const employees = await fetchEmployees() + reviewSubmitReviewerOptions.value = (Array.isArray(employees) ? employees : []) + .filter( + (item) => + item.status === '在职' && + Array.isArray(item.roleCodes) && + item.roleCodes.includes('manager') + ) + .map((item) => ({ + value: item.name, + label: `${item.name} · ${item.position || '高级管理员'}` + })) + } catch (error) { + reviewSubmitReviewerOptions.value = [] + toast(error?.message || '审核人列表加载失败,请稍后重试。') + } finally { + reviewSubmitReviewerLoading.value = false + } + } + + async function openSubmitReviewDialog() { + if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) { + return + } + reviewSubmitVersion.value = selectedSkill.value.workingVersion || selectedSkill.value.displayVersion || '' + reviewSubmitReviewer.value = selectedSkill.value.reviewer || '' + reviewSubmitOpen.value = true + await loadReviewSubmitReviewers() + if ( + !reviewSubmitReviewerOptions.value.some( + (item) => item.value === reviewSubmitReviewer.value + ) + ) { + reviewSubmitReviewer.value = reviewSubmitReviewerOptions.value[0]?.value || '' + } + } + + function closeSubmitReviewDialog() { + if (detailBusy.value) { + return + } + reviewSubmitOpen.value = false + } + + async function submitSelectedRuleForReview() { + if (!selectedSkill.value || !canSubmitReview.value || detailBusy.value) { + return + } + const version = normalizeText(reviewSubmitVersion.value) + const reviewer = normalizeText(reviewSubmitReviewer.value) + if (!version) { + toast('请输入送审版本号。') + return + } + if (!reviewer) { + toast('请选择审核人。') + return + } + + actionState.value = 'review-pending' + try { + await createAgentAssetReview( + selectedSkill.value.id, + { + version, + reviewer, + review_status: 'pending', + review_note: buildReviewNote('pending') + }, + { actor: resolveActor() } + ) + reviewSubmitOpen.value = false + await refreshCurrentAssets() + await loadSelectedAssetDetail(selectedSkill.value.id) + toast(`规则版本 ${version} 已提交给 ${reviewer} 审核。`) + } catch (error) { + toast(error?.message || '规则审核提交失败,请稍后重试。') + } finally { + actionState.value = '' + } + } + async function activateSelectedRule() { if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) { return @@ -2241,7 +2669,7 @@ export default { const payload = await fetchAgentAssetVersionTimeline(assetId) versionTimelineItems.value = Array.isArray(payload) ? payload : [] } catch (error) { - versionTimelineError.value = error?.message || '版本流转加载失败,请稍后重试。' + versionTimelineError.value = error?.message || '操作记录加载失败,请稍后重试。' if (!options.silent) { toast(versionTimelineError.value) } @@ -2280,6 +2708,23 @@ export default { await loadVersionCompare() } + function openSpreadsheetChangeRecord(item) { + if (selectedSkill.value?.isPreviewMock) { + toast('预览数据暂不支持真实的线上差异对比。') + return + } + const publishedVersion = normalizeText(selectedSkill.value?.publishedVersion) + if (!selectedSkill.value?.id || !publishedVersion || publishedVersion === '-') { + toast('当前还没有线上版本,暂时无法查看与线上差异。') + return + } + + openVersionCompare({ + baseVersion: publishedVersion, + targetVersion: item.version + }).catch(() => {}) + } + function closeVersionCompare() { versionCompareOpen.value = false } @@ -2312,6 +2757,7 @@ export default { }) onBeforeUnmount(() => { + stopSpreadsheetOnlyOfficeDeferredRefresh() destroySpreadsheetOnlyOfficeEditor() document.removeEventListener('click', handleDocumentClick) }) @@ -2348,6 +2794,7 @@ export default { canManageSelected, canEditSelected, canSubmitReview, + hasReviewSubmitReviewers, canReviewSelected, canEditMarkdown, canUploadSpreadsheet, @@ -2360,9 +2807,14 @@ export default { selectedSpreadsheetFileName, selectedSpreadsheetVersionModeLabel, selectedVersionTimelineItems, - recentVersionTimelineItems, + selectedSpreadsheetChangeRecords, detailBusy, actionState, + reviewSubmitOpen, + reviewSubmitVersion, + reviewSubmitReviewer, + reviewSubmitReviewerLoading, + reviewSubmitReviewerOptions, showReviewNote, spreadsheetUploadInput, spreadsheetOnlyOfficeLoading, @@ -2378,6 +2830,10 @@ export default { versionComparePayload, versionCompareCellRows, versionCompareSheetRows, + spreadsheetChangeDetailOpen, + selectedSpreadsheetChangeRecord, + selectedSpreadsheetChangeSheetRows, + selectedSpreadsheetChangeCellRows, compareBaseVersion, compareTargetVersion, openAssetDetail, @@ -2396,10 +2852,16 @@ export default { downloadSpreadsheetFile, handleSpreadsheetFileInput, reviewSelectedRule, + openSubmitReviewDialog, + closeSubmitReviewDialog, + submitSelectedRuleForReview, activateSelectedRule, restoreSelectedVersion, openVersionTimeline, closeVersionTimeline, + openSpreadsheetChangeRecord, + openSpreadsheetChangeDetail, + closeSpreadsheetChangeDetail, openVersionCompare, closeVersionCompare, loadVersionCompare, diff --git a/web/src/views/scripts/spreadsheetChangeRecords.js b/web/src/views/scripts/spreadsheetChangeRecords.js new file mode 100644 index 0000000..5784e1a --- /dev/null +++ b/web/src/views/scripts/spreadsheetChangeRecords.js @@ -0,0 +1,124 @@ +function normalizeText(value) { + if (typeof value === 'string') { + return value.trim() + } + if (value === null || value === undefined) { + return '' + } + return String(value).trim() +} + +function resolveOperationLabelFromSource(source) { + switch (normalizeText(source).toLowerCase()) { + case 'onlyoffice': + return '在线编辑保存' + case 'content-import': + return '导入表格' + case 'upload': + return '上传表格' + case 'restore': + return '恢复历史版本' + case 'review-submit': + return '提交审核固化' + case 'preview': + return '预览版本' + default: + return '' + } +} + +export function resolveSpreadsheetOperationLabel(record) { + const explicitLabel = normalizeText(record?.operationLabel) + if (explicitLabel) { + return explicitLabel + } + + const sourceLabel = resolveOperationLabelFromSource(record?.spreadsheetMeta?.source) + if (sourceLabel) { + return sourceLabel + } + + const note = normalizeText(record?.note).toLowerCase() + if (note.includes('onlyoffice')) { + return '在线编辑保存' + } + if (note.includes('导入')) { + return '导入表格' + } + if (note.includes('恢复')) { + return '恢复历史版本' + } + if (note.includes('上传')) { + return '上传表格' + } + + return '表格修改' +} + +function normalizeChangeRecord(record) { + const version = normalizeText(record?.version) + if (!version) { + return null + } + + return { + version, + operationLabel: resolveSpreadsheetOperationLabel(record), + operationActor: + normalizeText(record?.operationActor || record?.createdBy || record?.spreadsheetMeta?.updated_by) || + '系统', + note: normalizeText(record?.note) || '用户修改了表格内容。', + time: normalizeText(record?.time || record?.createdAt || record?.spreadsheetMeta?.updated_at), + isWorking: record?.isWorking !== false, + isPendingLocalEdit: Boolean(record?.isPendingLocalEdit), + disabledReason: normalizeText(record?.disabledReason) + } +} + +function toTimestamp(value) { + const normalized = normalizeText(value) + if (!normalized) { + return 0 + } + const parsed = Date.parse(normalized) + return Number.isNaN(parsed) ? 0 : parsed +} + +function dedupeChangeRecords(records) { + const seen = new Set() + return records.filter((item) => { + const key = [item.version, item.operationLabel, item.note].join('::') + if (seen.has(key)) { + return false + } + seen.add(key) + return true + }) +} + +export function buildSpreadsheetChangeRecords({ + history = [], + localRecords = [], + publishedVersion = '', + limit = 5 +} = {}) { + const normalizedPublishedVersion = normalizeText(publishedVersion) + const normalizedHistoryRecords = Array.isArray(history) + ? history.map(normalizeChangeRecord).filter(Boolean) + : [] + const normalizedLocalRecords = Array.isArray(localRecords) + ? localRecords.map(normalizeChangeRecord).filter(Boolean) + : [] + + const unpublishedHistoryRecords = normalizedHistoryRecords.filter( + (item) => item.version !== normalizedPublishedVersion + ) + const preferredHistoryRecords = + unpublishedHistoryRecords.length || !normalizedPublishedVersion + ? unpublishedHistoryRecords + : normalizedHistoryRecords + + return dedupeChangeRecords([...normalizedLocalRecords, ...preferredHistoryRecords]) + .sort((left, right) => toTimestamp(right.time) - toTimestamp(left.time)) + .slice(0, limit) +}