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:
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user