4 Commits

Author SHA1 Message Date
caoxiaozhu
9902a3b968 test: 添加 OnlyOffice 回调摘要测试 2026-05-18 09:45:05 +00:00
caoxiaozhu
29df4eee3b test: 添加规则服务与电子表格变更记录的测试用例
- AgentAssetService 测试覆盖版本创建、送审、审核通过等流程
- 新增电子表格变更记录的前端测试
2026-05-18 09:44:04 +00:00
caoxiaozhu
5106d286a1 feat(agent_assets): 添加规则版本送审时的命名副本创建逻辑
当提交的版本与当前工作版本不同时,自动创建命名副本:
- 新增 _create_named_working_copy_for_review 方法处理送审时的版本复制
- 支持将工作版本快照复制为指定版本进行送审
- 新增 AgentAssetSpreadsheetChangeRecordRead schema
- API 端点新增 /rules/{id}/spreadsheet-versions/{version}/change-records 接口
2026-05-18 09:42:23 +00:00
caoxiaozhu
64ec27949f feat(AuditView): 删除差旅费报销规则详情的审核按钮并优化显示
- 删除详情中间区域的提交审核、审核通过、驳回版本三个按钮
- 删除底部 footer 的提交审核、审核通过、驳回版本、正式上线四个按钮
- 将下载 Excel 按钮改为下载表格
- 简化电子表格编辑器的 meta 信息显示
2026-05-18 09:39:41 +00:00
11 changed files with 1650 additions and 395 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

@@ -25,10 +25,12 @@ from app.schemas.agent_asset import (
AgentAssetReviewCreate, AgentAssetReviewCreate,
AgentAssetVersionCreate, AgentAssetVersionCreate,
) )
from app.api.deps import CurrentUserContext
from app.services.agent_assets import AgentAssetService from app.services.agent_assets import AgentAssetService
from app.services.agent_runs import AgentRunService from app.services.agent_runs import AgentRunService
from app.services.audit import AuditLogService from app.services.audit import AuditLogService
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService from app.services.expense_rule_runtime import ExpenseRuleRuntimeService
from app.services.settings import OnlyOfficeRuntimeConfig
def build_session() -> Session: def build_session() -> Session:
@@ -173,6 +175,36 @@ def test_rule_working_version_does_not_replace_published_version_until_activatio
assert detail.latest_review is None assert detail.latest_review is None
def test_pending_review_can_name_new_working_version_before_submission() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
item
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
if item.code == "rule.expense.travel_risk_control_standard"
)
review = service.create_review(
rule.id,
AgentAssetReviewCreate(
version="v1.2.0",
reviewer="manager_user",
review_status=AgentReviewStatus.PENDING,
review_note="请审核",
),
actor="finance_user",
)
detail = service.get_asset(rule.id)
assert review.version == "v1.2.0"
assert detail is not None
assert detail.current_version == "v1.2.0"
assert detail.working_version == "v1.2.0"
assert detail.published_version == "v1.1.0"
assert detail.latest_review is not None
assert detail.latest_review.reviewer == "manager_user"
def test_expense_rule_runtime_uses_published_version_instead_of_working_version() -> None: def test_expense_rule_runtime_uses_published_version_instead_of_working_version() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)
@@ -296,6 +328,78 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
live_path.write_bytes(original_live_bytes) live_path.write_bytes(original_live_bytes)
def test_spreadsheet_change_records_return_recent_edit_details() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
item
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
if item.code == "rule.expense.company_travel_expense_reimbursement"
)
service.audit_service.log_action(
actor="manager_user",
action="edit_rule_spreadsheet",
resource_type=rule.asset_type,
resource_id=rule.id,
after_json={
"summary": "在线编辑:共 1 处改动。",
"changed_sheet_count": 1,
"changed_cell_count": 1,
"sheet_changes": [],
"cell_changes": [
{
"sheet_name": "规则表",
"cell": "B2",
"change_type": "modified",
"before_value": 500,
"after_value": 550,
}
],
},
)
records = service.list_spreadsheet_change_records(rule.id)
assert len(records) == 1
assert records[0].actor == "manager_user"
assert records[0].changed_cell_count == 1
assert records[0].cell_changes[0].cell == "B2"
def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -> None:
with build_session() as db:
monkeypatch.setattr(
"app.services.agent_assets.resolve_onlyoffice_settings",
lambda: OnlyOfficeRuntimeConfig(
enabled=True,
public_url="http://onlyoffice.example.com",
backend_url="http://backend.example.com",
jwt_secret="secret",
),
)
service = AgentAssetService(db)
rule = next(
item
for item in service.list_assets(asset_type=AgentAssetType.RULE.value)
if item.code == "rule.expense.company_travel_expense_reimbursement"
)
config = service.build_rule_spreadsheet_onlyoffice_config(
rule.id,
CurrentUserContext(
username="finance_user",
name="财务人员",
role_codes=["finance"],
is_admin=False,
),
)
customization = config.config["editorConfig"]["customization"]
assert config.config["editorConfig"]["mode"] == "edit"
assert customization["forcesave"] is True
def test_version_timeline_contains_created_review_and_publish_events() -> None: def test_version_timeline_contains_created_review_and_publish_events() -> None:
with build_session() as db: with build_session() as db:
service = AgentAssetService(db) service = AgentAssetService(db)

