feat(agent_assets): 添加规则版本送审时的命名副本创建逻辑

当提交的版本与当前工作版本不同时,自动创建命名副本:
- 新增 _create_named_working_copy_for_review 方法处理送审时的版本复制
- 支持将工作版本快照复制为指定版本进行送审
- 新增 AgentAssetSpreadsheetChangeRecordRead schema
- API 端点新增 /rules/{id}/spreadsheet-versions/{version}/change-records 接口
This commit is contained in:
caoxiaozhu
2026-05-18 09:42:23 +00:00
parent 64ec27949f
commit 5106d286a1
7 changed files with 1039 additions and 72 deletions

View File

@@ -23,6 +23,7 @@ from app.schemas.agent_asset import (
AgentAssetRead, AgentAssetRead,
AgentAssetReviewCreate, AgentAssetReviewCreate,
AgentAssetReviewRead, AgentAssetReviewRead,
AgentAssetSpreadsheetChangeRecordRead,
AgentAssetVersionCompareRead, AgentAssetVersionCompareRead,
AgentAssetUpdate, AgentAssetUpdate,
AgentAssetVersionCreate, AgentAssetVersionCreate,
@@ -276,12 +277,17 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback(
str, str,
Query(min_length=1, description="打开编辑器时对应的规则版本号。"), Query(min_length=1, description="打开编辑器时对应的规则版本号。"),
], ],
actor_name: Annotated[
str | None,
Query(description="发起编辑的用户显示名。"),
] = None,
) -> AgentAssetOnlyOfficeCallbackRead: ) -> AgentAssetOnlyOfficeCallbackRead:
try: try:
AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback( AgentAssetService(db).handle_rule_spreadsheet_onlyoffice_callback(
asset_id, asset_id,
version=version, version=version,
payload=payload.model_dump(), payload=payload.model_dump(),
actor_name=actor_name,
) )
except Exception as exc: except Exception as exc:
_handle_asset_error(exc) _handle_asset_error(exc)
@@ -289,6 +295,24 @@ def handle_agent_asset_spreadsheet_onlyoffice_callback(
return AgentAssetOnlyOfficeCallbackRead() 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( @router.post(
"", "",
response_model=AgentAssetRead, response_model=AgentAssetRead,

View File

@@ -128,6 +128,16 @@ class AgentAssetVersionCompareRead(BaseModel):
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list) 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): class AgentAssetVersionRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

@@ -5,6 +5,7 @@ from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
from urllib.parse import quote
from urllib.request import Request, urlopen from urllib.request import Request, urlopen
import jwt import jwt
@@ -29,6 +30,7 @@ from app.schemas.agent_asset import (
AgentAssetRead, AgentAssetRead,
AgentAssetReviewCreate, AgentAssetReviewCreate,
AgentAssetReviewRead, AgentAssetReviewRead,
AgentAssetSpreadsheetChangeRecordRead,
AgentAssetSpreadsheetDiffCellRead, AgentAssetSpreadsheetDiffCellRead,
AgentAssetSpreadsheetDiffSheetRead, AgentAssetSpreadsheetDiffSheetRead,
AgentAssetUpdate, AgentAssetUpdate,
@@ -300,6 +302,19 @@ class AgentAssetService:
asset = self.repository.get(asset_id) asset = self.repository.get(asset_id)
if asset is None: if asset is None:
raise LookupError("Asset not found") 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: if self.repository.get_version(asset_id, payload.version) is None:
raise LookupError(f"版本 {payload.version} 不存在") raise LookupError(f"版本 {payload.version} 不存在")
if asset.asset_type == AgentAssetType.RULE.value: if asset.asset_type == AgentAssetType.RULE.value:
@@ -352,6 +367,82 @@ class AgentAssetService:
) )
return AgentAssetReviewRead.model_validate(created) 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( def activate_asset(
self, self,
asset_id: str, asset_id: str,
@@ -576,6 +667,7 @@ class AgentAssetService:
*, *,
version: str, version: str,
payload: dict[str, Any], payload: dict[str, Any],
actor_name: str | None = None,
) -> None: ) -> None:
self._ensure_ready() self._ensure_ready()
if asset_id == PREVIEW_RULE_ASSET_ID: 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): if current_metadata.checksum and current_metadata.checksum == self._hash_bytes(content):
return 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( self.upload_rule_spreadsheet(
asset.id, asset.id,
filename=current_metadata.file_name, filename=current_metadata.file_name,
content=content, content=content,
actor=actor_name, actor=resolved_actor_name,
change_note="ONLYOFFICE 编辑保存规则表。", change_note=change_note,
source="onlyoffice", 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: def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready() AgentFoundationService(self.db).ensure_foundation_ready()
@@ -853,6 +987,39 @@ class AgentAssetService:
cell_changes=cell_changes[:500], 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( def _serialize_version(
self, version: AgentAssetVersion, asset: AgentAsset self, version: AgentAssetVersion, asset: AgentAsset
) -> AgentAssetVersionRead: ) -> AgentAssetVersionRead:
@@ -964,7 +1131,7 @@ class AgentAssetService:
) )
callback_url = ( callback_url = (
f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/callback" 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] = { config: dict[str, Any] = {
@@ -994,7 +1161,7 @@ class AgentAssetService:
"compactToolbar": False, "compactToolbar": False,
"toolbarNoTabs": False, "toolbarNoTabs": False,
"autosave": False, "autosave": False,
"forcesave": False, "forcesave": editable,
}, },
}, },
"width": "100%", "width": "100%",
@@ -1207,6 +1374,52 @@ class AgentAssetService:
raise FileNotFoundError(metadata.file_name) raise FileNotFoundError(metadata.file_name)
return load_workbook(BytesIO(file_path.read_bytes()), read_only=False, data_only=False) 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 @staticmethod
def _extract_restore_source_version(change_note: str | None) -> str | None: def _extract_restore_source_version(change_note: str | None) -> str | None:
normalized = str(change_note or "").strip() normalized = str(change_note or "").strip()

