refactor: 重构 AuditView 和 TravelReimbursementCreateView 相关代码

- 优化 agent_assets、agent_foundation、user_agent 服务层结构
- 更新 AuditView 视图和脚本
- 更新 TravelReimbursementCreateView 脚本

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
caoxiaozhu
2026-05-19 20:23:58 +08:00
parent dc007f948a
commit 9472813739
7 changed files with 4287 additions and 2204 deletions

View File

@@ -1,6 +1,7 @@
from __future__ import annotations from __future__ import annotations
import json import json
from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from pathlib import Path from pathlib import Path
@@ -30,6 +31,8 @@ from app.schemas.agent_asset import (
AgentAssetRead, AgentAssetRead,
AgentAssetReviewCreate, AgentAssetReviewCreate,
AgentAssetReviewRead, AgentAssetReviewRead,
AgentAssetRuleJsonRead,
AgentAssetRuleJsonWrite,
AgentAssetSpreadsheetChangeRecordRead, AgentAssetSpreadsheetChangeRecordRead,
AgentAssetSpreadsheetDiffCellRead, AgentAssetSpreadsheetDiffCellRead,
AgentAssetSpreadsheetDiffSheetRead, AgentAssetSpreadsheetDiffSheetRead,
@@ -39,9 +42,15 @@ from app.schemas.agent_asset import (
AgentAssetVersionRead, AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead, AgentAssetVersionTimelineItemRead,
) )
from app.services.agent_asset_rule_library import AgentAssetRuleLibraryManager
from app.services.agent_asset_spreadsheet import ( from app.services.agent_asset_spreadsheet import (
AgentAssetSpreadsheetManager, AgentAssetSpreadsheetManager,
COMPANY_COMMUNICATION_EXPENSE_RULE_CODE,
COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME,
COMPANY_TRAVEL_EXPENSE_RULE_CODE,
COMPANY_TRAVEL_EXPENSE_RULE_FILENAME, COMPANY_TRAVEL_EXPENSE_RULE_FILENAME,
FINANCE_RULES_LIBRARY,
RISK_RULES_LIBRARY,
RULE_LIBRARY_NAMES, RULE_LIBRARY_NAMES,
RuleSpreadsheetMeta, RuleSpreadsheetMeta,
SPREADSHEET_MIME_TYPE, SPREADSHEET_MIME_TYPE,
@@ -74,6 +83,7 @@ class AgentAssetService:
self.repository = AgentAssetRepository(db) self.repository = AgentAssetRepository(db)
self.audit_service = AuditLogService(db) self.audit_service = AuditLogService(db)
self.spreadsheet_manager = AgentAssetSpreadsheetManager() self.spreadsheet_manager = AgentAssetSpreadsheetManager()
self.rule_library_manager = AgentAssetRuleLibraryManager()
def list_assets( def list_assets(
self, self,
@@ -84,10 +94,16 @@ class AgentAssetService:
keyword: str | None = None, keyword: str | None = None,
) -> list[AgentAssetListItem]: ) -> list[AgentAssetListItem]:
self._ensure_ready() self._ensure_ready()
items = self.repository.list( if asset_type in {None, "", AgentAssetType.RULE.value}:
self.sync_platform_risk_rules_from_library()
assets = self.repository.list(
asset_type=asset_type, status=status, domain=domain, keyword=keyword asset_type=asset_type, status=status, domain=domain, keyword=keyword
) )
return [AgentAssetListItem.model_validate(item) for item in items] version_stats = self._collect_version_stats(assets)
return [
self._serialize_list_item(asset, version_stats.get(asset.id))
for asset in assets
]
def get_asset(self, asset_id: str) -> AgentAssetRead | None: def get_asset(self, asset_id: str) -> AgentAssetRead | None:
self._ensure_ready() self._ensure_ready()
@@ -110,8 +126,9 @@ class AgentAssetService:
if working_version if working_version
else None else None
) )
version_stats = self._collect_version_stats([asset]).get(asset.id)
return AgentAssetRead( return AgentAssetRead(
**AgentAssetListItem.model_validate(asset).model_dump(), **self._serialize_list_item(asset, version_stats).model_dump(),
current_version_content=self._deserialize_content(current_version) current_version_content=self._deserialize_content(current_version)
if current_version if current_version
else None, else None,
@@ -500,8 +517,8 @@ class AgentAssetService:
) )
asset = self._require_spreadsheet_rule(asset_id) asset = self._require_spreadsheet_rule(asset_id)
resolved_version, metadata = self._resolve_spreadsheet_version_meta(asset, version=version) resolved_version, metadata = self._resolve_current_spreadsheet_meta(asset)
editable = self._can_edit_spreadsheet_version(asset, current_user, resolved_version) editable = self._can_edit_current_spreadsheet(current_user)
return self._build_onlyoffice_spreadsheet_config( return self._build_onlyoffice_spreadsheet_config(
asset_id=asset.id, asset_id=asset.id,
current_user=current_user, current_user=current_user,
@@ -525,7 +542,7 @@ class AgentAssetService:
return file_path, metadata.mime_type, metadata.file_name return file_path, metadata.mime_type, metadata.file_name
asset = self._require_spreadsheet_rule(asset_id) asset = self._require_spreadsheet_rule(asset_id)
_, metadata = self._resolve_spreadsheet_version_meta(asset, version=version) _, metadata = self._resolve_current_spreadsheet_meta(asset)
file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key) file_path = self.spreadsheet_manager.resolve_storage_path(metadata.storage_key)
if not file_path.exists(): if not file_path.exists():
raise FileNotFoundError(metadata.file_name) raise FileNotFoundError(metadata.file_name)
@@ -575,58 +592,31 @@ class AgentAssetService:
if not content: if not content:
raise ValueError("规则表文件内容不能为空。") raise ValueError("规则表文件内容不能为空。")
next_version = self._increment_version(self._resolve_working_version(asset)) _, current_metadata = self._resolve_current_spreadsheet_meta(asset)
metadata = self.spreadsheet_manager.store_spreadsheet( file_name = current_metadata.file_name or self._resolve_default_spreadsheet_file_name(asset)
asset_id=asset.id, metadata = self._store_current_rule_spreadsheet(
version=next_version, asset,
file_name=normalized_name, file_name=file_name,
content=content, content=content,
actor_name=actor, actor=actor,
source=source, source=source,
) )
markdown = self.spreadsheet_manager.build_version_markdown( self.audit_service.log_action(
rule_name=asset.name,
version=next_version,
metadata=metadata,
)
self.create_version(
asset.id,
AgentAssetVersionCreate(
version=next_version,
content=markdown,
content_type=AgentAssetContentType.MARKDOWN,
change_note=change_note or f"上传 Excel 规则表:{normalized_name}",
created_by=actor,
),
actor=actor, actor=actor,
action="edit_rule_spreadsheet",
resource_type=asset.asset_type,
resource_id=asset.id,
before_json={"storage_key": current_metadata.storage_key},
after_json={
"summary": change_note or f"上传并覆盖当前规则表:{normalized_name}",
"changed_sheet_count": 0,
"changed_cell_count": 0,
"sheet_changes": [],
"cell_changes": [],
"storage_key": metadata.storage_key,
},
request_id=request_id, request_id=request_id,
) )
refreshed = self.repository.get(asset.id)
if refreshed is None:
raise LookupError("Asset not found")
config_json = dict(refreshed.config_json or {})
config_json["detail_mode"] = "spreadsheet"
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
current_document_meta = metadata
rule_library = str(config_json.get("rule_library") or "").strip()
if rule_library in RULE_LIBRARY_NAMES:
current_document_meta = self.spreadsheet_manager.store_rule_library_spreadsheet(
library=rule_library,
file_name=normalized_name,
content=content,
actor_name=actor,
source=source,
)
rule_document = self.spreadsheet_manager.build_rule_document_config(
current_document_meta,
asset_version=next_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 self.get_asset(asset.id) # type: ignore[return-value] return self.get_asset(asset.id) # type: ignore[return-value]
def import_rule_spreadsheet_content( def import_rule_spreadsheet_content(
@@ -646,10 +636,7 @@ class AgentAssetService:
if Path(normalized_name).suffix.lower() != ".xlsx": if Path(normalized_name).suffix.lower() != ".xlsx":
raise ValueError("当前仅支持导入 .xlsx 格式的规则表。") raise ValueError("当前仅支持导入 .xlsx 格式的规则表。")
_, current_metadata = self._resolve_spreadsheet_version_meta( _, current_metadata = self._resolve_current_spreadsheet_meta(asset)
asset,
version=self._resolve_working_version(asset),
)
imported_content = self.spreadsheet_manager.rebuild_from_uploaded_content(content) imported_content = self.spreadsheet_manager.rebuild_from_uploaded_content(content)
return self.upload_rule_spreadsheet( return self.upload_rule_spreadsheet(
asset.id, asset.id,
@@ -681,10 +668,10 @@ class AgentAssetService:
callback = self._parse_onlyoffice_callback(payload) callback = self._parse_onlyoffice_callback(payload)
if callback.status not in {2, 6} or not callback.download_url: if callback.status not in {2, 6} or not callback.download_url:
return return
if self._resolve_working_version(asset) != str(version or "").strip(): if str(version or "").strip() not in {"", "current", self._resolve_working_version(asset)}:
return return
_, current_metadata = self._resolve_spreadsheet_version_meta(asset, version=version) _, current_metadata = self._resolve_current_spreadsheet_meta(asset)
request = Request( request = Request(
callback.download_url, callback.download_url,
headers={"User-Agent": "x-financial-onlyoffice-agent-asset"}, headers={"User-Agent": "x-financial-onlyoffice-agent-asset"},
@@ -723,12 +710,11 @@ class AgentAssetService:
resolved_actor_name = str(actor_name or "").strip() or ( resolved_actor_name = str(actor_name or "").strip() or (
callback.users[0] if callback.users else "ONLYOFFICE" callback.users[0] if callback.users else "ONLYOFFICE"
) )
self.upload_rule_spreadsheet( self._store_current_rule_spreadsheet(
asset.id, asset,
filename=current_metadata.file_name, file_name=current_metadata.file_name,
content=content, content=content,
actor=resolved_actor_name, actor=resolved_actor_name,
change_note=change_note,
source="onlyoffice", source="onlyoffice",
) )
if changed_sheet_count > 0 or changed_cell_count > 0: if changed_sheet_count > 0 or changed_cell_count > 0:
@@ -737,7 +723,7 @@ class AgentAssetService:
action="edit_rule_spreadsheet", action="edit_rule_spreadsheet",
resource_type=asset.asset_type, resource_type=asset.asset_type,
resource_id=asset.id, resource_id=asset.id,
before_json={"version": version}, before_json={"storage_key": current_metadata.storage_key},
after_json={ after_json={
"summary": change_note, "summary": change_note,
"changed_sheet_count": changed_sheet_count, "changed_sheet_count": changed_sheet_count,
@@ -750,6 +736,11 @@ class AgentAssetService:
def _ensure_ready(self) -> None: def _ensure_ready(self) -> None:
AgentFoundationService(self.db).ensure_foundation_ready() AgentFoundationService(self.db).ensure_foundation_ready()
def sync_platform_risk_rules_from_library(self) -> int:
manifest_count = AgentFoundationService(self.db).sync_platform_risk_rules_from_library()
self.db.commit()
return manifest_count
def _validate_version_payload( def _validate_version_payload(
self, asset: AgentAsset, payload: AgentAssetVersionCreate self, asset: AgentAsset, payload: AgentAssetVersionCreate
) -> None: ) -> None:
@@ -1003,6 +994,7 @@ class AgentAssetService:
) )
return [ return [
AgentAssetSpreadsheetChangeRecordRead( AgentAssetSpreadsheetChangeRecordRead(
id=log.id,
actor=log.actor, actor=log.actor,
changed_at=log.created_at, changed_at=log.created_at,
summary=str((log.after_json or {}).get("summary") or "ONLYOFFICE 在线编辑保存。"), summary=str((log.after_json or {}).get("summary") or "ONLYOFFICE 在线编辑保存。"),
@@ -1046,6 +1038,80 @@ class AgentAssetService:
), ),
) )
def _collect_version_stats(
self, assets: list[AgentAsset]
) -> dict[str, dict[str, int | str | None]]:
asset_ids = [item.id for item in assets]
versions = self.repository.list_versions_for_assets(asset_ids)
spreadsheet_logs = self.audit_service.repository.list_for_resources(
resource_type=AgentAssetType.RULE.value,
resource_ids=[
item.id
for item in assets
if item.asset_type == AgentAssetType.RULE.value
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
== "spreadsheet"
],
action="edit_rule_spreadsheet",
)
working_versions = {
item.id: self._resolve_working_version(item) for item in assets
}
version_counts: dict[str, int] = defaultdict(int)
modified_by: dict[str, str | None] = {item.id: None for item in assets}
spreadsheet_edit_counts: dict[str, int] = defaultdict(int)
spreadsheet_last_actor: dict[str, str | None] = {}
spreadsheet_last_changed_at: dict[str, datetime] = {}
for version in versions:
version_counts[version.asset_id] += 1
if (
modified_by.get(version.asset_id) is None
and version.version == working_versions.get(version.asset_id)
):
modified_by[version.asset_id] = version.created_by
for log in spreadsheet_logs:
spreadsheet_edit_counts[log.resource_id] += 1
last_changed_at = spreadsheet_last_changed_at.get(log.resource_id)
if last_changed_at is None or log.created_at >= last_changed_at:
spreadsheet_last_changed_at[log.resource_id] = log.created_at
spreadsheet_last_actor[log.resource_id] = log.actor
return {
item.id: {
"change_count": (
spreadsheet_edit_counts.get(item.id, 0)
if item.asset_type == AgentAssetType.RULE.value
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
== "spreadsheet"
and spreadsheet_edit_counts.get(item.id, 0) > 0
else max(version_counts.get(item.id, 0) - 1, 0)
),
"modified_by": (
spreadsheet_last_actor.get(item.id)
if item.asset_type == AgentAssetType.RULE.value
and str((item.config_json or {}).get("detail_mode") or "").strip().lower()
== "spreadsheet"
and spreadsheet_last_actor.get(item.id)
else modified_by.get(item.id)
),
}
for item in assets
}
@staticmethod
def _serialize_list_item(
asset: AgentAsset,
version_stats: dict[str, int | str | None] | None = None,
) -> AgentAssetListItem:
payload = AgentAssetListItem.model_validate(asset).model_dump()
payload["change_count"] = int((version_stats or {}).get("change_count") or 0)
payload["modified_by"] = (
str((version_stats or {}).get("modified_by") or "").strip() or None
)
return AgentAssetListItem.model_validate(payload)
@staticmethod @staticmethod
def _sort_versions( def _sort_versions(
versions: list[AgentAssetVersion], current_version: str | None versions: list[AgentAssetVersion], current_version: str | None
@@ -1104,6 +1170,138 @@ class AgentAssetService:
raise FileNotFoundError("规则表版本快照不存在。") raise FileNotFoundError("规则表版本快照不存在。")
return resolved_version, metadata return resolved_version, metadata
def _resolve_current_spreadsheet_meta(
self,
asset: AgentAsset,
) -> tuple[str, RuleSpreadsheetMeta]:
config_json = dict(asset.config_json or {})
current_meta = self._read_current_rule_document_meta(asset)
file_name = (
current_meta.file_name
if current_meta is not None and current_meta.file_name
else self._resolve_default_spreadsheet_file_name(asset)
)
library = self._resolve_spreadsheet_rule_library(asset)
storage_key = (Path("rules") / library / file_name).as_posix()
file_path = self.spreadsheet_manager.resolve_storage_path(storage_key)
if not file_path.exists():
content: bytes | None = None
if current_meta is not None and current_meta.storage_key:
try:
legacy_path = self.spreadsheet_manager.resolve_storage_path(
current_meta.storage_key
)
except FileNotFoundError:
legacy_path = None
if legacy_path is not None and legacy_path.exists():
content = legacy_path.read_bytes()
if content is None:
content = AgentAssetSpreadsheetManager.build_blank_rule_workbook(
Path(file_name).stem or "规则表"
)
meta = self.spreadsheet_manager.store_rule_library_spreadsheet(
library=library,
file_name=file_name,
content=content,
actor_name=(
current_meta.updated_by
if current_meta is not None and current_meta.updated_by
else "system"
),
source="current-rule",
)
else:
content = file_path.read_bytes()
meta = RuleSpreadsheetMeta(
file_name=file_name,
storage_key=storage_key,
mime_type=(
current_meta.mime_type
if current_meta is not None and current_meta.mime_type
else SPREADSHEET_MIME_TYPE
),
size_bytes=file_path.stat().st_size,
checksum=self._hash_bytes(content),
updated_at=datetime.fromtimestamp(file_path.stat().st_mtime, UTC).isoformat(),
updated_by=(
current_meta.updated_by
if current_meta is not None and current_meta.updated_by
else "system"
),
source=(
current_meta.source
if current_meta is not None and current_meta.source
else "current-rule"
),
)
expected_document = {
**self.spreadsheet_manager.build_rule_document_config(
meta,
asset_version="current",
),
"storage_key": meta.storage_key,
}
if config_json.get("rule_document") != expected_document:
config_json["detail_mode"] = "spreadsheet"
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
config_json["rule_library"] = library
config_json["rule_document"] = expected_document
asset.config_json = config_json
self.repository.save_asset(asset)
return "current", meta
def _store_current_rule_spreadsheet(
self,
asset: AgentAsset,
*,
file_name: str,
content: bytes,
actor: str,
source: str,
) -> RuleSpreadsheetMeta:
library = self._resolve_spreadsheet_rule_library(asset)
metadata = self.spreadsheet_manager.store_rule_library_spreadsheet(
library=library,
file_name=file_name,
content=content,
actor_name=actor,
source=source,
)
config_json = dict(asset.config_json or {})
config_json["detail_mode"] = "spreadsheet"
config_json["tag"] = str(config_json.get("tag") or "财务规则").strip() or "财务规则"
config_json["rule_library"] = library
config_json["rule_document"] = {
**self.spreadsheet_manager.build_rule_document_config(
metadata,
asset_version="current",
),
"storage_key": metadata.storage_key,
}
asset.config_json = config_json
self.repository.save_asset(asset)
return metadata
@staticmethod
def _resolve_spreadsheet_rule_library(asset: AgentAsset) -> str:
config_json = dict(asset.config_json or {})
library = str(config_json.get("rule_library") or FINANCE_RULES_LIBRARY).strip()
if library not in RULE_LIBRARY_NAMES:
return FINANCE_RULES_LIBRARY
return library
@staticmethod
def _resolve_default_spreadsheet_file_name(asset: AgentAsset) -> str:
if asset.code == COMPANY_TRAVEL_EXPENSE_RULE_CODE:
return COMPANY_TRAVEL_EXPENSE_RULE_FILENAME
if asset.code == COMPANY_COMMUNICATION_EXPENSE_RULE_CODE:
return COMPANY_COMMUNICATION_EXPENSE_RULE_FILENAME
fallback = Path(str(asset.name or "规则表").strip()).name
return fallback if fallback.lower().endswith(".xlsx") else f"{fallback}.xlsx"
def _build_onlyoffice_spreadsheet_config( def _build_onlyoffice_spreadsheet_config(
self, self,
*, *,
@@ -1286,6 +1484,11 @@ class AgentAssetService:
can_edit = current_user.is_admin or "manager" in role_codes or "finance" in role_codes can_edit = current_user.is_admin or "manager" in role_codes or "finance" in role_codes
return can_edit and AgentAssetService._resolve_working_version(asset) == str(version or "").strip() return can_edit and AgentAssetService._resolve_working_version(asset) == str(version or "").strip()
@staticmethod
def _can_edit_current_spreadsheet(current_user: CurrentUserContext) -> bool:
role_codes = {str(item).strip() for item in current_user.role_codes}
return current_user.is_admin or "manager" in role_codes or "finance" in role_codes
@staticmethod @staticmethod
def _build_onlyoffice_document_key( def _build_onlyoffice_document_key(
asset_id: str, asset_id: str,
@@ -1428,3 +1631,93 @@ class AgentAssetService:
if not normalized.startswith(prefix) or suffix not in normalized: if not normalized.startswith(prefix) or suffix not in normalized:
return None return None
return normalized.removeprefix(prefix).split(suffix, 1)[0].strip() or None return normalized.removeprefix(prefix).split(suffix, 1)[0].strip() or None
def _resolve_json_risk_rule_document(self, asset: AgentAsset) -> tuple[str, str]:
config_json = dict(asset.config_json or {})
detail_mode = str(config_json.get("detail_mode") or "").strip().lower()
if detail_mode != "json_risk":
raise ValueError("当前资产不是 JSON 风险规则。")
rule_library = str(config_json.get("rule_library") or RISK_RULES_LIBRARY).strip()
if rule_library not in RULE_LIBRARY_NAMES:
raise ValueError("规则库目录不合法。")
rule_document = config_json.get("rule_document")
if not isinstance(rule_document, dict):
raise ValueError("规则资产缺少 rule_document 配置。")
file_name = str(rule_document.get("file_name") or "").strip()
if not file_name:
raise ValueError("规则资产缺少 JSON 文件名。")
return rule_library, file_name
def read_rule_json(self, asset_id: str) -> AgentAssetRuleJsonRead:
asset = self.repository.get(asset_id)
if asset is None:
raise LookupError("资产不存在。")
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
payload = self.rule_library_manager.read_rule_library_json(
library=rule_library,
file_name=file_name,
)
return AgentAssetRuleJsonRead(
file_name=file_name,
rule_code=str(payload.get("rule_code") or asset.code or ""),
name=str(payload.get("name") or asset.name or ""),
description=str(payload.get("description") or asset.description or "").strip(),
evaluator=str(payload.get("evaluator") or ""),
ontology_signal=str(payload.get("ontology_signal") or "") or None,
inputs=payload.get("inputs") if isinstance(payload.get("inputs"), dict) else {},
outcomes=payload.get("outcomes") if isinstance(payload.get("outcomes"), dict) else {},
payload=payload,
)
def write_rule_json(
self,
asset_id: str,
*,
body: AgentAssetRuleJsonWrite,
actor: str,
request_id: str | None = None,
) -> AgentAssetRuleJsonRead:
asset = self.repository.get(asset_id)
if asset is None:
raise LookupError("资产不存在。")
rule_library, file_name = self._resolve_json_risk_rule_document(asset)
payload = dict(body.payload or {})
asset_code = str(asset.code or "").strip()
if asset_code and str(payload.get("rule_code") or "").strip() not in {"", asset_code}:
raise ValueError("规则 JSON 的 rule_code 必须与资产编码一致。")
if asset_code and not str(payload.get("rule_code") or "").strip():
payload["rule_code"] = asset_code
saved = self.rule_library_manager.write_rule_library_json(
library=rule_library,
file_name=file_name,
payload=payload,
)
rule_description = str(saved.get("description") or "").strip()
if rule_description:
asset.description = rule_description
rule_name = str(saved.get("name") or "").strip()
if rule_name:
asset.name = rule_name
risk_category = str(saved.get("risk_category") or "").strip()
if risk_category:
config_json = dict(asset.config_json or {})
config_json["risk_category"] = risk_category
asset.config_json = config_json
asset.scenario_json = [risk_category]
self.audit_service.log_action(
actor=actor,
action="update_agent_asset_rule_json",
resource_type=asset.asset_type,
resource_id=asset.id,
before_json={"file_name": file_name},
after_json={"file_name": file_name, "rule_code": saved.get("rule_code")},
request_id=request_id,
)
self.db.commit()
return self.read_rule_json(asset_id)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -2511,3 +2511,216 @@ tbody tr.spotlight {
grid-column: span 1; grid-column: span 1;
} }
} }
.json-risk-skill-detail .detail-scroll {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
}
.json-risk-editor-shell {
min-height: 0;
height: 100%;
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px;
}
.json-risk-editor-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.json-risk-editor-title {
min-width: 0;
display: flex;
align-items: flex-start;
gap: 12px;
}
.json-risk-editor-title h2 {
color: #0f172a;
font-size: 18px;
font-weight: 850;
}
.json-risk-editor-title p {
margin-top: 2px;
max-width: 760px;
color: #64748b;
font-size: 12px;
line-height: 1.4;
}
.json-risk-head-subtitle {
display: -webkit-box;
margin: 6px 0 0;
max-width: 760px;
overflow: hidden;
color: #64748b;
font-size: 13px;
line-height: 1.55;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.json-risk-head-category {
margin: 6px 0 0;
color: #be123c;
font-size: 12px;
font-weight: 600;
}
.skill-name-cell .skill-list-subtitle {
display: -webkit-box;
overflow: hidden;
color: #94a3b8;
font-size: 12px;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.json-risk-editor-actions {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.json-risk-mode-pill {
min-height: 28px;
display: inline-flex;
align-items: center;
padding: 0 10px;
border-radius: 999px;
background: #fff1f2;
color: #be123c;
font-size: 12px;
font-weight: 800;
}
.json-risk-editor-body {
flex: 1 1 auto;
min-height: 0;
display: block;
}
.json-risk-main-stage {
min-height: 0;
display: grid;
gap: 12px;
}
.json-risk-description-card {
border-color: #fecdd3;
background: linear-gradient(180deg, #fffafb 0%, #ffffff 100%);
}
.json-risk-description-text {
margin: 0;
padding: 0 4px 8px;
color: #334155;
font-size: 14px;
line-height: 1.75;
white-space: pre-wrap;
word-break: break-word;
}
.json-risk-description-source {
margin: 0;
padding: 8px 12px 4px;
border-top: 1px solid #ffe4e6;
color: #94a3b8;
font-size: 12px;
line-height: 1.5;
}
.json-risk-summary-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.json-risk-summary-grid span {
min-height: 34px;
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 12px;
border-radius: 10px;
background: #f8fafc;
color: #475569;
font-size: 12px;
}
.json-risk-summary-grid strong {
color: #0f172a;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.json-risk-flow-diagram {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
gap: 10px;
align-items: center;
}
.json-risk-flow-column {
display: grid;
gap: 6px;
padding: 12px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #f8fafc;
}
.json-risk-flow-column.center {
text-align: center;
background: #fff1f2;
border-color: #fecdd3;
}
.json-risk-flow-column code {
font-size: 11px;
color: #334155;
}
.json-risk-flow-label {
font-size: 11px;
font-weight: 800;
color: #64748b;
text-transform: uppercase;
}
.json-risk-flow-arrow {
color: #94a3b8;
font-size: 18px;
font-weight: 800;
}
.json-risk-editor-toolbar {
display: flex;
align-items: center;
gap: 8px;
}
.json-risk-editor {
min-height: 280px;
}
.json-risk-version-center {
min-height: 0;
display: grid;
gap: 12px;
align-content: start;
padding: 12px;
border-radius: 12px;
border: 1px solid #e2e8f0;
background: #ffffff;
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,8 +15,10 @@ import {
fetchAgentAssetSpreadsheetBlob, fetchAgentAssetSpreadsheetBlob,
fetchAgentAssetSpreadsheetChangeRecords, fetchAgentAssetSpreadsheetChangeRecords,
fetchAgentAssetSpreadsheetOnlyOfficeConfig, fetchAgentAssetSpreadsheetOnlyOfficeConfig,
fetchAgentAssetRuleJson,
fetchAgentAssetVersionTimeline, fetchAgentAssetVersionTimeline,
fetchAgentRuns, fetchAgentRuns,
saveAgentAssetRuleJson,
importAgentAssetSpreadsheetContent, importAgentAssetSpreadsheetContent,
restoreAgentAssetVersion, restoreAgentAssetVersion,
updateAgentAsset updateAgentAsset
@@ -30,9 +32,8 @@ const RULE_TABLE_COLUMNS = {
category: '业务域', category: '业务域',
owner: '负责人', owner: '负责人',
scope: '适用场景', scope: '适用场景',
runtime: '风险等级', version: '修改次数',
version: '当前版本', metric: '修改人'
metric: '审核状态'
} }
const TYPE_META = { const TYPE_META = {
@@ -106,6 +107,8 @@ const TAB_META = {
hintText: '仅展示 tag 为“财务规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。', hintText: '仅展示 tag 为“财务规则”的规则资产;未打新 tag 的旧规则已从规则中心隐藏。',
searchPlaceholder: '搜索财务规则名称、编码或负责人', searchPlaceholder: '搜索财务规则名称、编码或负责人',
tableColumns: RULE_TABLE_COLUMNS, tableColumns: RULE_TABLE_COLUMNS,
showRuntimeColumn: false,
showStatusColumn: false,
badgeTone: 'emerald' badgeTone: 'emerald'
}, },
riskRules: { riskRules: {
@@ -114,9 +117,12 @@ const TAB_META = {
label: '风险规则', label: '风险规则',
typeLabel: '风险规则', typeLabel: '风险规则',
createButtonLabel: '风险规则已接入', createButtonLabel: '风险规则已接入',
hintText: '仅展示 tag 为“风险规则”的规则资产;当前未打标签的规则不会显示在这里。', hintText: '仅展示平台风险规则;适用场景按差旅、发票、餐饮招待等分类,可用「使用场景」筛选。',
searchPlaceholder: '搜索风险规则名称、编码或负责人', searchPlaceholder: '搜索风险规则名称、编码或负责人',
tableColumns: RULE_TABLE_COLUMNS, tableColumns: RULE_TABLE_COLUMNS,
showRuntimeColumn: false,
showVersionColumn: false,
showStatusColumn: false,
badgeTone: 'rose' badgeTone: 'rose'
}, },
skills: { skills: {
@@ -287,7 +293,32 @@ const RULE_TAB_TAG_ALIASES = {
riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk']) riskRules: new Set(['风险规则', '风险', '风控', 'riskrule', 'riskrules', 'risk'])
} }
const RISK_SCENARIO_OPTIONS = [
{ value: '', label: '全部场景' },
{ value: '差旅', label: '差旅' },
{ value: '发票', label: '发票' },
{ value: '餐饮招待', label: '餐饮招待' },
{ value: '交通出行', label: '交通出行' },
{ value: '办公物料', label: '办公物料' },
{ value: '费用科目', label: '费用科目' },
{ value: '通用', label: '通用' }
]
const LEGACY_RISK_SCENARIO_KEYS = new Set([
'expense',
'risk_check',
'travel',
'meal',
'invoice',
'travel_policy',
'travel_standard',
'attachment_policy',
'scene_policy',
'invoice_anomaly'
])
const SPREADSHEET_DETAIL_MODE = 'spreadsheet' const SPREADSHEET_DETAIL_MODE = 'spreadsheet'
const JSON_RISK_DETAIL_MODE = 'json_risk'
const PREVIEW_RULE_ID = 'preview-rule-expense-company-travel-expense' const PREVIEW_RULE_ID = 'preview-rule-expense-company-travel-expense'
const PREVIEW_RULE_CODE = 'rule.expense.company_travel_expense_reimbursement' const PREVIEW_RULE_CODE = 'rule.expense.company_travel_expense_reimbursement'
const PREVIEW_RULE_VERSION_SPECS = [ const PREVIEW_RULE_VERSION_SPECS = [
@@ -461,6 +492,11 @@ function isSpreadsheetRuleSource(value) {
return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === SPREADSHEET_DETAIL_MODE
} }
function isJsonRiskRuleSource(value) {
const configJson = readConfigJson(value)
return normalizeText(configJson.detail_mode || configJson.rule_detail_mode).toLowerCase() === JSON_RISK_DETAIL_MODE
}
function normalizeRuleTagValue(value) { function normalizeRuleTagValue(value) {
return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '') return normalizeText(value).toLowerCase().replace(/[\s_-]+/g, '')
} }
@@ -478,6 +514,14 @@ function collectRuleTagValues(source) {
} }
function resolveRuleTabId(source) { function resolveRuleTabId(source) {
const code = normalizeText(source?.code || '').toLowerCase()
if (code.startsWith('risk.')) {
return 'riskRules'
}
if (isJsonRiskRuleSource(source)) {
return 'riskRules'
}
const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item)) const normalizedTags = collectRuleTagValues(source).map((item) => normalizeRuleTagValue(item))
if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) { if (normalizedTags.some((item) => RULE_TAB_TAG_ALIASES.riskRules.has(item))) {
@@ -510,6 +554,108 @@ function resolveTabMeta(tabId, typeKey) {
return TAB_META[typeKey] return TAB_META[typeKey]
} }
function resolveRiskRuleDescription(payload) {
if (!isPlainObject(payload)) {
return ''
}
return normalizeText(payload.description)
}
function resolveRiskRuleSourceRef(payload) {
if (!isPlainObject(payload)) {
return ''
}
const metadata = isPlainObject(payload.metadata) ? payload.metadata : {}
return normalizeText(metadata.source_ref)
}
function inferRiskCategoryFromCode(code) {
const normalized = normalizeText(code).toLowerCase()
if (normalized.startsWith('risk.travel.')) {
return '差旅'
}
if (normalized.startsWith('risk.invoice.')) {
return '发票'
}
if (normalized.includes('entertainment') || normalized.includes('meal_localized')) {
return '餐饮招待'
}
if (normalized.includes('consecutive_transport')) {
return '交通出行'
}
if (normalized.startsWith('risk.expense.')) {
return '费用科目'
}
return '通用'
}
function resolveRiskRuleCategory(source) {
const configJson = readConfigJson(source)
const explicit = normalizeText(configJson.risk_category)
if (explicit) {
return explicit
}
const payloadCategory = normalizeText(source?.risk_category)
if (payloadCategory) {
return payloadCategory
}
const scenarioItems = Array.isArray(source?.scenario_json)
? source.scenario_json
: Array.isArray(source?.scenarioList)
? source.scenarioList
: []
const businessScenario = scenarioItems
.map((item) => normalizeText(item))
.find((item) => item && !LEGACY_RISK_SCENARIO_KEYS.has(item))
if (businessScenario) {
return businessScenario
}
return inferRiskCategoryFromCode(source?.code)
}
function buildRiskListSubtitle(text, maxLength = 42) {
const normalized = normalizeText(text)
if (!normalized) {
return '平台内置风险规则'
}
const firstSentence = normalized.split(/[。;;\n]/)[0] || normalized
if (firstSentence.length <= maxLength) {
return firstSentence
}
return `${firstSentence.slice(0, maxLength)}`
}
function applyRiskRuleJsonState(target, payload, apiPayload) {
const rulePayload = isPlainObject(payload) ? payload : {}
const fullDescription =
resolveRiskRuleDescription(rulePayload) ||
normalizeText(apiPayload?.description) ||
normalizeText(target.riskRuleDescription)
const riskCategory =
normalizeText(rulePayload.risk_category) ||
resolveRiskRuleCategory({ ...target, risk_category: rulePayload.risk_category, config_json: rulePayload })
return {
...target,
riskRuleDescription: fullDescription,
riskRuleSubtitle: buildRiskListSubtitle(fullDescription, 48),
riskCategory,
scope: riskCategory,
riskRuleSourceRef: resolveRiskRuleSourceRef(rulePayload),
riskRuleSummary: {
name: apiPayload?.name || target.name,
evaluator: apiPayload?.evaluator || rulePayload.evaluator || '',
ontologySignal: apiPayload?.ontology_signal || rulePayload.ontology_signal || '',
inputs: apiPayload?.inputs || rulePayload.inputs || {},
outcomes: apiPayload?.outcomes || rulePayload.outcomes || {}
},
riskRuleJsonText: JSON.stringify(rulePayload, null, 2)
}
}
function cloneJsonObject(value) { function cloneJsonObject(value) {
if (!isPlainObject(value)) { if (!isPlainObject(value)) {
return null return null
@@ -812,7 +958,7 @@ function buildRowRuntime(asset, typeKey) {
function buildRowMetric(asset, typeKey) { function buildRowMetric(asset, typeKey) {
if (typeKey === 'rules') { if (typeKey === 'rules') {
return asset.reviewer ? `审核人:${asset.reviewer}` : '待分配审核人' return normalizeText(asset.modified_by) || '未记录'
} }
if (typeKey === 'skills') { if (typeKey === 'skills') {
return '进入详情查看输出' return '进入详情查看输出'
@@ -832,6 +978,25 @@ function buildListItem(asset) {
const tabMeta = resolveTabMeta(tabId, typeKey) const tabMeta = resolveTabMeta(tabId, typeKey)
const statusMeta = resolveStatusMeta(asset.status) const statusMeta = resolveStatusMeta(asset.status)
const workingVersion = asset.working_version || asset.current_version || '-'
const changeCount =
typeof asset.change_count === 'number'
? asset.change_count
: Array.isArray(asset.recent_versions)
? Math.max(asset.recent_versions.length - 1, 0)
: 0
const modifiedBy =
normalizeText(asset.modified_by) ||
normalizeText(
Array.isArray(asset.recent_versions)
? asset.recent_versions.find((item) => item.version === workingVersion)?.created_by
: ''
)
const isRiskRule = tabId === 'riskRules'
const riskCategory = isRiskRule ? resolveRiskRuleCategory(asset) : ''
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
: normalizeText(asset.description)
return { return {
id: asset.id, id: asset.id,
@@ -842,19 +1007,24 @@ function buildListItem(asset) {
short: makeShort(asset.name), short: makeShort(asset.name),
name: asset.name, name: asset.name,
code: asset.code, code: asset.code,
summary: asset.description, summary: listSubtitle,
listSubtitle,
category: resolveDomainLabel(asset.domain), category: resolveDomainLabel(asset.domain),
owner: asset.owner, owner: asset.owner,
reviewer: asset.reviewer || '待分配', reviewer: asset.reviewer || '待分配',
scope: formatScenarioList(asset.scenario_json), scope: isRiskRule ? riskCategory || '通用' : formatScenarioList(asset.scenario_json),
riskCategory,
model: buildRowRuntime(asset, typeKey), model: buildRowRuntime(asset, typeKey),
version: asset.working_version || asset.current_version || '-', version: workingVersion,
versionDisplay: typeKey === 'rules' ? `${changeCount}` : workingVersion,
publishedVersion: asset.published_version || '-', publishedVersion: asset.published_version || '-',
workingVersion: asset.working_version || asset.current_version || '-', workingVersion,
status: statusMeta.label, status: statusMeta.label,
statusValue: asset.status, statusValue: asset.status,
statusTone: statusMeta.tone, statusTone: statusMeta.tone,
hitRate: buildRowMetric(asset, typeKey), hitRate: buildRowMetric({ ...asset, modified_by: modifiedBy }, typeKey),
modifiedBy,
changeCount,
updatedAt: formatDateTime(asset.updated_at), updatedAt: formatDateTime(asset.updated_at),
badgeTone: tabMeta.badgeTone, badgeTone: tabMeta.badgeTone,
spotlight: asset.status === 'active', spotlight: asset.status === 'active',
@@ -1214,6 +1384,7 @@ function buildDetailViewModel(detail, runs) {
const history = buildHistory(detail.recent_versions || [], detail) const history = buildHistory(detail.recent_versions || [], detail)
const previewVersion = history.find((item) => item.isWorking) || history[0] || null const previewVersion = history.find((item) => item.isWorking) || history[0] || null
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(detail) const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(detail)
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(detail)
const ruleDocument = readRuleDocumentMeta(detail) const ruleDocument = readRuleDocumentMeta(detail)
const previewRawMarkdown = const previewRawMarkdown =
detail.current_version_content_type === 'markdown' detail.current_version_content_type === 'markdown'
@@ -1239,11 +1410,12 @@ function buildDetailViewModel(detail, runs) {
short: makeShort(detail.name), short: makeShort(detail.name),
name: detail.name, name: detail.name,
code: detail.code, code: detail.code,
summary: detail.description, summary: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : detail.description,
listSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : normalizeText(detail.description),
owner: detail.owner, owner: detail.owner,
reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配', reviewer: detail.reviewer || detail.latest_review?.reviewer || '待分配',
category: resolveDomainLabel(detail.domain), category: resolveDomainLabel(detail.domain),
scope: formatScenarioList(detail.scenario_json), scope: usesJsonRiskRule ? resolveRiskRuleCategory(detail) || '通用' : formatScenarioList(detail.scenario_json),
version: detail.working_version || detail.current_version || '-', version: detail.working_version || detail.current_version || '-',
currentVersion: detail.current_version || '-', currentVersion: detail.current_version || '-',
publishedVersion: detail.published_version || '-', publishedVersion: detail.published_version || '-',
@@ -1257,6 +1429,13 @@ function buildDetailViewModel(detail, runs) {
badgeTone: tabMeta.badgeTone, badgeTone: tabMeta.badgeTone,
configJson, configJson,
usesSpreadsheetRule, usesSpreadsheetRule,
usesJsonRiskRule,
riskRuleJsonText: '{}',
riskRuleSummary: null,
riskRuleDescription: '',
riskRuleSubtitle: usesJsonRiskRule ? buildRiskListSubtitle(detail.description) : '',
riskRuleSourceRef: '',
riskCategory: usesJsonRiskRule ? resolveRiskRuleCategory(detail) : '',
ruleDocument, ruleDocument,
scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [], scenarioList: Array.isArray(detail.scenario_json) ? [...detail.scenario_json] : [],
markdownContent: previewMarkdown, markdownContent: previewMarkdown,
@@ -1380,6 +1559,7 @@ export default {
const selectedDomain = ref('') const selectedDomain = ref('')
const selectedOwner = ref('') const selectedOwner = ref('')
const selectedStatus = ref('') const selectedStatus = ref('')
const selectedRiskScenario = ref('')
const loading = ref(false) const loading = ref(false)
const errorMessage = ref('') const errorMessage = ref('')
const detailLoading = ref(false) const detailLoading = ref(false)
@@ -1434,11 +1614,17 @@ export default {
const createButtonLabel = computed(() => activeMeta.value.createButtonLabel) const createButtonLabel = computed(() => activeMeta.value.createButtonLabel)
const hintText = computed(() => activeMeta.value.hintText) const hintText = computed(() => activeMeta.value.hintText)
const tableColumns = computed(() => activeMeta.value.tableColumns) const tableColumns = computed(() => activeMeta.value.tableColumns)
const showRuntimeColumn = computed(() => activeMeta.value.showRuntimeColumn !== false)
const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false) const showMetricColumn = computed(() => activeMeta.value.showMetricColumn !== false)
const showVersionColumn = computed(() => activeMeta.value.showVersionColumn !== false)
const showStatusColumn = computed(() => activeMeta.value.showStatusColumn !== false)
const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules') const selectedSkillIsRule = computed(() => selectedSkill.value?.type === 'rules')
const selectedSkillUsesSpreadsheet = computed( const selectedSkillUsesSpreadsheet = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule) () => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesSpreadsheetRule)
) )
const selectedSkillUsesJsonRisk = computed(
() => selectedSkillIsRule.value && Boolean(selectedSkill.value?.usesJsonRiskRule)
)
const canManageSelected = computed( const canManageSelected = computed(
() => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock () => isAdmin.value && Boolean(selectedSkill.value) && !selectedSkill.value?.isPreviewMock
) )
@@ -1581,13 +1767,23 @@ export default {
const selectedStatusLabel = computed( const selectedStatusLabel = computed(
() => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态' () => STATUS_OPTIONS.find((item) => item.value === selectedStatus.value)?.label || '状态'
) )
const showRiskScenarioFilter = computed(() => activeType.value === 'riskRules')
const showStatusFilter = computed(() => activeType.value !== 'riskRules')
const selectedRiskScenarioLabel = computed(
() =>
RISK_SCENARIO_OPTIONS.find((item) => item.value === selectedRiskScenario.value)?.label ||
'使用场景'
)
const activeFilterTokens = computed(() => { const activeFilterTokens = computed(() => {
const tokens = [] const tokens = []
if (selectedDomain.value) { if (selectedDomain.value) {
tokens.push(`业务域:${resolveDomainLabel(selectedDomain.value)}`) tokens.push(`业务域:${resolveDomainLabel(selectedDomain.value)}`)
} }
if (selectedStatus.value) { if (showRiskScenarioFilter.value && selectedRiskScenario.value) {
tokens.push(`使用场景:${selectedRiskScenario.value}`)
}
if (showStatusFilter.value && selectedStatus.value) {
tokens.push(`状态:${resolveStatusMeta(selectedStatus.value).label}`) tokens.push(`状态:${resolveStatusMeta(selectedStatus.value).label}`)
} }
if (selectedOwner.value) { if (selectedOwner.value) {
@@ -1612,7 +1808,10 @@ export default {
actionIcon: '', actionIcon: '',
tone: 'amber', tone: 'amber',
artLabel: 'ASSET', artLabel: 'ASSET',
tips: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤'] tips:
activeType.value === 'riskRules'
? ['切换页签可查看其他资产类型', '支持按业务域、负责人和使用场景做过滤']
: ['切换页签可查看其他资产类型', '支持按业务域、负责人和状态做过滤']
} }
} }
@@ -1620,7 +1819,9 @@ export default {
eyebrow: '筛选结果为空', eyebrow: '筛选结果为空',
title: `没有找到匹配的${activeTabLabel.value}`, title: `没有找到匹配的${activeTabLabel.value}`,
desc: hasFilters desc: hasFilters
? '试试清空业务域、负责人、状态或关键词筛选,再重新查看。' ? showRiskScenarioFilter.value
? '试试清空业务域、负责人、使用场景或关键词筛选,再重新查看。'
: '试试清空业务域、负责人、状态或关键词筛选,再重新查看。'
: `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`, : `当前列表中还没有满足展示条件的${activeTabLabel.value}资产。`,
icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline', icon: hasFilters ? 'mdi mdi-tune-variant' : 'mdi mdi-view-grid-outline',
actionLabel: hasFilters ? '清空筛选' : '', actionLabel: hasFilters ? '清空筛选' : '',
@@ -1628,7 +1829,9 @@ export default {
tone: hasFilters ? 'emerald' : 'slate', tone: hasFilters ? 'emerald' : 'slate',
artLabel: hasFilters ? 'FILTER' : 'QUEUE', artLabel: hasFilters ? 'FILTER' : 'QUEUE',
tips: hasFilters tips: hasFilters
? ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索'] ? showRiskScenarioFilter.value
? ['业务域、负责人、使用场景与关键词会叠加过滤', '可以换个规则名称或场景分类继续搜索']
: ['业务域、负责人、状态与关键词会叠加过滤', '可以换个编码、名称或负责人关键词继续搜索']
: ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据'] : ['列表展示来自真实资产 API', '切换资产类型后会自动重新拉取数据']
} }
}) })
@@ -1675,9 +1878,18 @@ export default {
: true : true
const matchesDomain = selectedDomain.value ? item.domainValue === selectedDomain.value : true const matchesDomain = selectedDomain.value ? item.domainValue === selectedDomain.value : true
const matchesOwner = selectedOwner.value ? item.owner === selectedOwner.value : true const matchesOwner = selectedOwner.value ? item.owner === selectedOwner.value : true
const matchesStatus = selectedStatus.value ? item.statusValue === selectedStatus.value : true const matchesStatus = showStatusFilter.value
? selectedStatus.value
? item.statusValue === selectedStatus.value
: true
: true
const matchesRiskScenario = showRiskScenarioFilter.value
? selectedRiskScenario.value
? item.riskCategory === selectedRiskScenario.value
: true
: true
return matchesKeyword && matchesDomain && matchesOwner && matchesStatus return matchesKeyword && matchesDomain && matchesOwner && matchesStatus && matchesRiskScenario
}) })
}) })
@@ -1723,6 +1935,7 @@ export default {
selectedDomain.value = '' selectedDomain.value = ''
selectedOwner.value = '' selectedOwner.value = ''
selectedStatus.value = '' selectedStatus.value = ''
selectedRiskScenario.value = ''
activeFilterPopover.value = '' activeFilterPopover.value = ''
} }
@@ -1753,6 +1966,9 @@ export default {
if (name === 'status') { if (name === 'status') {
selectedStatus.value = value selectedStatus.value = value
} }
if (name === 'riskScenario') {
selectedRiskScenario.value = value
}
closeFilterPopover() closeFilterPopover()
} }
@@ -1840,7 +2056,7 @@ export default {
) )
spreadsheetChangeRecordsByAsset.value = { spreadsheetChangeRecordsByAsset.value = {
...spreadsheetChangeRecordsByAsset.value, ...spreadsheetChangeRecordsByAsset.value,
[assetId]: [nextRecord, ...deduped].slice(0, 5) [assetId]: [nextRecord, ...deduped].slice(0, 30)
} }
} }
@@ -1898,22 +2114,51 @@ export default {
function getLatestSpreadsheetChangeKey(assetId) { function getLatestSpreadsheetChangeKey(assetId) {
const records = spreadsheetChangeRecordsByAsset.value[assetId] || [] const records = spreadsheetChangeRecordsByAsset.value[assetId] || []
const latest = records.find((item) => item?.changed_at) const latest = records.find((item) => item?.changed_at)
return latest ? `${latest.changed_at}-${latest.actor}-${latest.summary}` : '' if (!latest) {
return ''
}
const previewSignature = Array.isArray(latest.cell_changes)
? latest.cell_changes
.slice(0, 8)
.map((item) =>
[
item?.sheet_name,
item?.cell,
item?.change_type,
item?.before_value,
item?.after_value
]
.map((value) => normalizeText(value))
.join(':')
)
.join('|')
: ''
return [
latest.id,
latest.changed_at,
latest.actor,
latest.summary,
latest.changed_sheet_count,
latest.changed_cell_count,
previewSignature
]
.map((value) => normalizeText(value))
.join('-')
} }
async function refreshSpreadsheetChangeRecordsAfterSave(assetId, previousLatestKey = '', attempt = 0) { async function refreshSpreadsheetChangeRecordsAfterSave(assetId, previousLatestKey = '', attempt = 0) {
const normalizedAssetId = normalizeText(assetId) const normalizedAssetId = normalizeText(assetId)
if (!normalizedAssetId || selectedSkill.value?.id !== normalizedAssetId) { if (!normalizedAssetId || selectedSkill.value?.id !== normalizedAssetId) {
return return false
} }
await loadSpreadsheetChangeRecords(normalizedAssetId) await loadSpreadsheetChangeRecords(normalizedAssetId)
const nextLatestKey = getLatestSpreadsheetChangeKey(normalizedAssetId) const nextLatestKey = getLatestSpreadsheetChangeKey(normalizedAssetId)
if (nextLatestKey && nextLatestKey !== previousLatestKey) { if (nextLatestKey && nextLatestKey !== previousLatestKey) {
return return true
} }
if (attempt >= 9) { if (attempt >= 9) {
return return false
} }
await new Promise((resolve) => window.setTimeout(resolve, 800)) await new Promise((resolve) => window.setTimeout(resolve, 800))
return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1) return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1)
@@ -1973,6 +2218,17 @@ export default {
stopSpreadsheetOnlyOfficeVersionSync() stopSpreadsheetOnlyOfficeVersionSync()
return return
} }
const changeRecordRefreshed = await refreshSpreadsheetChangeRecordsAfterSave(
normalizedAssetId,
previousLatestChangeKey
)
if (changeRecordRefreshed) {
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
await refreshCurrentAssets()
stopSpreadsheetOnlyOfficeVersionSync()
return
}
} catch { } catch {
// Ignore transient polling failures and continue retrying within the window. // Ignore transient polling failures and continue retrying within the window.
} }
@@ -2275,10 +2531,15 @@ export default {
const detail = await fetchAgentAssetDetail(assetId) const detail = await fetchAgentAssetDetail(assetId)
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(() => {}) if (!selectedSkill.value.usesJsonRiskRule) {
loadVersionTimeline(assetId, { silent: true }).catch(() => {})
}
if (selectedSkill.value.usesSpreadsheetRule) { if (selectedSkill.value.usesSpreadsheetRule) {
loadSpreadsheetChangeRecords(assetId).catch(() => {}) loadSpreadsheetChangeRecords(assetId).catch(() => {})
} }
if (selectedSkill.value.usesJsonRiskRule) {
await loadRiskRuleJson(assetId)
}
} }
} catch (error) { } catch (error) {
detailError.value = error?.message || '资产详情加载失败,请稍后重试。' detailError.value = error?.message || '资产详情加载失败,请稍后重试。'
@@ -2288,6 +2549,67 @@ export default {
} }
} }
async function loadRiskRuleJson(assetId) {
if (!assetId || !selectedSkill.value?.usesJsonRiskRule) {
return
}
const payload = await fetchAgentAssetRuleJson(assetId)
const rulePayload = payload?.payload && typeof payload.payload === 'object' ? payload.payload : payload
selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, rulePayload, payload)
}
async function saveRiskRuleJson() {
if (!selectedSkill.value?.id || !canEditMarkdown.value) {
return
}
actionState.value = 'save-risk-json'
detailBusy.value = true
try {
const parsed = JSON.parse(String(selectedSkill.value.riskRuleJsonText || '{}'))
const saved = await saveAgentAssetRuleJson(selectedSkill.value.id, { payload: parsed })
const rulePayload = saved?.payload && typeof saved.payload === 'object' ? saved.payload : saved
selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, rulePayload, saved)
toast('风险规则 JSON 已保存。')
} catch (error) {
toast(error?.message || '风险规则 JSON 保存失败。')
} finally {
detailBusy.value = false
actionState.value = ''
}
}
function formatRiskRuleJson() {
if (!selectedSkill.value?.usesJsonRiskRule) {
return
}
try {
const parsed = JSON.parse(String(selectedSkill.value.riskRuleJsonText || '{}'))
selectedSkill.value = applyRiskRuleJsonState(selectedSkill.value, parsed, {
name: selectedSkill.value.name,
description: resolveRiskRuleDescription(parsed)
})
} catch (error) {
toast(error?.message || 'JSON 格式无效,无法格式化。')
}
}
function downloadRiskRuleJson() {
if (!selectedSkill.value?.usesJsonRiskRule) {
return
}
const blob = new Blob([String(selectedSkill.value.riskRuleJsonText || '{}')], {
type: 'application/json;charset=utf-8'
})
const fileName =
selectedSkill.value.ruleDocument?.file_name ||
`${selectedSkill.value.code || 'risk-rule'}.json`
const link = document.createElement('a')
link.href = URL.createObjectURL(blob)
link.download = fileName
link.click()
URL.revokeObjectURL(link.href)
}
async function loadSpreadsheetChangeRecords(assetId) { async function loadSpreadsheetChangeRecords(assetId) {
if (!assetId) { if (!assetId) {
return return
@@ -2328,6 +2650,11 @@ export default {
configJson: {}, configJson: {},
isPreviewMock: false, isPreviewMock: false,
usesSpreadsheetRule: false, usesSpreadsheetRule: false,
usesJsonRiskRule: false,
riskRuleJsonText: '{}',
riskRuleSummary: null,
riskRuleDescription: '',
riskRuleSourceRef: '',
ruleDocument: null, ruleDocument: null,
scenarioList: [], scenarioList: [],
fields: [], fields: [],
@@ -2775,7 +3102,10 @@ export default {
hintText, hintText,
searchPlaceholder, searchPlaceholder,
tableColumns, tableColumns,
showRuntimeColumn,
showVersionColumn,
showMetricColumn, showMetricColumn,
showStatusColumn,
visibleSkills, visibleSkills,
auditEmptyState, auditEmptyState,
loading, loading,
@@ -2785,12 +3115,17 @@ export default {
selectedDomain, selectedDomain,
selectedOwner, selectedOwner,
selectedStatus, selectedStatus,
selectedRiskScenario,
selectedDomainLabel, selectedDomainLabel,
selectedOwnerLabel, selectedOwnerLabel,
selectedStatusLabel, selectedStatusLabel,
selectedRiskScenarioLabel,
showRiskScenarioFilter,
showStatusFilter,
domainOptions, domainOptions,
ownerOptions, ownerOptions,
statusOptions: STATUS_OPTIONS, statusOptions: STATUS_OPTIONS,
riskScenarioOptions: RISK_SCENARIO_OPTIONS,
activeFilterPopover, activeFilterPopover,
activeFilterTokens, activeFilterTokens,
canManageSelected, canManageSelected,
@@ -2806,6 +3141,7 @@ export default {
activateBlockedReason, activateBlockedReason,
selectedSkillIsRule, selectedSkillIsRule,
selectedSkillUsesSpreadsheet, selectedSkillUsesSpreadsheet,
selectedSkillUsesJsonRisk,
selectedSpreadsheetFileName, selectedSpreadsheetFileName,
selectedSpreadsheetVersionModeLabel, selectedSpreadsheetVersionModeLabel,
selectedVersionTimelineItems, selectedVersionTimelineItems,
@@ -2850,6 +3186,9 @@ export default {
confirmVersionSwitch, confirmVersionSwitch,
saveRuleMarkdown, saveRuleMarkdown,
saveRuleRuntimeJson, saveRuleRuntimeJson,
saveRiskRuleJson,
formatRiskRuleJson,
downloadRiskRuleJson,
triggerSpreadsheetUpload, triggerSpreadsheetUpload,
downloadSpreadsheetFile, downloadSpreadsheetFile,
handleSpreadsheetFileInput, handleSpreadsheetFileInput,

View File

@@ -5,6 +5,7 @@ import ConfirmDialog from '../../components/shared/ConfirmDialog.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'
import { recognizeOcrFiles } from '../../services/ocr.js' import { recognizeOcrFiles } from '../../services/ocr.js'
import { fetchAgentRunDetail } from '../../services/agentAssets.js'
import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js' import { clearUserConversations, deleteConversation, fetchLatestConversation, runOrchestrator } from '../../services/orchestrator.js'
import { renderMarkdown } from '../../utils/markdown.js' import { renderMarkdown } from '../../utils/markdown.js'
import { import {
@@ -175,6 +176,36 @@ const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const REVIEW_DRAWER_MODE_REVIEW = 'review' const REVIEW_DRAWER_MODE_REVIEW = 'review'
const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents' const REVIEW_DRAWER_MODE_DOCUMENTS = 'documents'
const REVIEW_DRAWER_MODE_RISK = 'risk' const REVIEW_DRAWER_MODE_RISK = 'risk'
const FLOW_STEP_STATUS_PENDING = 'pending'
const FLOW_STEP_STATUS_RUNNING = 'running'
const FLOW_STEP_STATUS_COMPLETED = 'completed'
const FLOW_STEP_STATUS_FAILED = 'failed'
const FLOW_STEP_FALLBACKS = {
intent: {
title: '意图识别',
tool: 'IntentRecognizer',
runningText: '正在识别业务意图...',
completedText: '意图识别完成'
},
extraction: {
title: '信息提取',
tool: 'SemanticExtractor',
runningText: '正在提取时间、金额、费用类型和待补项...',
completedText: '信息提取完成'
},
ocr: {
title: '票据/OCR识别',
tool: 'OCRService',
runningText: '正在识别票据附件...',
completedText: '票据识别完成'
},
result: {
title: '生成结果',
tool: 'ResultGenerator',
runningText: '正在生成解释与草稿...',
completedText: '结果已生成'
}
}
const HOT_KNOWLEDGE_QUESTIONS = [ const HOT_KNOWLEDGE_QUESTIONS = [
'差旅住宿标准按什么规则执行?', '差旅住宿标准按什么规则执行?',
'酒店超标后如何申请例外报销?', '酒店超标后如何申请例外报销?',
@@ -199,6 +230,23 @@ const CATEGORY_CONFIDENCE_KEYWORDS = {
communication: [/通讯|电话|流量|话费|宽带|网络/], communication: [/通讯|电话|流量|话费|宽带|网络/],
welfare: [/福利|体检|团建|节日|慰问|关怀/] welfare: [/福利|体检|团建|节日|慰问|关怀/]
} }
const FLOW_MISSING_SLOT_LABELS = {
expense_type: '报销类型',
customer_name: '客户名称',
time_range: '发生时间',
location: '地点',
merchant_name: '酒店/商户',
amount: '金额',
reason: '事由说明',
participants: '参与人员',
attachments: '票据附件'
}
const FLOW_INTENT_KEYWORDS = {
draft: ['报销', '草稿', '生成', '提交', '申请', '请走报销'],
query: ['查询', '查一下', '多少', '明细', '统计'],
risk_check: ['风险', '异常', '重复', '超标'],
explain: ['为什么', '依据', '规则', '怎么']
}
let messageSeed = 0 let messageSeed = 0
@@ -246,6 +294,297 @@ function formatMessageTime(value) {
}) })
} }
function createFlowSteps() {
return []
}
function formatSemanticEntityValue(entity) {
const normalizedValue = String(entity?.normalized_value || '').trim()
const rawValue = String(entity?.value || '').trim()
const entityType = String(entity?.type || '').trim()
if (entityType === 'amount') {
const numericValue = Number(normalizedValue || rawValue)
if (Number.isFinite(numericValue) && numericValue > 0) {
return Number.isInteger(numericValue) ? `${numericValue}` : `${numericValue.toFixed(2)}`
}
}
return rawValue || normalizedValue
}
function summarizeSemanticParseDetail(semanticParse, ontologyJson = {}) {
if (!semanticParse || typeof semanticParse !== 'object') {
return FLOW_STEP_FALLBACKS.extraction.completedText
}
const entities = Array.isArray(semanticParse.entities_json) ? semanticParse.entities_json : []
const entityMap = new Map()
for (const item of entities) {
const entityType = String(item?.type || '').trim()
if (!entityType || entityMap.has(entityType)) continue
entityMap.set(entityType, item)
}
const extractedParts = []
const timeRange = semanticParse.time_range_json && typeof semanticParse.time_range_json === 'object'
? semanticParse.time_range_json
: {}
const startDate = String(timeRange.start_date || '').trim()
const endDate = String(timeRange.end_date || '').trim()
if (startDate) {
extractedParts.push(`时间 ${startDate}${endDate && endDate !== startDate ? `${endDate}` : ''}`)
}
const amountEntity = entityMap.get('amount')
if (amountEntity) {
const amountValue = formatSemanticEntityValue(amountEntity)
if (amountValue) {
extractedParts.push(`金额 ${amountValue}`)
}
}
const expenseTypeEntity = entityMap.get('expense_type')
if (expenseTypeEntity) {
const expenseTypeLabel = resolveExpenseTypeLabel(
String(expenseTypeEntity?.normalized_value || '').trim(),
String(expenseTypeEntity?.value || '').trim()
)
if (expenseTypeLabel) {
extractedParts.push(`费用类型 ${expenseTypeLabel}`)
}
}
const customerEntity = entityMap.get('customer')
if (customerEntity) {
const customerValue = formatSemanticEntityValue(customerEntity)
if (customerValue) {
extractedParts.push(`客户 ${customerValue}`)
}
}
const missingSlots = Array.isArray(ontologyJson?.missing_slots) ? ontologyJson.missing_slots : []
const missingLabels = missingSlots
.map((item) => FLOW_MISSING_SLOT_LABELS[String(item || '').trim()] || String(item || '').trim())
.filter(Boolean)
if (extractedParts.length && missingLabels.length) {
return `已提取${extractedParts.join('、')};待补充 ${missingLabels.join('、')}`
}
if (extractedParts.length) {
return `已提取${extractedParts.join('、')}`
}
if (missingLabels.length) {
return `已完成信息提取;待补充 ${missingLabels.join('、')}`
}
return FLOW_STEP_FALLBACKS.extraction.completedText
}
function summarizeSemanticIntentDetail(semanticParse) {
if (!semanticParse || typeof semanticParse !== 'object') {
return FLOW_STEP_FALLBACKS.intent.completedText
}
const scenarioLabel = SCENARIO_LABELS[String(semanticParse.scenario || '').trim()] || String(semanticParse.scenario || '').trim() || '通用'
const intentLabel = INTENT_LABELS[String(semanticParse.intent || '').trim()] || String(semanticParse.intent || '').trim() || '处理'
return `已识别为${scenarioLabel}场景,当前目标是${intentLabel}`
}
function extractLocalFlowCandidates(rawText) {
const text = String(rawText || '').trim()
const compact = text.replace(/\s+/g, '')
let time = ''
const explicitTimeMatch = text.match(/发生时间[:]?\s*([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (explicitTimeMatch?.[1]) {
time = explicitTimeMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else {
const dateMatch = text.match(/([0-9]{4}[-/年][0-9]{1,2}[-/月][0-9]{1,2}日?)/)
if (dateMatch?.[1]) {
time = dateMatch[1].replace(/年/g, '-').replace(/月/g, '-').replace(/日/g, '').replace(/\//g, '-')
} else if (/今天|今日/.test(compact)) {
time = '今天'
} else if (/昨天|昨日/.test(compact)) {
time = '昨天'
} else if (/前天/.test(compact)) {
time = '前天'
}
}
let amount = ''
const amountMatch = text.match(/([0-9]+(?:\.[0-9]{1,2})?)\s*(?:元|员|圆|园|块|块钱|万元|万)/)
if (amountMatch?.[1]) {
const numericValue = Number(amountMatch[1])
if (Number.isFinite(numericValue)) {
amount = Number.isInteger(numericValue) ? `${numericValue}` : `${numericValue.toFixed(2)}`
}
}
let event = ''
let expenseType = ''
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
event = '请客户吃饭'
expenseType = '业务招待费'
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
event = '出差行程'
expenseType = '差旅费'
} else if (/打车|网约车|出租车|车费|停车/.test(compact)) {
event = '交通出行'
expenseType = '交通费'
} else if (/住宿|酒店|宾馆/.test(compact)) {
event = '住宿报销'
expenseType = '住宿费'
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
event = '餐饮用餐'
expenseType = '餐费'
}
return {
time,
amount,
event,
expenseType
}
}
function buildLocalIntentPreview(rawText, sessionType = SESSION_TYPE_EXPENSE) {
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
return '初步识别为财务知识问答,正在准备检索范围'
}
const text = String(rawText || '').trim()
const compact = text.replace(/\s+/g, '')
const intentKey = Object.entries(FLOW_INTENT_KEYWORDS).find(([, keywords]) =>
keywords.some((keyword) => compact.includes(keyword))
)?.[0] || 'draft'
const intentLabel = INTENT_LABELS[intentKey] || '处理'
return `初步识别为报销场景,准备进入${intentLabel}`
}
function buildLocalExtractionProgressMessages(rawText, options = {}) {
const candidates = extractLocalFlowCandidates(rawText)
const messages = []
messages.push('正在提取发生时间...')
messages.push(
candidates.time
? `发现发生时间 ${candidates.time},继续提取金额...`
: '暂未定位到明确时间,继续提取金额...'
)
messages.push(
candidates.amount
? `发现金额 ${candidates.amount},继续识别事件类型...`
: '暂未定位到明确金额,继续识别事件类型...'
)
if (candidates.event || candidates.expenseType) {
const eventParts = [candidates.event, candidates.expenseType].filter(Boolean)
messages.push(`识别到${eventParts.join(' / ')},继续判断待补项...`)
} else {
messages.push('正在识别事件类型和费用分类...')
}
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
return messages
}
function formatFlowDuration(ms) {
const numericValue = Number(ms)
if (!Number.isFinite(numericValue) || numericValue < 0) {
return '--'
}
if (numericValue < 100) {
return '<0.1s'
}
if (numericValue < 1000) {
return `${(numericValue / 1000).toFixed(1)}s`
}
if (numericValue < 10000) {
return `${(numericValue / 1000).toFixed(1)}s`
}
return `${Math.round(numericValue / 1000)}s`
}
function parseFlowTimestamp(value) {
const timestamp = new Date(value || '').getTime()
return Number.isFinite(timestamp) ? timestamp : 0
}
function resolveSemanticPhaseDurations(run) {
const runStart = parseFlowTimestamp(run?.started_at)
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
const firstToolStartedAt = toolCalls
.map((item) => parseFlowTimestamp(item?.created_at))
.filter((value) => value > 0)
.sort((left, right) => left - right)[0] || 0
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
const semanticFinishedAt = firstToolStartedAt || runFinishedAt
if (!runStart || !semanticFinishedAt || semanticFinishedAt <= runStart) {
return { intentMs: null, extractionMs: null }
}
const totalMs = semanticFinishedAt - runStart
const intentMs = Math.max(120, Math.round(totalMs * 0.35))
const extractionMs = Math.max(160, totalMs - intentMs)
return {
intentMs,
extractionMs
}
}
function resolveToolCallDurationMs(toolCall, index, toolCalls, run) {
const explicitDuration = Number(toolCall?.duration_ms)
if (Number.isFinite(explicitDuration) && explicitDuration > 0) {
return explicitDuration
}
const startedAt = parseFlowTimestamp(toolCall?.created_at)
if (!startedAt) {
return null
}
const nextStartedAt = parseFlowTimestamp(toolCalls[index + 1]?.created_at)
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
const finishedAt = nextStartedAt > startedAt ? nextStartedAt : (runFinishedAt > startedAt ? runFinishedAt : 0)
if (!finishedAt || finishedAt <= startedAt) {
return null
}
return finishedAt - startedAt
}
function resolveResultStepDurationMs(run) {
const runFinishedAt = parseFlowTimestamp(run?.finished_at)
if (!runFinishedAt) {
return null
}
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
const semanticFinishedAt = (
toolCalls
.map((item, index) => {
const startedAt = parseFlowTimestamp(item?.created_at)
const durationMs = resolveToolCallDurationMs(item, index, toolCalls, run)
if (!startedAt || !durationMs) {
return 0
}
return startedAt + durationMs
})
.filter((value) => value > 0)
.sort((left, right) => right - left)[0]
) || parseFlowTimestamp(run?.started_at)
if (!semanticFinishedAt || runFinishedAt <= semanticFinishedAt) {
return null
}
return runFinishedAt - semanticFinishedAt
}
function sanitizeRequest(request) { function sanitizeRequest(request) {
if (!request || typeof request !== 'object') return null if (!request || typeof request !== 'object') return null
@@ -994,6 +1333,13 @@ function formatDraftApplyTime(date = new Date()) {
return `${year}-${month}-${day} ${hours}:${minutes}` return `${year}-${month}-${day} ${hours}:${minutes}`
} }
function formatDateInputValue(date = new Date()) {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
function buildDraftSavedPayload({ function buildDraftSavedPayload({
draftPayload, draftPayload,
reviewPayload, reviewPayload,
@@ -2173,9 +2519,15 @@ export default {
const fileInputMode = ref('composer') const fileInputMode = ref('composer')
const messageListRef = ref(null) const messageListRef = ref(null)
const composerDraft = ref('') const composerDraft = ref('')
const composerDatePickerOpen = ref(false)
const composerDateMode = ref('single')
const composerSingleDate = ref(formatDateInputValue())
const composerRangeStartDate = ref(formatDateInputValue())
const composerRangeEndDate = ref(formatDateInputValue())
const attachedFiles = ref([]) const attachedFiles = ref([])
const composerFilesExpanded = ref(false) const composerFilesExpanded = ref(false)
const submitting = ref(false) const submitting = ref(false)
const workbenchVisible = ref(false)
const linkedRequest = computed(() => sanitizeRequest(props.requestContext)) const linkedRequest = computed(() => sanitizeRequest(props.requestContext))
const initialSessionType = resolveInitialSessionType(props.initialConversation) const initialSessionType = resolveInitialSessionType(props.initialConversation)
const initialSessionState = props.initialConversation const initialSessionState = props.initialConversation
@@ -2222,10 +2574,61 @@ export default {
url: '' url: ''
}) })
const sessionSwitchBusy = ref(false) const sessionSwitchBusy = ref(false)
const flowPanelOpen = ref(false)
const flowRunId = ref('')
const flowStartedAt = ref(0)
const flowFinishedAt = ref(0)
const flowSteps = ref(createFlowSteps())
const flowRefreshBusy = ref(false)
const flowTick = ref(Date.now())
let flowTickTimer = 0
const flowSimulationTimers = []
const canSubmit = computed( const canSubmit = computed(
() => !submitting.value && !sessionSwitchBusy.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length) () => !submitting.value && !sessionSwitchBusy.value && Boolean(composerDraft.value.trim() || attachedFiles.value.length)
) )
const composerCanApplyDateSelection = computed(() => {
if (composerDateMode.value === 'single') {
return Boolean(composerSingleDate.value)
}
return Boolean(
composerRangeStartDate.value
&& composerRangeEndDate.value
&& composerRangeStartDate.value <= composerRangeEndDate.value
)
})
const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE) const isKnowledgeSession = computed(() => activeSessionType.value === SESSION_TYPE_KNOWLEDGE)
const completedFlowStepCount = computed(
() => flowSteps.value.filter((step) => step.status === FLOW_STEP_STATUS_COMPLETED).length
)
const runningFlowStep = computed(
() => flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_RUNNING) || null
)
const flowOverallStatusTone = computed(() => {
if (flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) {
return 'failed'
}
if (runningFlowStep.value) {
return 'running'
}
if (flowSteps.value.length && completedFlowStepCount.value === flowSteps.value.length && flowStartedAt.value) {
return 'completed'
}
return 'pending'
})
const flowOverallStatusText = computed(() => {
const total = flowSteps.value.length
const completed = completedFlowStepCount.value
if (flowOverallStatusTone.value === 'failed') {
return `异常 ${completed}/${total}`
}
if (flowOverallStatusTone.value === 'completed') {
return `已完成 ${total}/${total}`
}
if (flowOverallStatusTone.value === 'running') {
return `执行中 ${completed}/${total}`
}
return total ? `待执行 0/${total}` : '暂无流程'
})
const hasInsightPanelContent = computed( const hasInsightPanelContent = computed(
() => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome' () => isKnowledgeSession.value || currentInsight.value.intent !== 'welcome'
) )
@@ -2578,6 +2981,12 @@ export default {
) )
onMounted(() => { onMounted(() => {
flowTickTimer = window.setInterval(() => {
flowTick.value = Date.now()
}, 250)
nextTick(() => {
workbenchVisible.value = true
})
void clearKnowledgeSessionOnEntry() void clearKnowledgeSessionOnEntry()
currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value) currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value)
if (props.initialPrompt?.trim() || props.initialFiles.length) { if (props.initialPrompt?.trim() || props.initialFiles.length) {
@@ -2598,6 +3007,10 @@ export default {
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
if (flowTickTimer) {
window.clearInterval(flowTickTimer)
}
clearFlowSimulationTimers()
for (const url of previewRegistry) { for (const url of previewRegistry) {
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
} }
@@ -2612,6 +3025,12 @@ export default {
const emptyState = buildEmptySessionState(activeSessionType.value) const emptyState = buildEmptySessionState(activeSessionType.value)
sessionSnapshots.value[activeSessionType.value] = emptyState sessionSnapshots.value[activeSessionType.value] = emptyState
applySessionState(emptyState) applySessionState(emptyState)
clearFlowSimulationTimers()
flowRunId.value = ''
flowStartedAt.value = 0
flowFinishedAt.value = 0
flowSteps.value = createFlowSteps()
flowPanelOpen.value = false
} }
function adjustComposerTextareaHeight() { function adjustComposerTextareaHeight() {
@@ -2633,6 +3052,346 @@ export default {
adjustComposerTextareaHeight() adjustComposerTextareaHeight()
} }
function handleComposerEnter(event) {
if (event?.isComposing || submitting.value || reviewActionBusy.value || sessionSwitchBusy.value) {
return
}
submitComposer()
}
function toggleFlowPanel() {
flowPanelOpen.value = !flowPanelOpen.value
}
function openFlowPanel() {
flowPanelOpen.value = true
}
function clearFlowSimulationTimers() {
while (flowSimulationTimers.length) {
const timerId = flowSimulationTimers.pop()
window.clearTimeout(timerId)
window.clearInterval(timerId)
}
}
function scheduleFlowPanelAutoCollapse(delayMs = 1200) {
const collapseTimer = window.setTimeout(() => {
if (runningFlowStep.value || flowRefreshBusy.value || submitting.value) {
return
}
if (flowSteps.value.length && !flowSteps.value.some((step) => step.status === FLOW_STEP_STATUS_FAILED)) {
flowPanelOpen.value = false
}
}, delayMs)
flowSimulationTimers.push(collapseTimer)
}
function resetFlowRun() {
clearFlowSimulationTimers()
flowPanelOpen.value = true
flowRunId.value = ''
flowStartedAt.value = Date.now()
flowFinishedAt.value = 0
flowSteps.value = createFlowSteps()
}
function findFlowDefinition(key) {
return FLOW_STEP_FALLBACKS[key] || null
}
function normalizeFlowStepPatch(key, patch = {}) {
const definition = findFlowDefinition(key) || {}
const normalizedPatch = typeof patch === 'string' ? { detail: patch } : { ...patch }
return {
title: normalizedPatch.title || definition.title || '智能体工具调用',
tool: normalizedPatch.tool || definition.tool || 'AgentTool',
detail: normalizedPatch.detail || definition.runningText || '',
...normalizedPatch
}
}
function createFlowStep(key, patch = {}) {
const normalizedPatch = normalizeFlowStepPatch(key, patch)
return {
key,
index: flowSteps.value.length + 1,
title: normalizedPatch.title,
tool: normalizedPatch.tool,
status: normalizedPatch.status || FLOW_STEP_STATUS_PENDING,
detail: normalizedPatch.detail || '',
durationMs: normalizedPatch.durationMs ?? null,
startedAt: normalizedPatch.startedAt || 0,
finishedAt: normalizedPatch.finishedAt || 0,
error: normalizedPatch.error || ''
}
}
function upsertFlowStep(key, patch) {
const existingStep = flowSteps.value.find((step) => step.key === key)
if (!existingStep) {
flowSteps.value = [...flowSteps.value, createFlowStep(key, patch)]
return
}
const normalizedPatch = normalizeFlowStepPatch(key, patch)
flowSteps.value = flowSteps.value.map((step) => (
step.key === key ? { ...step, ...normalizedPatch } : step
))
}
function startFlowStep(key, patch = {}) {
const normalizedPatch = normalizeFlowStepPatch(key, patch)
upsertFlowStep(key, {
...normalizedPatch,
status: FLOW_STEP_STATUS_RUNNING,
detail: normalizedPatch.detail,
startedAt: Date.now(),
finishedAt: 0,
durationMs: null,
error: ''
})
}
function completeFlowStep(key, detail = '', durationMs = null, patch = {}) {
const now = Date.now()
const definition = findFlowDefinition(key)
const currentStep = flowSteps.value.find((step) => step.key === key)
const startedAt = currentStep?.startedAt || now
upsertFlowStep(key, {
...patch,
status: FLOW_STEP_STATUS_COMPLETED,
detail: detail || definition?.completedText || '',
startedAt,
finishedAt: now,
durationMs: Number.isFinite(Number(durationMs)) ? Number(durationMs) : now - startedAt,
error: ''
})
}
function failFlowStep(key, detail = '', error = '', patch = {}) {
const now = Date.now()
const definition = findFlowDefinition(key)
const currentStep = flowSteps.value.find((step) => step.key === key)
const startedAt = currentStep?.startedAt || now
upsertFlowStep(key, {
...patch,
status: FLOW_STEP_STATUS_FAILED,
detail: detail || error || '调用失败',
startedAt,
finishedAt: now,
durationMs: now - startedAt,
error: String(error || definition?.title || '').trim()
})
flowFinishedAt.value = now
}
function completePendingFlowStep(key, detail = '', durationMs = null, patch = {}) {
const currentStep = flowSteps.value.find((step) => step.key === key)
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED) {
return
}
const normalizedDuration = Number(durationMs)
const hasMeasuredDuration = Number.isFinite(normalizedDuration) && normalizedDuration > 0
if (!currentStep || currentStep.status === FLOW_STEP_STATUS_PENDING) {
if (!hasMeasuredDuration && !currentStep?.startedAt) {
upsertFlowStep(key, {
...patch,
status: FLOW_STEP_STATUS_COMPLETED,
detail: detail || findFlowDefinition(key)?.completedText || '',
startedAt: 0,
finishedAt: 0,
durationMs: null,
error: ''
})
return
}
startFlowStep(key, patch)
}
completeFlowStep(key, detail, hasMeasuredDuration ? normalizedDuration : null, patch)
}
function failCurrentFlowStep(error) {
clearFlowSimulationTimers()
const currentStep = runningFlowStep.value || flowSteps.value.find((step) => step.status === FLOW_STEP_STATUS_PENDING)
failFlowStep(currentStep?.key || 'result', error?.message || '智能体调用失败', error?.message || '')
}
function startSemanticFlowPreview(rawText, options = {}) {
clearFlowSimulationTimers()
const intentPreview = buildLocalIntentPreview(rawText, activeSessionType.value)
const extractionMessages = buildLocalExtractionProgressMessages(rawText, options)
const completeIntentTimer = window.setTimeout(() => {
const currentStep = flowSteps.value.find((step) => step.key === 'intent')
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) {
return
}
completePendingFlowStep('intent', intentPreview, null)
}, 260)
flowSimulationTimers.push(completeIntentTimer)
const startExtractionTimer = window.setTimeout(() => {
const currentStep = flowSteps.value.find((step) => step.key === 'extraction')
if (currentStep?.status === FLOW_STEP_STATUS_COMPLETED || currentStep?.status === FLOW_STEP_STATUS_FAILED) {
return
}
startFlowStep('extraction', extractionMessages[0] || FLOW_STEP_FALLBACKS.extraction.runningText)
if (extractionMessages.length <= 1) {
return
}
let index = 1
const detailTimer = window.setInterval(() => {
const runningStep = flowSteps.value.find((step) => step.key === 'extraction')
if (!runningStep || runningStep.status !== FLOW_STEP_STATUS_RUNNING) {
window.clearInterval(detailTimer)
return
}
upsertFlowStep('extraction', {
detail: extractionMessages[index] || extractionMessages[extractionMessages.length - 1]
})
index = Math.min(index + 1, extractionMessages.length - 1)
}, 650)
flowSimulationTimers.push(detailTimer)
}, 420)
flowSimulationTimers.push(startExtractionTimer)
}
function resolveToolCallFlowMeta(toolCall, index) {
const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase()
const key = `tool-${toolCall?.id || `${index}-${toolType}-${toolName}`}`
if (toolType.includes('rule')) {
return { key, title: '规则引擎校验', tool: toolCall?.tool_name || 'RuleEngine' }
}
if (toolType.includes('mcp')) {
return { key, title: toolName.includes('standard') ? '差旅补助标准查询' : 'MCP 服务调用', tool: toolCall?.tool_name || 'MCPService' }
}
if (toolName.includes('knowledge')) {
return { key, title: '知识库检索', tool: toolCall?.tool_name || 'KnowledgeSearch' }
}
if (toolName.includes('expense_claim') || toolName.includes('save_or_submit')) {
return { key, title: '报销草稿处理', tool: toolCall?.tool_name || 'ExpenseClaimService' }
}
if (toolType.includes('database')) {
return { key, title: '数据查询/字段处理', tool: toolCall?.tool_name || 'DatabaseTool' }
}
if (toolType.includes('llm') || toolName.includes('user_agent')) {
return { key, title: '智能体生成', tool: toolCall?.tool_name || 'UserAgent' }
}
return { key, title: '智能体工具调用', tool: toolCall?.tool_name || toolCall?.tool_type || 'AgentTool' }
}
function summarizeFlowToolCall(toolCall) {
const response = toolCall?.response_json && typeof toolCall.response_json === 'object'
? toolCall.response_json
: {}
return (
String(response.message || response.summary || response.result_summary || '').trim()
|| String(toolCall?.tool_name || '').trim()
|| '工具调用完成'
)
}
function mergeFlowRunDetail(run) {
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
if (run?.semantic_parse && flowSteps.value.some((step) => step.key === 'intent')) {
clearFlowSimulationTimers()
const semanticDurations = resolveSemanticPhaseDurations(run)
completePendingFlowStep(
'intent',
summarizeSemanticIntentDetail(run.semantic_parse),
semanticDurations.intentMs
)
completePendingFlowStep(
'extraction',
summarizeSemanticParseDetail(run.semantic_parse, run?.ontology_json || {}),
semanticDurations.extractionMs
)
}
toolCalls.forEach((toolCall, index) => {
const meta = resolveToolCallFlowMeta(toolCall, index)
const failed = String(toolCall?.status || '').toLowerCase() === 'failed'
if (failed) {
failFlowStep(meta.key, toolCall?.error_message || summarizeFlowToolCall(toolCall), toolCall?.error_message || '', meta)
} else {
const toolDurationMs = resolveToolCallDurationMs(toolCall, index, toolCalls, run)
completePendingFlowStep(
meta.key,
summarizeFlowToolCall(toolCall),
toolDurationMs,
meta
)
}
})
if (String(run?.status || '').toLowerCase() === 'failed') {
failCurrentFlowStep({ message: run?.error_message || '智能体调用失败' })
return
}
}
function completeFlowResult(payload, run = null) {
const answer = String(payload?.result?.answer || payload?.result?.message || '').trim()
if (!answer && !payload?.result) {
return
}
startFlowStep('result', '正在返回处理结果...')
completeFlowStep('result', '结果已返回到对话区', resolveResultStepDurationMs(run))
flowFinishedAt.value = Date.now()
scheduleFlowPanelAutoCollapse()
}
async function refreshFlowRunDetail() {
if (!flowRunId.value || flowRefreshBusy.value) {
return null
}
flowRefreshBusy.value = true
try {
const run = await fetchAgentRunDetail(flowRunId.value)
mergeFlowRunDetail(run)
return run
} catch (error) {
console.warn('Failed to refresh agent run detail:', error)
return null
} finally {
flowRefreshBusy.value = false
}
}
function formatFlowStepDuration(step) {
if (step?.status === FLOW_STEP_STATUS_RUNNING && step.startedAt) {
return formatFlowDuration(flowTick.value - step.startedAt)
}
return formatFlowDuration(step?.durationMs)
}
function buildComposerDateSelectionText() {
if (composerDateMode.value === 'single') {
return `发生时间:${composerSingleDate.value}`
}
if (composerRangeStartDate.value === composerRangeEndDate.value) {
return `发生时间:${composerRangeStartDate.value}`
}
return `发生时间:${composerRangeStartDate.value}${composerRangeEndDate.value}`
}
async function applyComposerDateSelection() {
if (!composerCanApplyDateSelection.value) {
return
}
const dateText = buildComposerDateSelectionText()
const currentDraft = composerDraft.value.trim()
composerDraft.value = currentDraft ? `${currentDraft}${dateText}` : dateText
composerDatePickerOpen.value = false
await nextTick()
adjustComposerTextareaHeight()
composerTextareaRef.value?.focus()
}
function rememberFilePreviews(filePreviews) { function rememberFilePreviews(filePreviews) {
reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews) reviewFilePreviews.value = mergeFilePreviews(reviewFilePreviews.value, filePreviews)
} }
@@ -3102,6 +3861,10 @@ export default {
} }
function requestCloseWorkbench() { function requestCloseWorkbench() {
workbenchVisible.value = false
}
function emitCloseAfterLeave() {
emit('close') emit('close')
} }
@@ -3285,6 +4048,12 @@ export default {
return null return null
} }
resetFlowRun()
if (rawText) {
startFlowStep('intent', '正在识别业务意图...')
startSemanticFlowPreview(rawText, { attachmentCount: files.length })
}
const fileNames = files.map((file) => file.name) const fileNames = files.map((file) => file.name)
const filePreviews = buildFilePreviews(files, previewRegistry) const filePreviews = buildFilePreviews(files, previewRegistry)
rememberFilePreviews(filePreviews) rememberFilePreviews(filePreviews)
@@ -3334,14 +4103,17 @@ export default {
let ocrFilePreviews = [] let ocrFilePreviews = []
if (files.length) { if (files.length) {
startFlowStep('ocr', `正在识别 ${files.length} 份附件...`)
try { try {
ocrPayload = await recognizeOcrFiles(files) ocrPayload = await recognizeOcrFiles(files)
ocrSummary = buildOcrSummary(ocrPayload) ocrSummary = buildOcrSummary(ocrPayload)
ocrDocuments = normalizeOcrDocuments(ocrPayload) ocrDocuments = normalizeOcrDocuments(ocrPayload)
ocrFilePreviews = buildOcrFilePreviews(ocrPayload) ocrFilePreviews = buildOcrFilePreviews(ocrPayload)
rememberFilePreviews(ocrFilePreviews) rememberFilePreviews(ocrFilePreviews)
completeFlowStep('ocr', `识别到 ${ocrDocuments.length || files.length} 张票据`)
} catch (error) { } catch (error) {
console.warn('OCR request failed:', error) console.warn('OCR request failed:', error)
completeFlowStep('ocr', 'OCR识别失败已继续使用附件名称')
} }
} }
@@ -3396,6 +4168,11 @@ export default {
: {} : {}
) )
responsePayload = payload responsePayload = payload
flowRunId.value = String(payload?.run_id || '').trim()
let flowRunDetail = null
if (flowRunId.value) {
flowRunDetail = await refreshFlowRunDetail()
}
conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value conversationId.value = String(payload?.conversation_id || '').trim() || conversationId.value
draftClaimId.value = draftClaimId.value =
@@ -3432,7 +4209,10 @@ export default {
effectiveFileNames, effectiveFileNames,
mergeFilePreviews(filePreviews, ocrFilePreviews) mergeFilePreviews(filePreviews, ocrFilePreviews)
) )
completeFlowResult(payload, flowRunDetail)
} catch (error) { } catch (error) {
clearFlowSimulationTimers()
failCurrentFlowStep(error)
replaceMessage( replaceMessage(
pendingMessage.id, pendingMessage.id,
createMessage( createMessage(
@@ -3704,6 +4484,18 @@ export default {
composerTextareaRef, composerTextareaRef,
messageListRef, messageListRef,
composerDraft, composerDraft,
composerDatePickerOpen,
composerDateMode,
composerSingleDate,
composerRangeStartDate,
composerRangeEndDate,
composerCanApplyDateSelection,
flowPanelOpen,
flowSteps,
flowRunId,
flowRefreshBusy,
flowOverallStatusTone,
flowOverallStatusText,
attachedFiles, attachedFiles,
composerFilesExpanded, composerFilesExpanded,
visibleAttachedFiles, visibleAttachedFiles,
@@ -3755,6 +4547,7 @@ export default {
REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OTHER_OPTION,
REVIEW_SCENE_OPTIONS, REVIEW_SCENE_OPTIONS,
REVIEW_OTHER_CATEGORY_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
workbenchVisible,
reviewPanelConfidence, reviewPanelConfidence,
reviewRiskScore, reviewRiskScore,
reviewRiskSummary, reviewRiskSummary,
@@ -3806,12 +4599,18 @@ export default {
getExpenseQueryVisibleRecords, getExpenseQueryVisibleRecords,
resolveDocumentPreview, resolveDocumentPreview,
triggerFileUpload, triggerFileUpload,
applyComposerDateSelection,
handleFilesChange, handleFilesChange,
handleComposerInput, handleComposerInput,
handleComposerEnter,
runShortcut, runShortcut,
askHotKnowledgeQuestion, askHotKnowledgeQuestion,
resolveKnowledgeRankLabel, resolveKnowledgeRankLabel,
resolveKnowledgeRankTone, resolveKnowledgeRankTone,
toggleFlowPanel,
openFlowPanel,
refreshFlowRunDetail,
formatFlowStepDuration,
toggleInsightPanel, toggleInsightPanel,
toggleReviewDocumentDrawer, toggleReviewDocumentDrawer,
toggleReviewRiskDrawer, toggleReviewRiskDrawer,
@@ -3819,6 +4618,7 @@ export default {
removeAttachedFile, removeAttachedFile,
clearAttachedFiles, clearAttachedFiles,
requestCloseWorkbench, requestCloseWorkbench,
emitCloseAfterLeave,
openExpenseQueryRecord, openExpenseQueryRecord,
setExpenseQueryPage, setExpenseQueryPage,
shiftExpenseQueryPage, shiftExpenseQueryPage,