View File

@@ -0,0 +1,78 @@
import pytest
from unittest.mock import MagicMock, patch
from io import BytesIO
from openpyxl import Workbook
from app.services.agent_assets import AgentAssetService
from app.schemas.agent_asset import AgentAssetOnlyOfficeCallbackWrite
def test_onlyoffice_callback_generates_summary_note():
# Setup mock DB and repository
db = MagicMock()
service = AgentAssetService(db)
service.repository = MagicMock()
service.spreadsheet_manager = MagicMock()
service._ensure_ready = MagicMock()
# Mock asset and metadata
asset = MagicMock()
asset.id = "test-asset"
asset.name = "测试规则"
service._require_spreadsheet_rule = MagicMock(return_value=asset)
service._resolve_working_version = MagicMock(return_value="v1")
base_meta = MagicMock()
base_meta.file_name = "test.xlsx"
base_meta.storage_key = "old-key"
base_meta.checksum = "old-checksum"
service._resolve_spreadsheet_version_meta = MagicMock(return_value=("v1", base_meta))
# Create base workbook
base_wb = Workbook()
base_ws = base_wb.active
base_ws["A1"] = "old value"
# Mock loading base workbook
service._load_spreadsheet_for_compare = MagicMock(return_value=base_wb)
service.spreadsheet_manager.resolve_storage_path = MagicMock()
# Create new content (modified)
new_wb = Workbook()
new_ws = new_wb.active
new_ws["A1"] = "new value" # 1 cell changed
new_ws["B2"] = "added" # 1 more cell changed
# Mock URL open to return new content
new_content_bio = BytesIO()
new_wb.save(new_content_bio)
new_content = new_content_bio.getvalue()
with patch("app.services.agent_assets.urlopen") as mock_urlopen:
mock_response = MagicMock()
mock_response.read.return_value = new_content
mock_response.__enter__.return_value = mock_response
mock_urlopen.return_value = mock_response
# Mock upload_rule_spreadsheet
service.upload_rule_spreadsheet = MagicMock()
# Execute callback handler
payload = {
"status": 2,
"url": "http://onlyoffice/download",
"users": ["test_user"]
}
service.handle_rule_spreadsheet_onlyoffice_callback(
"test-asset",
version="v1",
payload=payload
)
# Verify upload_rule_spreadsheet was called with correct change_note
service.upload_rule_spreadsheet.assert_called_once()
call_args = service.upload_rule_spreadsheet.call_args[1]
assert "涉及 1 个 Sheet共 2 处改动" in call_args["change_note"]
assert call_args["actor"] == "test_user"
if __name__ == "__main__":
pytest.main([__file__])

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