View File

@@ -947,7 +947,7 @@ tbody tr.spotlight {
height: 100%; height: 100%;
align-self: stretch; align-self: stretch;
display: grid; display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto; grid-template-rows: auto minmax(0, 1fr) auto;
gap: 12px; gap: 12px;
padding: 14px; padding: 14px;
border: 1px solid #dbe4ee; border: 1px solid #dbe4ee;
@@ -1101,6 +1101,10 @@ tbody tr.spotlight {
cursor: pointer; cursor: pointer;
} }
.version-center-item > button:disabled {
cursor: default;
}
.version-center-item > button div { .version-center-item > button div {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1115,7 +1119,8 @@ tbody tr.spotlight {
} }
.version-center-item > button span, .version-center-item > button span,
.version-center-item > button p { .version-center-item > button p,
.version-center-item > button small {
color: #64748b; color: #64748b;
font-size: 11px; font-size: 11px;
} }
@@ -1125,70 +1130,129 @@ tbody tr.spotlight {
line-height: 1.45; line-height: 1.45;
} }
.version-center-item footer { .change-record-summary-empty {
display: flex; color: #64748b;
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;
font-size: 11px; 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; cursor: pointer;
transition: border-color 180ms ease, box-shadow 180ms ease, transform 180ms ease;
} }
.version-center-item footer button:nth-child(2) { .change-record-item:hover {
background: #eef2ff; border-color: rgba(16, 185, 129, 0.35);
color: #4f46e5; box-shadow: 0 10px 24px rgba(15, 23, 42, 0.08);
transform: translateY(-1px);
} }
.version-center-item footer button:nth-child(3) { .change-record-item:focus-visible {
background: #fff7ed; outline: 3px solid rgba(16, 185, 129, 0.18);
color: #ea580c; 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; display: grid;
gap: 8px; gap: 3px;
} }
.version-flow-preview article { .change-record-head strong {
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 {
color: #0f172a; color: #0f172a;
font-size: 12px; font-size: 13px;
font-weight: 900;
} }
.version-flow-preview span, .change-record-head span,
.version-flow-preview small, .change-record-item p,
.version-flow-empty { .change-record-more {
color: #64748b; color: #64748b;
font-size: 11px; font-size: 11px;
} }
.version-flow-preview small { .change-record-head b {
grid-column: 2; 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 { .rule-drawer-backdrop {
@@ -1381,6 +1445,13 @@ tbody tr.spotlight {
color: #94a3b8; color: #94a3b8;
} }
.compare-content {
min-height: 0;
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 18px;
}
.compare-summary-grid { .compare-summary-grid {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -1390,8 +1461,9 @@ tbody tr.spotlight {
.compare-summary-grid article { .compare-summary-grid article {
display: grid; display: grid;
gap: 4px; gap: 4px;
min-height: 76px; align-content: center;
padding: 12px; min-height: 0;
padding: 10px 12px;
border-radius: 14px; border-radius: 14px;
background: #f8fafc; background: #f8fafc;
} }
@@ -1403,8 +1475,9 @@ tbody tr.spotlight {
.compare-summary-grid strong { .compare-summary-grid strong {
color: #0f172a; color: #0f172a;
font-size: 24px; font-size: 22px;
font-weight: 950; font-weight: 950;
line-height: 1.1;
} }
.compare-panel { .compare-panel {
@@ -1412,6 +1485,11 @@ tbody tr.spotlight {
gap: 10px; gap: 10px;
} }
.compare-cell-panel {
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
.compare-panel header { .compare-panel header {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1475,6 +1553,7 @@ tbody tr.spotlight {
} }
.compare-table-wrap { .compare-table-wrap {
min-height: 0;
overflow: auto; overflow: auto;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 14px; border-radius: 14px;
@@ -1528,6 +1607,51 @@ tbody tr.spotlight {
color: #dc2626; 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) { @media (max-width: 1280px) {
.spreadsheet-editor-body { .spreadsheet-editor-body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1543,6 +1667,10 @@ tbody tr.spotlight {
.compare-summary-grid { .compare-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.change-detail-meta {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
.rule-spreadsheet-toolbar { .rule-spreadsheet-toolbar {
@@ -1924,8 +2052,8 @@ tbody tr.spotlight {
padding: 0 9px; padding: 0 9px;
border: 0; border: 0;
border-radius: 999px; border-radius: 999px;
background: #eef2ff; background: #fff7ed;
color: #4f46e5; color: #ea580c;
font-size: 11px; font-size: 11px;
font-weight: 850; font-weight: 850;
cursor: pointer; cursor: pointer;

View File

@@ -144,6 +144,12 @@ export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targe
return apiRequest(`/agent-assets/${assetId}/versions/compare?${query.toString()}`) 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 = {}) { export function updateAgentAsset(assetId, payload, options = {}) {
return apiRequest(`/agent-assets/${assetId}`, { return apiRequest(`/agent-assets/${assetId}`, {
method: 'PATCH', method: 'PATCH',

View File

@@ -1,6 +1,7 @@
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import ConfirmDialog from '../../components/shared/ConfirmDialog.vue' import ConfirmDialog from '../../components/shared/ConfirmDialog.vue'
import { fetchEmployees } from '../../services/employees.js'
import TableEmptyState from '../../components/shared/TableEmptyState.vue' import TableEmptyState from '../../components/shared/TableEmptyState.vue'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
@@ -12,6 +13,7 @@ import {
fetchAgentAssetDetail, fetchAgentAssetDetail,
fetchAgentAssets, fetchAgentAssets,
fetchAgentAssetSpreadsheetBlob, fetchAgentAssetSpreadsheetBlob,
fetchAgentAssetSpreadsheetChangeRecords,
fetchAgentAssetSpreadsheetOnlyOfficeConfig, fetchAgentAssetSpreadsheetOnlyOfficeConfig,
fetchAgentAssetVersionTimeline, fetchAgentAssetVersionTimeline,
fetchAgentRuns, fetchAgentRuns,
@@ -1381,6 +1383,11 @@ export default {
const detailLoading = ref(false) const detailLoading = ref(false)
const detailError = ref('') const detailError = ref('')
const actionState = 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 runLoading = ref(false)
const runs = ref([]) const runs = ref([])
const spreadsheetUploadInput = ref(null) const spreadsheetUploadInput = ref(null)
@@ -1399,6 +1406,15 @@ export default {
const versionComparePayload = ref(null) const versionComparePayload = ref(null)
const compareBaseVersion = ref('') const compareBaseVersion = ref('')
const compareTargetVersion = 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({ const assetBuckets = ref({
financialRules: [], financialRules: [],
riskRules: [], riskRules: [],
@@ -1437,6 +1453,7 @@ export default {
const canSubmitReview = computed( const canSubmitReview = computed(
() => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value () => canEditSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
) )
const hasReviewSubmitReviewers = computed(() => reviewSubmitReviewerOptions.value.length > 0)
const canReviewSelected = computed( const canReviewSelected = computed(
() => canManageSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value () => canManageSelected.value && selectedSkillIsRule.value && isDisplayingWorkingVersion.value
) )
@@ -1471,11 +1488,11 @@ export default {
) )
const selectedSpreadsheetVersionModeLabel = computed(() => { const selectedSpreadsheetVersionModeLabel = computed(() => {
if (selectedSkill.value?.isPreviewMock) { if (selectedSkill.value?.isPreviewMock) {
return canEditSpreadsheetInline.value ? 'ONLYOFFICE 可编辑预览' : 'ONLYOFFICE 历史预览' return canEditSpreadsheetInline.value ? 'ONLYOFFICE 可编辑' : 'ONLYOFFICE 预览'
} }
return selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion return selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
? '当前工作版本' ? '在线可编辑'
: '历史预览版本' : '只读预览'
}) })
const selectedVersionTimelineItems = computed(() => const selectedVersionTimelineItems = computed(() =>
versionTimelineItems.value.map((item) => ({ versionTimelineItems.value.map((item) => ({
@@ -1484,8 +1501,34 @@ export default {
timeLabel: formatDateTime(item.event_time) timeLabel: formatDateTime(item.event_time)
})) }))
) )
const recentVersionTimelineItems = computed(() => const selectedSpreadsheetChangeRecords = computed(() => {
[...selectedVersionTimelineItems.value].slice(-3).reverse() 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(() => const versionCompareCellRows = computed(() =>
Array.isArray(versionComparePayload.value?.cell_changes) Array.isArray(versionComparePayload.value?.cell_changes)
@@ -1663,6 +1706,7 @@ export default {
) )
watch(activeType, () => { watch(activeType, () => {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor() destroySpreadsheetOnlyOfficeEditor()
selectedSkill.value = null selectedSkill.value = null
versionSwitchTarget.value = null versionSwitchTarget.value = null
@@ -1750,6 +1794,14 @@ export default {
} }
function destroySpreadsheetOnlyOfficeEditor() { 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) { if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) {
spreadsheetOnlyOfficeEditor.value.destroyEditor() spreadsheetOnlyOfficeEditor.value.destroyEditor()
} }
@@ -1757,43 +1809,281 @@ export default {
spreadsheetOnlyOfficeReady.value = false 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) { if (!selectedSkillUsesSpreadsheet.value || !selectedSkill.value?.id || selectedSkill.value?.loading) {
destroySpreadsheetOnlyOfficeEditor() destroySpreadsheetOnlyOfficeEditor()
return return
} }
const mountSeq = ++spreadsheetOnlyOfficeMountSeq
const assetId = selectedSkill.value.id
const version = selectedSkill.value.displayVersion
const editable = canEditSpreadsheetInline.value
spreadsheetOnlyOfficeLoading.value = true spreadsheetOnlyOfficeLoading.value = true
spreadsheetOnlyOfficeError.value = '' spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeReady.value = false spreadsheetOnlyOfficeReady.value = false
destroySpreadsheetOnlyOfficeEditor() destroySpreadsheetOnlyOfficeEditor()
try { try {
const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig( const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version)
selectedSkill.value.id, if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
selectedSkill.value.displayVersion return
) }
await loadOnlyOfficeApi(payload.documentServerUrl) await loadOnlyOfficeApi(payload.documentServerUrl)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
return
}
if (!window.DocsAPI?.DocEditor) { if (!window.DocsAPI?.DocEditor) {
throw new Error('ONLYOFFICE 编辑器未正确加载。') 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() await nextTick()
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
return
}
const config = buildOnlyOfficeEditorConfig(payload.config, { const config = buildOnlyOfficeEditorConfig(payload.config, {
viewportHeight: window.innerHeight, viewportHeight: window.innerHeight,
editable: canEditSpreadsheetInline.value, editable,
fillContainer: true fillContainer: true
}) })
const upstreamEvents = config.events || {} 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 = { config.events = {
...upstreamEvents, ...upstreamEvents,
onAppReady(event) { onAppReady(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
spreadsheetOnlyOfficeReady.value = true spreadsheetOnlyOfficeReady.value = true
spreadsheetOnlyOfficeLoading.value = false spreadsheetOnlyOfficeLoading.value = false
upstreamEvents.onAppReady?.(event) upstreamEvents.onAppReady?.(event)
}, },
onError(event) { onError(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
const errorCode = event?.data?.errorCode const errorCode = event?.data?.errorCode
const errorDescription = event?.data?.errorDescription const errorDescription = event?.data?.errorDescription
spreadsheetOnlyOfficeError.value = errorDescription spreadsheetOnlyOfficeError.value = errorDescription
@@ -1801,13 +2091,37 @@ export default {
: `ONLYOFFICE 加载失败${errorCode ? `(错误码 ${errorCode}` : '。'}` : `ONLYOFFICE 加载失败${errorCode ? `(错误码 ${errorCode}` : '。'}`
spreadsheetOnlyOfficeLoading.value = false spreadsheetOnlyOfficeLoading.value = false
upstreamEvents.onError?.(event) 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( spreadsheetOnlyOfficeEditor.value = new window.DocsAPI.DocEditor(
spreadsheetOnlyOfficeHostId.value, spreadsheetOnlyOfficeHostId.value,
config config
) )
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
destroySpreadsheetOnlyOfficeEditor()
}
} catch (error) { } catch (error) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
return
}
spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。' spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。'
spreadsheetOnlyOfficeLoading.value = false spreadsheetOnlyOfficeLoading.value = false
toast(spreadsheetOnlyOfficeError.value) toast(spreadsheetOnlyOfficeError.value)
@@ -1860,6 +2174,7 @@ export default {
}) })
await refreshCurrentAssets() await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id) await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSpreadsheetChangeRecords(selectedSkill.value.id)
toast(`已导入 ${file.name} 的表格内容,并生成新版本。`) toast(`已导入 ${file.name} 的表格内容,并生成新版本。`)
} catch (error) { } catch (error) {
toast(error?.message || '规则表内容导入失败,请稍后重试。') toast(error?.message || '规则表内容导入失败,请稍后重试。')
@@ -1959,6 +2274,9 @@ export default {
selectedSkill.value = buildDetailViewModel(detail, runs.value) selectedSkill.value = buildDetailViewModel(detail, runs.value)
if (selectedSkill.value?.type === 'rules') { if (selectedSkill.value?.type === 'rules') {
loadVersionTimeline(assetId, { silent: true }).catch(() => {}) loadVersionTimeline(assetId, { silent: true }).catch(() => {})
if (selectedSkill.value.usesSpreadsheetRule) {
loadSpreadsheetChangeRecords(assetId).catch(() => {})
}
} }
} catch (error) { } catch (error) {
detailError.value = error?.message || '资产详情加载失败,请稍后重试。' 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) { function openAssetDetail(asset) {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor() destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = '' spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false spreadsheetOnlyOfficeLoading.value = false
@@ -2009,6 +2351,7 @@ export default {
} }
function closeDetail() { function closeDetail() {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor() destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = '' spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false 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() { async function activateSelectedRule() {
if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) { if (!selectedSkill.value || !selectedSkillIsRule.value || !canManageSelected.value || detailBusy.value) {
return return
@@ -2241,7 +2669,7 @@ export default {
const payload = await fetchAgentAssetVersionTimeline(assetId) const payload = await fetchAgentAssetVersionTimeline(assetId)
versionTimelineItems.value = Array.isArray(payload) ? payload : [] versionTimelineItems.value = Array.isArray(payload) ? payload : []
} catch (error) { } catch (error) {
versionTimelineError.value = error?.message || '版本流转加载失败,请稍后重试。' versionTimelineError.value = error?.message || '操作记录加载失败,请稍后重试。'
if (!options.silent) { if (!options.silent) {
toast(versionTimelineError.value) toast(versionTimelineError.value)
} }
@@ -2280,6 +2708,23 @@ export default {
await loadVersionCompare() 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() { function closeVersionCompare() {
versionCompareOpen.value = false versionCompareOpen.value = false
} }
@@ -2312,6 +2757,7 @@ export default {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor() destroySpreadsheetOnlyOfficeEditor()
document.removeEventListener('click', handleDocumentClick) document.removeEventListener('click', handleDocumentClick)
}) })
@@ -2348,6 +2794,7 @@ export default {
canManageSelected, canManageSelected,
canEditSelected, canEditSelected,
canSubmitReview, canSubmitReview,
hasReviewSubmitReviewers,
canReviewSelected, canReviewSelected,
canEditMarkdown, canEditMarkdown,
canUploadSpreadsheet, canUploadSpreadsheet,
@@ -2360,9 +2807,14 @@ export default {
selectedSpreadsheetFileName, selectedSpreadsheetFileName,
selectedSpreadsheetVersionModeLabel, selectedSpreadsheetVersionModeLabel,
selectedVersionTimelineItems, selectedVersionTimelineItems,
recentVersionTimelineItems, selectedSpreadsheetChangeRecords,
detailBusy, detailBusy,
actionState, actionState,
reviewSubmitOpen,
reviewSubmitVersion,
reviewSubmitReviewer,
reviewSubmitReviewerLoading,
reviewSubmitReviewerOptions,
showReviewNote, showReviewNote,
spreadsheetUploadInput, spreadsheetUploadInput,
spreadsheetOnlyOfficeLoading, spreadsheetOnlyOfficeLoading,
@@ -2378,6 +2830,10 @@ export default {
versionComparePayload, versionComparePayload,
versionCompareCellRows, versionCompareCellRows,
versionCompareSheetRows, versionCompareSheetRows,
spreadsheetChangeDetailOpen,
selectedSpreadsheetChangeRecord,
selectedSpreadsheetChangeSheetRows,
selectedSpreadsheetChangeCellRows,
compareBaseVersion, compareBaseVersion,
compareTargetVersion, compareTargetVersion,
openAssetDetail, openAssetDetail,
@@ -2396,10 +2852,16 @@ export default {
downloadSpreadsheetFile, downloadSpreadsheetFile,
handleSpreadsheetFileInput, handleSpreadsheetFileInput,
reviewSelectedRule, reviewSelectedRule,
openSubmitReviewDialog,
closeSubmitReviewDialog,
submitSelectedRuleForReview,
activateSelectedRule, activateSelectedRule,
restoreSelectedVersion, restoreSelectedVersion,
openVersionTimeline, openVersionTimeline,
closeVersionTimeline, closeVersionTimeline,
openSpreadsheetChangeRecord,
openSpreadsheetChangeDetail,
closeSpreadsheetChangeDetail,
openVersionCompare, openVersionCompare,
closeVersionCompare, closeVersionCompare,
loadVersionCompare, loadVersionCompare,

View File

@@ -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)
}