@@ -99,24 +99,6 @@
<span class="spreadsheet-mode-pill"> <span class="spreadsheet-mode-pill">
{{ selectedSpreadsheetVersionModeLabel }} {{ selectedSpreadsheetVersionModeLabel }}
</span> </span>
<button
class="mini-btn"
type="button"
:disabled="selectedSkill.isPreviewMock"
@click="openVersionCompare()"
>
<i class="mdi mdi-compare-horizontal"></i>
<span>版本对比</span>
</button>
<button
class="mini-btn"
type="button"
:disabled="selectedSkill.isPreviewMock"
@click="openVersionTimeline"
>
<i class="mdi mdi-timeline-clock-outline"></i>
<span>查看流转</span>
</button>
</div> </div>
</header> </header>
@@ -132,16 +114,13 @@
<section class="spreadsheet-main-stage"> <section class="spreadsheet-main-stage">
<div class="spreadsheet-editor-meta"> <div class="spreadsheet-editor-meta">
<span><strong>文件</strong>{{ selectedSpreadsheetFileName }}</span> <span><strong>文件</strong>{{ selectedSpreadsheetFileName }}</span>
<span><strong>线上版本</strong>{{ selectedSkill.publishedVersion }}</span>
<span><strong>工作版本</strong>{{ selectedSkill.workingVersion }}</span>
<span><strong>当前预览</strong>{{ selectedSkill.displayVersion }}</span>
<span><strong>审核状态</strong>{{ selectedSkill.reviewStatusLabel }}</span>
<span><strong>负责人</strong>{{ selectedSkill.owner }}</span> <span><strong>负责人</strong>{{ selectedSkill.owner }}</span>
<span><strong>最近更新</strong>{{ selectedSkill.updatedAt }}</span> <span><strong>最近更新</strong>{{ selectedSkill.updatedAt }}</span>
</div> </div>
<div class="spreadsheet-workbench"> <div class="spreadsheet-workbench">
<div <div
:key="spreadsheetOnlyOfficeHostId"
:id="spreadsheetOnlyOfficeHostId" :id="spreadsheetOnlyOfficeHostId"
class="rule-spreadsheet-host" class="rule-spreadsheet-host"
:class="{ hidden: !spreadsheetOnlyOfficeReady && !spreadsheetOnlyOfficeError }" :class="{ hidden: !spreadsheetOnlyOfficeReady && !spreadsheetOnlyOfficeError }"
@@ -160,94 +139,54 @@
<span> <span>
{{ {{
canEditSpreadsheetInline canEditSpreadsheetInline
? '当前版本可直接编辑;关闭编辑器并保存后,会自动生成新的规则版本快照。' ? '可直接在线编辑;保存后,右侧会自动记录本次修改内容。'
: '当前为历史版本预览或只读模式。' : '当前为只读预览模式。'
}} }}
</span> </span>
<span>最近版本说明{{ selectedSkill.displayVersionChangeNote }}</span> <span>右侧仅展示最近 30 次修改操作</span>
</footer> </footer>
</section> </section>
<aside class="spreadsheet-version-center"> <aside class="spreadsheet-version-center">
<header class="version-center-head"> <header class="version-center-head">
<div> <div>
<h3>版本中心</h3> <h3>最近修改</h3>
<p>一眼看清线上工作与最近流转</p> <p>展示最近 30 次在线编辑保存后的具体改动</p>
</div> </div>
</header> </header>
<div class="version-pair-grid">
<article class="version-pair-card published">
<span>线上版本</span>
<strong>{{ selectedSkill.publishedVersion }}</strong>
<b>正式生效</b>
</article>
<article class="version-pair-card working">
<span>工作版本</span>
<strong>{{ selectedSkill.workingVersion }}</strong>
<b>{{ selectedSkill.reviewStatusLabel }}</b>
</article>
</div>
<section class="version-center-section version-history-section"> <section class="version-center-section version-history-section">
<header> <div v-if="selectedSpreadsheetChangeRecords.length" class="version-center-list">
<strong>最近版本</strong> <button
<small>最近 5 </small> v-for="item in selectedSpreadsheetChangeRecords"
</header> :key="`spreadsheet-change-${item.changed_at}-${item.actor}`"
<div class="version-center-list"> type="button"
<article class="version-center-item change-record-item"
v-for="item in selectedSkill.history.slice(0, 5)" @click="openSpreadsheetChangeDetail(item)"
:key="`spreadsheet-version-${item.version}-${item.time}`"
class="version-center-item"
:class="{ active: item.version === selectedSkill.displayVersion }"
> >
<button type="button" @click="openVersionSwitch(item)"> <div class="change-record-head">
<div> <div>
<strong>{{ item.version }}</strong> <strong>{{ item.actor }}</strong>
<b :class="['version-state', item.lifecycleMeta.tone]"> <span>{{ item.time }}</span>
{{ item.lifecycleMeta.label }}
</b>
</div> </div>
<span>{{ item.time }}</span> <b>{{ item.changed_cell_count }} 处改动</b>
<p>{{ item.note }}</p>
</button>
<footer>
<button type="button" @click="openVersionSwitch(item)">查看</button>
<button
v-if="selectedSkill.publishedVersion && item.version !== selectedSkill.publishedVersion"
type="button"
@click="openVersionCompare({ baseVersion: selectedSkill.publishedVersion, targetVersion: item.version })"
>
与线上比
</button>
<button
v-if="canManageSelected && !item.isWorking"
type="button"
@click="restoreSelectedVersion(item.version)"
>
恢复
</button>
</footer>
</article>
</div>
</section>
<section class="version-center-section compact">
<header>
<strong>最近流转</strong>
<button type="button" @click="openVersionTimeline">查看完整流转</button>
</header>
<div v-if="recentVersionTimelineItems.length" class="version-flow-preview">
<article v-for="item in recentVersionTimelineItems" :key="`${item.event_type}-${item.version}-${item.event_time}`">
<i :class="item.meta.icon"></i>
<div>
<strong>{{ item.meta.label }}</strong>
<span>{{ item.version }} · {{ item.actor }}</span>
</div> </div>
<small>{{ item.timeLabel }}</small> <p>{{ item.summary }}</p>
</article> <div v-if="item.previewChanges.length" class="change-record-preview">
<span
v-for="change in item.previewChanges"
:key="`${change.sheet_name}-${change.cell}`"
>
{{ change.sheet_name }}!{{ change.cell }}
{{ change.before_value ?? '-' }} {{ change.after_value ?? '-' }}
</span>
</div>
<small v-if="item.remainingChangeCount" class="change-record-more">
另有 {{ item.remainingChangeCount }} 处改动
</small>
</button>
</div> </div>
<p v-else class="version-flow-empty">暂无版本流转记录</p> <p v-else class="version-flow-empty">暂无修改记录</p>
</section> </section>
</aside> </aside>
</div> </div>
@@ -441,40 +380,6 @@
</div> </div>
</div> </div>
</div> </div>
<div v-if="selectedSkillIsRule" class="review-action-strip">
<button
class="minor-action"
type="button"
:disabled="!canSubmitReview || detailBusy"
@click="reviewSelectedRule('pending')"
>
<i class="mdi mdi-send-outline"></i>
<span>{{ actionState === 'review-pending' ? '提交中...' : '提交审核' }}</span>
</button>
<button
class="minor-action success-action"
type="button"
:disabled="!canReviewSelected || detailBusy"
@click="reviewSelectedRule('approved')"
>
<i class="mdi mdi-check-decagram-outline"></i>
<span>{{ actionState === 'review-approved' ? '处理中...' : '审核通过' }}</span>
</button>
<button
class="minor-action danger-action"
type="button"
:disabled="!canReviewSelected || detailBusy"
@click="reviewSelectedRule('rejected')"
>
<i class="mdi mdi-close-octagon-outline"></i>
<span>{{ actionState === 'review-rejected' ? '处理中...' : '驳回版本' }}</span>
</button>
</div>
<p v-if="selectedSkillIsRule && activateBlockedReason" class="action-help">
{{ activateBlockedReason }}
</p>
</article> </article>
</section> </section>
@@ -613,7 +518,7 @@
@click="downloadSpreadsheetFile" @click="downloadSpreadsheetFile"
> >
<i class="mdi mdi-file-download-outline"></i> <i class="mdi mdi-file-download-outline"></i>
<span>{{ actionState === 'download-spreadsheet' ? '下载中...' : '下载 Excel' }}</span> <span>{{ actionState === 'download-spreadsheet' ? '下载中...' : '下载表格' }}</span>
</button> </button>
<button <button
v-if="selectedSkill.usesSpreadsheetRule" v-if="selectedSkill.usesSpreadsheetRule"
@@ -625,7 +530,7 @@
<i class="mdi mdi-file-upload-outline"></i> <i class="mdi mdi-file-upload-outline"></i>
<span>{{ actionState === 'upload-spreadsheet' ? '导入中...' : '上传表格' }}</span> <span>{{ actionState === 'upload-spreadsheet' ? '导入中...' : '上传表格' }}</span>
</button> </button>
<button <button
v-else v-else
class="minor-action" class="minor-action"
type="button" type="button"
@@ -635,43 +540,6 @@
<i class="mdi mdi-content-save-outline"></i> <i class="mdi mdi-content-save-outline"></i>
<span>{{ actionState === 'save-markdown' ? '保存中...' : '保存 Markdown' }}</span> <span>{{ actionState === 'save-markdown' ? '保存中...' : '保存 Markdown' }}</span>
</button> </button>
<button
class="minor-action"
type="button"
:disabled="!canSubmitReview || detailBusy"
@click="reviewSelectedRule('pending')"
>
<i class="mdi mdi-send-outline"></i>
<span>{{ actionState === 'review-pending' ? '提交中...' : '提交审核' }}</span>
</button>
<button
class="minor-action success-action"
type="button"
:disabled="!canReviewSelected || detailBusy"
@click="reviewSelectedRule('approved')"
>
<i class="mdi mdi-check-decagram-outline"></i>
<span>{{ actionState === 'review-approved' ? '处理中...' : '审核通过' }}</span>
</button>
<button
class="minor-action danger-action"
type="button"
:disabled="!canReviewSelected || detailBusy"
@click="reviewSelectedRule('rejected')"
>
<i class="mdi mdi-close-octagon-outline"></i>
<span>{{ actionState === 'review-rejected' ? '处理中...' : '驳回版本' }}</span>
</button>
<button
class="major-action"
type="button"
:disabled="!canActivateSelected"
:title="activateBlockedReason"
@click="activateSelectedRule"
>
<i class="mdi mdi-rocket-launch-outline"></i>
<span>{{ actionState === 'activate' ? '上线中...' : selectedSkill.statusValue === 'active' ? '已上线' : '正式上线' }}</span>
</button>
</div> </div>
<div v-else class="detail-action-group detail-meta-actions"> <div v-else class="detail-action-group detail-meta-actions">
@@ -946,13 +814,62 @@
</div> </div>
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog
:open="reviewSubmitOpen"
badge="提交审核"
badge-tone="info"
title="提交规则版本审核"
description="请先确认本次送审采用的版本号,并选择负责审核的高级管理员。若填写新的版本号,系统会将当前工作稿固化为该版本后再送审。"
cancel-text="取消"
confirm-text="确认提交"
busy-text="提交中..."
confirm-tone="primary"
confirm-icon="mdi mdi-send-outline"
:busy="actionState === 'review-pending'"
@close="closeSubmitReviewDialog"
@confirm="submitSelectedRuleForReview"
>
<div class="review-submit-form">
<label>
<span>送审版本号</span>
<input
v-model="reviewSubmitVersion"
type="text"
placeholder="例如v1.1.0"
:disabled="actionState === 'review-pending'"
/>
</label>
<label>
<span>审核人</span>
<select
v-model="reviewSubmitReviewer"
:disabled="reviewSubmitReviewerLoading || actionState === 'review-pending'"
>
<option value="" disabled>
{{ reviewSubmitReviewerLoading ? '加载审核人中...' : '请选择高级管理员' }}
</option>
<option
v-for="item in reviewSubmitReviewerOptions"
:key="item.value"
:value="item.value"
>
{{ item.label }}
</option>
</select>
</label>
<p v-if="!reviewSubmitReviewerLoading && !hasReviewSubmitReviewers" class="review-submit-hint">
当前没有可选的高级管理员请先在员工管理中配置具备管理员角色的员工
</p>
</div>
</ConfirmDialog>
<Transition name="drawer-fade"> <Transition name="drawer-fade">
<div v-if="versionTimelineOpen" class="rule-drawer-backdrop" @click.self="closeVersionTimeline"> <div v-if="versionTimelineOpen" class="rule-drawer-backdrop" @click.self="closeVersionTimeline">
<aside class="rule-drawer timeline-drawer"> <aside class="rule-drawer timeline-drawer">
<header class="rule-drawer-head"> <header class="rule-drawer-head">
<div> <div>
<span>版本治理</span> <span>修改记录</span>
<h3>版本流转时间线</h3> <h3>文档操作记录</h3>
</div> </div>
<button type="button" @click="closeVersionTimeline"> <button type="button" @click="closeVersionTimeline">
<i class="mdi mdi-close"></i> <i class="mdi mdi-close"></i>
@@ -961,7 +878,7 @@
<div v-if="versionTimelineLoading" class="rule-drawer-state"> <div v-if="versionTimelineLoading" class="rule-drawer-state">
<i class="mdi mdi-loading mdi-spin"></i> <i class="mdi mdi-loading mdi-spin"></i>
<span>正在加载版本流转...</span> <span>正在加载操作记录...</span>
</div> </div>
<div v-else-if="versionTimelineError" class="rule-drawer-state error"> <div v-else-if="versionTimelineError" class="rule-drawer-state error">
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
@@ -977,12 +894,12 @@
<div> <div>
<header> <header>
<strong>{{ item.meta.label }}</strong> <strong>{{ item.meta.label }}</strong>
<b>{{ item.version }}</b>
<span>{{ item.timeLabel }}</span> <span>{{ item.timeLabel }}</span>
</header> </header>
<p>{{ item.description || item.note || '暂无补充说明' }}</p> <p>{{ item.description || item.note || '暂无补充说明' }}</p>
<small> <small>
操作人{{ item.actor }} 操作人{{ item.actor }}
<template v-if="item.version"> · 关联版本{{ item.version }}</template>
<template v-if="item.source_version"> · 来源版本{{ item.source_version }}</template> <template v-if="item.source_version"> · 来源版本{{ item.source_version }}</template>
</small> </small>
</div> </div>
@@ -990,7 +907,104 @@
</div> </div>
<div v-else class="rule-drawer-state"> <div v-else class="rule-drawer-state">
<i class="mdi mdi-history"></i> <i class="mdi mdi-history"></i>
<span>暂无版本流转记录</span> <span>暂无操作记录</span>
</div>
</aside>
</div>
</Transition>
<Transition name="drawer-fade">
<div
v-if="spreadsheetChangeDetailOpen"
class="rule-drawer-backdrop"
@click.self="closeSpreadsheetChangeDetail"
>
<aside class="rule-drawer compare-drawer change-detail-drawer">
<header class="rule-drawer-head">
<div>
<span>最近修改</span>
<h3>修改详情</h3>
</div>
<button type="button" @click="closeSpreadsheetChangeDetail">
<i class="mdi mdi-close"></i>
</button>
</header>
<div v-if="selectedSpreadsheetChangeRecord" class="compare-content change-detail-content">
<section class="change-detail-meta">
<article>
<span>修改人</span>
<strong>{{ selectedSpreadsheetChangeRecord.actor }}</strong>
</article>
<article>
<span>修改时间</span>
<strong>{{ selectedSpreadsheetChangeRecord.time }}</strong>
</article>
<article>
<span>修改工作表</span>
<strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong>
</article>
<article>
<span>变更单元格</span>
<strong>{{ selectedSpreadsheetChangeRecord.changed_cell_count }}</strong>
</article>
</section>
<section class="compare-panel">
<header>
<strong>本次修改摘要</strong>
</header>
<p>{{ selectedSpreadsheetChangeRecord.summary }}</p>
</section>
<section class="compare-panel">
<header>
<strong>工作表变化</strong>
</header>
<div v-if="selectedSpreadsheetChangeSheetRows.length" class="compare-sheet-list">
<span
v-for="item in selectedSpreadsheetChangeSheetRows"
:key="`${item.sheet_name}-${item.change_type}`"
:class="item.meta.tone"
>
{{ item.sheet_name }} · {{ item.meta.label }}
</span>
</div>
<p v-else>本次没有新增或删除工作表</p>
</section>
<section class="compare-panel compare-cell-panel">
<header>
<strong>单元格差异</strong>
<small>最多展示前 500 </small>
</header>
<div v-if="selectedSpreadsheetChangeCellRows.length" class="compare-table-wrap">
<table>
<thead>
<tr>
<th>工作表</th>
<th>位置</th>
<th>类型</th>
<th>旧值</th>
<th>新值</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in selectedSpreadsheetChangeCellRows"
:key="`${item.sheet_name}-${item.cell}`"
>
<td>{{ item.sheet_name }}</td>
<td>{{ item.cell }}</td>
<td><b :class="item.meta.tone">{{ item.meta.label }}</b></td>
<td>{{ item.before_value ?? '-' }}</td>
<td>{{ item.after_value ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else>本次没有发现单元格级差异</p>
</section>
</div> </div>
</aside> </aside>
</div> </div>
@@ -1045,7 +1059,7 @@
<i class="mdi mdi-alert-circle-outline"></i> <i class="mdi mdi-alert-circle-outline"></i>
<span>{{ versionCompareError }}</span> <span>{{ versionCompareError }}</span>
</div> </div>
<template v-else-if="versionComparePayload"> <div v-else-if="versionComparePayload" class="compare-content">
<section class="compare-summary-grid"> <section class="compare-summary-grid">
<article> <article>
<span>新增工作表</span> <span>新增工作表</span>
@@ -1081,7 +1095,7 @@
<p v-else>没有新增或删除工作表</p> <p v-else>没有新增或删除工作表</p>
</section> </section>
<section class="compare-panel"> <section class="compare-panel compare-cell-panel">
<header> <header>
<strong>单元格差异</strong> <strong>单元格差异</strong>
<small>最多展示前 500 </small> <small>最多展示前 500 </small>
@@ -1113,7 +1127,7 @@
</div> </div>
<p v-else>两个版本内容一致没有发现单元格级差异</p> <p v-else>两个版本内容一致没有发现单元格级差异</p>
</section> </section>
</template> </div>
</aside> </aside>
</div> </div>
</Transition> </Transition>

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

View File

@@ -0,0 +1,92 @@
import assert from 'node:assert/strict'
import {
buildSpreadsheetChangeRecords,
resolveSpreadsheetOperationLabel
} from '../src/views/scripts/spreadsheetChangeRecords.js'
function testResolveSpreadsheetOperationLabelFromHistorySource() {
assert.equal(
resolveSpreadsheetOperationLabel({
spreadsheetMeta: { source: 'onlyoffice' },
note: 'ONLYOFFICE 编辑保存规则表。'
}),
'在线编辑保存'
)
assert.equal(
resolveSpreadsheetOperationLabel({
spreadsheetMeta: { source: 'content-import' }
}),
'导入表格'
)
}
function testBuildSpreadsheetChangeRecordsPrefersUnpublishedHistory() {
const records = buildSpreadsheetChangeRecords({
publishedVersion: 'v1.0.0',
history: [
{
version: 'v1.0.1',
note: 'ONLYOFFICE 编辑保存规则表。',
createdBy: '张三',
createdAt: '2026-05-18T10:00:00Z',
isWorking: true,
spreadsheetMeta: { source: 'onlyoffice' }
},
{
version: 'v1.0.0',
note: '首个上线版本',
createdBy: '系统',
createdAt: '2026-05-18T09:00:00Z',
isWorking: false,
spreadsheetMeta: { source: 'upload' }
}
]
})
assert.deepEqual(
records.map((item) => [item.version, item.operationLabel]),
[['v1.0.1', '在线编辑保存']]
)
}
function testBuildSpreadsheetChangeRecordsKeepsPendingLocalEditFirst() {
const records = buildSpreadsheetChangeRecords({
publishedVersion: 'v1.0.0',
localRecords: [
{
version: 'v1.0.1',
operationLabel: '编辑中',
operationActor: '李四',
note: '检测到未保存的表格改动,保存后会生成新版本并可查看差异。',
time: '2026-05-18T10:30:00Z',
isPendingLocalEdit: true,
disabledReason: '当前是本地未保存修改,保存后才会生成可对比的版本。'
}
],
history: [
{
version: 'v1.0.1',
note: 'ONLYOFFICE 编辑保存规则表。',
createdBy: '李四',
createdAt: '2026-05-18T10:00:00Z',
isWorking: true,
spreadsheetMeta: { source: 'onlyoffice' }
}
]
})
assert.equal(records[0].operationLabel, '编辑中')
assert.equal(records[0].isPendingLocalEdit, true)
assert.equal(records[1].operationLabel, '在线编辑保存')
}
function run() {
testResolveSpreadsheetOperationLabelFromHistorySource()
testBuildSpreadsheetChangeRecordsPrefersUnpublishedHistory()
testBuildSpreadsheetChangeRecordsKeepsPendingLocalEditFirst()
console.log('spreadsheet change record tests passed')
}
run()