feat: 添加风险规则及 agent assets 功能增强

This commit is contained in:
caoxiaozhu
2026-05-19 16:19:03 +00:00
parent d460ee0fe7
commit 54ffef66d3
52 changed files with 26036 additions and 25171 deletions

View File

@@ -0,0 +1,32 @@
{
"schema_version": "1.0",
"rule_code": "risk.expense.consecutive_transport_receipts",
"name": "连号交通票据",
"enabled": true,
"risk_dimension": "consecutive_receipts",
"ontology_signal": "consecutive_transport_receipts",
"evaluator": "consecutive_transport_receipts",
"applies_to": {
"expense_types": ["transport", "travel"],
"min_attachments": 2
},
"inputs": {
"invoice_no": "attachment.invoice_no"
},
"params": {
"min_consecutive_count": 3
},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 三、车辆交通 / 连号票集中报销",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"rule_code": "risk.expense.entertainment_missing_detail",
"name": "招待费事由不完整",
"enabled": true,
"risk_dimension": "entertainment_detail",
"ontology_signal": "entertainment_missing_detail",
"evaluator": "entertainment_reason_missing",
"applies_to": {
"domains": ["meal"]
},
"inputs": {
"reason": "claim.reason_corpus"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 三、餐费招待 / 业务招待无事由对象",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.expense.meal_localized_as_travel",
"name": "同城餐饮混入差旅",
"enabled": true,
"risk_dimension": "meal_travel_mix",
"ontology_signal": "meal_as_travel",
"evaluator": "meal_as_travel_same_city",
"applies_to": {
"domains": ["travel"]
},
"inputs": {
"declared": "claim.location",
"meal_city": "attachment.cities"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 三、餐费招待 / 同城餐饮归集异地差旅",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"rule_code": "risk.expense.reason_too_brief",
"name": "报销事由过短",
"enabled": true,
"risk_dimension": "reason_quality",
"ontology_signal": "reason_too_brief",
"evaluator": "reason_too_brief",
"applies_to": {},
"inputs": {
"reason": "claim.reason_corpus"
},
"params": {
"min_reason_length": 6
},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 通用 / 事由不足以支撑真实性判断",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,32 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.claimant_buyer_name_match",
"name": "报销人与发票抬头一致",
"enabled": true,
"risk_dimension": "identity_consistency",
"ontology_signal": "buyer_name_mismatch",
"evaluator": "identity_consistency",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"claimant": "claim.employee_name",
"buyer": "attachment.buyer_name"
},
"params": {
"allow_keywords": ["代报", "集团", "公司", "有限公司"]
},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 抬头错误",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.cross_year_invoice",
"name": "跨年发票入账",
"enabled": true,
"risk_dimension": "cross_year_invoice",
"ontology_signal": "cross_year_invoice",
"evaluator": "cross_year_invoice",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"invoice_date": "attachment.invoice_date",
"claim_date": ["claim.occurred_at", "item.item_date"]
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 跨年发票",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.document_expense_mismatch",
"name": "开票内容与报销场景不符",
"enabled": true,
"risk_dimension": "document_expense_mismatch",
"ontology_signal": "document_expense_mismatch",
"evaluator": "document_expense_mismatch",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"document_type": "attachment.document_type",
"expense_type": ["claim.expense_type", "item.item_type"]
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 开票内容与业务不符",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.duplicate_invoice",
"name": "发票重复报销",
"enabled": true,
"risk_dimension": "duplicate_invoice",
"ontology_signal": "duplicate_invoice",
"evaluator": "duplicate_invoice",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"invoice_no": "attachment.invoice_no"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "block"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 重复报销",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.vague_goods_description",
"name": "发票品名过于笼统",
"enabled": true,
"risk_dimension": "vague_goods_description",
"ontology_signal": "vague_goods_description",
"evaluator": "vague_goods_description",
"applies_to": {
"expense_types": ["office", "other"],
"min_attachments": 1
},
"inputs": {
"ocr": "attachment.ocr_text"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 品名笼统",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.invoice.void_or_red_invoice",
"name": "作废或红冲发票",
"enabled": true,
"risk_dimension": "void_or_red_invoice",
"ontology_signal": "void_or_red_invoice",
"evaluator": "invoice_void_or_red",
"applies_to": {
"min_attachments": 1
},
"inputs": {
"status": "attachment.invoice_status",
"ocr": "attachment.ocr_text"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "block"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 二、发票类 / 作废红冲发票",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.base_location_overlap",
"name": "常驻地重合出差风险",
"enabled": true,
"risk_dimension": "base_location_overlap",
"ontology_signal": "base_location_overlap",
"evaluator": "base_location_overlap",
"applies_to": {
"domains": ["travel"]
},
"inputs": {
"employee_base": "employee.location",
"declared": "claim.location"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 一、出差类 / 两头在外",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,29 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.destination_receipt_location",
"name": "申报地点与票据地点一致",
"risk_dimension": "location_consistency",
"ontology_signal": "location_mismatch",
"evaluator": "location_consistency",
"inputs": {
"declared": "claim.location",
"evidence": ["attachment.cities", "item.item_location"]
},
"params": {
"match_mode": "city_fuzzy",
"missing_evidence": "warn"
},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review",
"message_template": "申报地点 {declared} 与票据识别地点 {evidence} 不一致"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"updated_at": "2026-05-18"
}
}

View File

@@ -0,0 +1,32 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.hotel_without_itinerary",
"name": "住宿城市与行程不一致",
"enabled": true,
"risk_dimension": "hotel_itinerary",
"ontology_signal": "hotel_itinerary_mismatch",
"evaluator": "hotel_without_itinerary",
"applies_to": {
"domains": ["travel"],
"expense_types": ["hotel", "travel"]
},
"inputs": {
"declared": "claim.location",
"hotel": "attachment.hotel_city",
"itinerary": "attachment.route_cities"
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 三、住宿费 / 夜间异地住宿、酒店连续多天",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.intracity_travel_claim",
"name": "同城虚报差旅补贴",
"enabled": true,
"risk_dimension": "intracity_travel",
"ontology_signal": "intracity_travel",
"evaluator": "intracity_travel_claim",
"applies_to": {
"domains": ["travel"]
},
"inputs": {
"declared": "claim.location",
"evidence": ["attachment.route", "attachment.cities"]
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "high",
"action": "manual_review"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 一、出差类 / 同城虚报差旅",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,30 @@
{
"schema_version": "1.0",
"rule_code": "risk.travel.multi_city_reason_required",
"name": "多城市行程需说明",
"enabled": true,
"risk_dimension": "multi_city_itinerary",
"ontology_signal": "multi_city_itinerary",
"evaluator": "multi_city_reason_required",
"applies_to": {
"domains": ["travel"]
},
"inputs": {
"reason": "claim.reason_corpus",
"cities": ["attachment.cities", "item.item_location"]
},
"params": {},
"outcomes": {
"pass": { "severity": "none", "action": "continue" },
"fail": {
"severity": "medium",
"action": "warn"
}
},
"metadata": {
"owner": "风控与审计部",
"stability": "platform_builtin",
"source_ref": "常用risk.txt / 一、出差类 / 绕道出行、行程不符",
"updated_at": "2026-05-19"
}
}

View File

@@ -0,0 +1,28 @@
#!/usr/bin/env python3
"""Sync platform risk rule assets from server/rules/risk-rules/*.json."""
from __future__ import annotations
import sys
from pathlib import Path
SERVER_SRC = Path(__file__).resolve().parents[1] / "src"
if str(SERVER_SRC) not in sys.path:
sys.path.insert(0, str(SERVER_SRC))
from app.db.session import get_session_factory # noqa: E402
from app.services.agent_foundation import AgentFoundationService # noqa: E402
def main() -> None:
db = get_session_factory()()
try:
count = AgentFoundationService(db).sync_platform_risk_rules_from_library()
db.commit()
print(f"Synced {count} risk rule manifest(s) from library.")
finally:
db.close()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,13 @@
#!/usr/bin/env python3
import json
import urllib.request
base = "http://127.0.0.1:8000/api/v1"
items = json.loads(urllib.request.urlopen(f"{base}/agent-assets?asset_type=rule").read())
risk = next((i for i in items if str(i.get("code", "")).startswith("risk.")), None)
print("risk asset:", risk.get("code") if risk else None)
if not risk:
raise SystemExit(1)
resp = urllib.request.urlopen(f"{base}/agent-assets/{risk['id']}/rule-json")
payload = json.loads(resp.read())
print("rule-json ok:", payload.get("file_name"), payload.get("evaluator"))

View File

@@ -27,7 +27,6 @@ from app.schemas.agent_asset import (
AgentAssetRuleJsonWrite,
AgentAssetSpreadsheetChangeRecordRead,
AgentAssetUpdate,
AgentAssetVersionCompareRead,
AgentAssetVersionCreate,
AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead,
@@ -167,7 +166,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
db: DbSession,
version: Annotated[
str | None,
Query(description="可选的规则版本号;不传时默认当前版本"),
Query(description="兼容旧前端的可选参数;表格规则始终打开当前规则表"),
] = None,
) -> AgentAssetOnlyOfficeConfigRead:
try:
@@ -184,7 +183,7 @@ def get_agent_asset_spreadsheet_onlyoffice_config(
"/{asset_id}/spreadsheet/content",
response_class=FileResponse,
summary="下载或预览规则 Excel 文件",
description="按版本返回规则 Excel 快照,用于浏览器预览或下载。",
description="返回当前规则 Excel 文件,用于浏览器预览或下载。",
)
def get_agent_asset_spreadsheet_content(
asset_id: str,
@@ -192,7 +191,7 @@ def get_agent_asset_spreadsheet_content(
db: DbSession,
version: Annotated[
str | None,
Query(description="可选的规则版本号;不传时默认当前版本"),
Query(description="兼容旧前端的可选参数;不传时返回当前规则表"),
] = None,
) -> FileResponse:
try:
@@ -215,18 +214,18 @@ def get_agent_asset_spreadsheet_content(
def get_agent_asset_spreadsheet_onlyoffice_content(
asset_id: str,
db: DbSession,
version: Annotated[
str,
Query(min_length=1, description="规则版本号。"),
],
access_token: Annotated[
str,
Query(min_length=1, description="ONLYOFFICE 临时访问令牌。"),
],
version: Annotated[
str | None,
Query(description="兼容旧 ONLYOFFICE URL当前表格模式不再使用。"),
] = None,
) -> FileResponse:
try:
service = AgentAssetService(db)
service.validate_rule_spreadsheet_access_token(asset_id, version, access_token)
service.validate_rule_spreadsheet_access_token(asset_id, access_token)
file_path, media_type, filename = service.get_rule_spreadsheet_content(
asset_id,
version=version,
@@ -246,7 +245,7 @@ def get_agent_asset_spreadsheet_onlyoffice_content(
response_model=AgentAssetRead,
status_code=status.HTTP_201_CREATED,
summary="上传规则 Excel 文件",
description="为指定规则上传新的 Excel 快照,并自动生成新规则版本",
description="为指定规则上传新的 Excel 文件,并记录本次表格修改",
)
def upload_agent_asset_spreadsheet(
asset_id: str,
@@ -311,16 +310,16 @@ def import_agent_asset_spreadsheet_content(
"/{asset_id}/spreadsheet/onlyoffice/callback",
response_model=AgentAssetOnlyOfficeCallbackRead,
summary="接收规则 Excel 的 ONLYOFFICE 回调",
description="接收 ONLYOFFICE 回写内容,并自动生成新的规则版本",
description="接收 ONLYOFFICE 回写内容,并记录本次表格修改",
)
def handle_agent_asset_spreadsheet_onlyoffice_callback(
asset_id: str,
payload: AgentAssetOnlyOfficeCallbackWrite,
db: DbSession,
version: Annotated[
str,
Query(min_length=1, description="打开编辑器时对应的规则版本号"),
],
str | None,
Query(description="兼容旧 ONLYOFFICE 回调;当前表格模式不再使用"),
] = None,
actor_name: Annotated[
str | None,
Query(description="发起编辑的用户显示名。"),
@@ -601,25 +600,3 @@ def get_agent_asset_version_timeline(
except Exception as exc:
_handle_asset_error(exc)
@router.get(
"/{asset_id}/versions/compare",
response_model=AgentAssetVersionCompareRead,
summary="比较两个规则表版本",
description="对比两个 Excel 规则表版本的工作表变化与单元格级差异。",
)
def compare_agent_asset_spreadsheet_versions(
asset_id: str,
_: CurrentUser,
db: DbSession,
base_version: Annotated[str, Query(min_length=1, description="基准版本号")],
target_version: Annotated[str, Query(min_length=1, description="对比版本号")],
) -> AgentAssetVersionCompareRead:
try:
return AgentAssetService(db).compare_spreadsheet_versions(
asset_id,
base_version=base_version,
target_version=target_version,
)
except Exception as exc:
_handle_asset_error(exc)

View File

@@ -133,22 +133,10 @@ class AgentAssetSpreadsheetDiffSheetRead(BaseModel):
change_type: str
class AgentAssetVersionCompareRead(BaseModel):
base_version: str
target_version: str
added_sheet_count: int = 0
removed_sheet_count: int = 0
changed_sheet_count: int = 0
changed_cell_count: int = 0
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)
class AgentAssetSpreadsheetChangeRecordRead(BaseModel):
id: str
actor: str
changed_at: datetime
version: str | None = None
summary: str
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead] = Field(default_factory=list)
cell_changes: list[AgentAssetSpreadsheetDiffCellRead] = Field(default_factory=list)

View File

@@ -36,7 +36,6 @@ from app.schemas.agent_asset import (
AgentAssetSpreadsheetDiffCellRead,
AgentAssetSpreadsheetDiffSheetRead,
AgentAssetUpdate,
AgentAssetVersionCompareRead,
AgentAssetVersionCreate,
AgentAssetVersionRead,
AgentAssetVersionTimelineItemRead,
@@ -511,18 +510,16 @@ class AgentAssetService:
return self._build_onlyoffice_spreadsheet_config(
asset_id=asset_id,
current_user=current_user,
resolved_version=resolved_version,
metadata=metadata,
editable=resolved_version == PREVIEW_RULE_CURRENT_VERSION,
)
asset = self._require_spreadsheet_rule(asset_id)
resolved_version, metadata = self._resolve_current_spreadsheet_meta(asset)
_, metadata = self._resolve_current_spreadsheet_meta(asset)
editable = self._can_edit_current_spreadsheet(current_user)
return self._build_onlyoffice_spreadsheet_config(
asset_id=asset.id,
current_user=current_user,
resolved_version=resolved_version,
metadata=metadata,
editable=editable,
)
@@ -555,7 +552,6 @@ class AgentAssetService:
def validate_rule_spreadsheet_access_token(
self,
asset_id: str,
version: str,
access_token: str,
) -> None:
onlyoffice_settings = resolve_onlyoffice_settings()
@@ -571,7 +567,6 @@ class AgentAssetService:
if (
payload.get("scope") != "agent-asset-spreadsheet"
or payload.get("asset_id") != asset_id
or payload.get("version") != version
):
raise ValueError("ONLYOFFICE 文件访问令牌无效。")
@@ -604,7 +599,6 @@ class AgentAssetService:
)
changed_sheet_count = self._count_changed_sheets(sheet_changes, cell_changes)
changed_cell_count = len(cell_changes)
next_version = self._next_available_version(asset)
metadata = self._store_current_rule_spreadsheet(
asset,
@@ -613,45 +607,10 @@ class AgentAssetService:
actor=actor,
source=source,
)
snapshot_metadata = self.spreadsheet_manager.store_rule_library_spreadsheet_snapshot(
library=self._resolve_spreadsheet_rule_library(asset),
asset_id=asset.id,
version=next_version,
file_name=file_name,
content=content,
actor_name=actor,
source=source,
)
operation_label = (
change_note
or (
"ONLYOFFICE 在线编辑"
if source == "onlyoffice"
else f"上传并覆盖当前规则表:{normalized_name}"
)
)
summary = self._build_spreadsheet_change_summary(
operation_label,
sheet_changes,
cell_changes,
)
version_content = self.spreadsheet_manager.build_version_markdown(
rule_name=asset.name,
version=next_version,
metadata=snapshot_metadata,
)
self.create_version(
asset.id,
AgentAssetVersionCreate(
version=next_version,
content=version_content,
content_type=AgentAssetContentType.MARKDOWN,
change_note=summary,
created_by=actor,
),
actor=actor,
request_id=request_id,
)
self.audit_service.log_action(
actor=actor,
action="edit_rule_spreadsheet",
@@ -660,13 +619,11 @@ class AgentAssetService:
before_json={"storage_key": current_metadata.storage_key},
after_json={
"summary": summary,
"version": next_version,
"changed_sheet_count": changed_sheet_count,
"changed_cell_count": changed_cell_count,
"sheet_changes": [item.model_dump() for item in sheet_changes],
"cell_changes": [item.model_dump() for item in cell_changes[:500]],
"storage_key": metadata.storage_key,
"snapshot_storage_key": snapshot_metadata.storage_key,
},
request_id=request_id,
)
@@ -705,7 +662,7 @@ class AgentAssetService:
self,
asset_id: str,
*,
version: str,
version: str | None = None,
payload: dict[str, Any],
actor_name: str | None = None,
) -> None:
@@ -721,8 +678,6 @@ class AgentAssetService:
callback = self._parse_onlyoffice_callback(payload)
if callback.status not in {2, 6} or not callback.download_url:
return
if str(version or "").strip() not in {"", "current", self._resolve_working_version(asset)}:
return
_, current_metadata = self._resolve_current_spreadsheet_meta(asset)
request = Request(
@@ -924,44 +879,6 @@ class AgentAssetService:
return sorted(events, key=lambda item: item.event_time)
def compare_spreadsheet_versions(
self,
asset_id: str,
*,
base_version: str,
target_version: str,
) -> AgentAssetVersionCompareRead:
self._ensure_ready()
asset = self._require_spreadsheet_rule(asset_id)
resolved_base, base_meta = self._resolve_spreadsheet_version_meta(
asset,
version=base_version,
)
resolved_target, target_meta = self._resolve_spreadsheet_version_meta(
asset,
version=target_version,
)
base_workbook = self._load_spreadsheet_for_compare(base_meta)
target_workbook = self._load_spreadsheet_for_compare(target_meta)
sheet_changes, cell_changes = self._collect_workbook_changes(
base_workbook,
target_workbook,
)
added_sheet_count = sum(1 for item in sheet_changes if item.change_type == "added")
removed_sheet_count = sum(1 for item in sheet_changes if item.change_type == "removed")
return AgentAssetVersionCompareRead(
base_version=resolved_base,
target_version=resolved_target,
added_sheet_count=added_sheet_count,
removed_sheet_count=removed_sheet_count,
changed_sheet_count=self._count_changed_sheets(sheet_changes, cell_changes),
changed_cell_count=len(cell_changes),
sheet_changes=sheet_changes,
cell_changes=cell_changes[:500],
)
def list_spreadsheet_change_records(
self,
asset_id: str,
@@ -981,8 +898,7 @@ class AgentAssetService:
id=log.id,
actor=log.actor,
changed_at=log.created_at,
version=str((log.after_json or {}).get("version") or "").strip() or None,
summary=str((log.after_json or {}).get("summary") or "ONLYOFFICE 在线编辑保存。"),
summary=str((log.after_json or {}).get("summary") or "表格内容已保存。"),
sheet_changes=[
AgentAssetSpreadsheetDiffSheetRead.model_validate(item)
for item in ((log.after_json or {}).get("sheet_changes") or [])
@@ -1292,7 +1208,6 @@ class AgentAssetService:
*,
asset_id: str,
current_user: CurrentUserContext,
resolved_version: str,
metadata: RuleSpreadsheetMeta,
editable: bool,
) -> AgentAssetOnlyOfficeConfigRead:
@@ -1307,21 +1222,21 @@ class AgentAssetService:
backend_base_url = onlyoffice_settings.backend_url.rstrip("/")
public_url = onlyoffice_settings.public_url.rstrip("/")
access_token = self._build_onlyoffice_access_token(asset_id, resolved_version)
access_token = self._build_onlyoffice_access_token(asset_id)
document_url = (
f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/content"
f"?version={resolved_version}&access_token={access_token}"
f"?access_token={access_token}"
)
callback_url = (
f"{backend_base_url}{settings.api_v1_prefix}/agent-assets/{asset_id}/spreadsheet/onlyoffice/callback"
f"?version={resolved_version}&actor_name={quote(current_user.name)}"
f"?actor_name={quote(current_user.name)}"
)
config: dict[str, Any] = {
"documentType": "cell",
"document": {
"fileType": Path(metadata.file_name).suffix.lstrip(".").lower() or "xlsx",
"key": self._build_onlyoffice_document_key(asset_id, resolved_version, metadata),
"key": self._build_onlyoffice_document_key(asset_id, metadata),
"title": metadata.file_name,
"url": document_url,
"permissions": {
@@ -1462,19 +1377,6 @@ class AgentAssetService:
major, minor, patch = [int(item) for item in parts]
return f"v{major}.{minor}.{patch + 1}"
@staticmethod
def _can_edit_spreadsheet_version(
asset: AgentAsset,
current_user: CurrentUserContext,
version: str,
) -> bool:
role_codes = {str(item).strip() for item in current_user.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()
)
@staticmethod
def _can_edit_current_spreadsheet(current_user: CurrentUserContext) -> bool:
role_codes = {str(item).strip() for item in current_user.role_codes}
@@ -1483,23 +1385,21 @@ class AgentAssetService:
@staticmethod
def _build_onlyoffice_document_key(
asset_id: str,
version: str,
metadata: RuleSpreadsheetMeta,
) -> str:
fingerprint = metadata.checksum or metadata.updated_at or metadata.file_name
raw_key = f"{asset_id}-{version}-{fingerprint}"
raw_key = f"{asset_id}-{fingerprint}"
return "".join(
character if character.isalnum() or character in {"-", "_", ".", "="} else "_"
for character in raw_key
)
@staticmethod
def _build_onlyoffice_access_token(asset_id: str, version: str) -> str:
def _build_onlyoffice_access_token(asset_id: str) -> str:
onlyoffice_settings = resolve_onlyoffice_settings()
payload = {
"scope": "agent-asset-spreadsheet",
"asset_id": asset_id,
"version": version,
}
return jwt.encode(payload, onlyoffice_settings.jwt_secret, algorithm="HS256")
@@ -1646,7 +1546,6 @@ class AgentAssetService:
@staticmethod
def _build_spreadsheet_change_summary(
operation_label: str,
sheet_changes: list[AgentAssetSpreadsheetDiffSheetRead],
cell_changes: list[AgentAssetSpreadsheetDiffCellRead],
) -> str:
@@ -1655,15 +1554,15 @@ class AgentAssetService:
| {item.sheet_name for item in cell_changes}
)
if not sheet_names:
return f"{operation_label}文件内容已保存,未发现单元格级差异。"
return "文件内容已保存,未发现单元格级差异。"
preview = "".join(sheet_names[:3])
if len(sheet_names) > 3:
preview = f"{preview}"
sheet_text = f"涉及 {len(sheet_names)} 个工作表({preview}"
if cell_changes:
return f"{operation_label}{sheet_text},共 {len(cell_changes)} 处单元格改动。"
return f"{operation_label}{sheet_text},工作表结构发生变化。"
return f"{sheet_text},共 {len(cell_changes)} 处单元格改动。"
return f"{sheet_text},工作表结构发生变化。"
def _next_available_version(self, asset: AgentAsset) -> str:
candidate = self._increment_version(self._resolve_working_version(asset))

View File

@@ -3189,7 +3189,24 @@ class UserAgentService:
evidence="来源于用户修改后的结构化表单。",
)
inferred_reason = self._infer_reason_from_claim_groups(
claim_groups=claim_groups,
)
reason_value = self._resolve_reason_text(self._resolve_reason_source_text(payload))
if inferred_reason:
return self._build_slot_value(
value=inferred_reason,
raw_value=reason_value or inferred_reason,
normalized_value=inferred_reason,
source="ocr",
confidence=0.82,
evidence=(
"系统已根据票据识别结果预置场景类型;原始描述仍保留为补充说明。"
if reason_value
else "系统已根据票据识别场景补全通用事由,若需更具体说明可继续修改。"
),
)
if reason_value:
return self._build_slot_value(
value=reason_value,
@@ -3199,19 +3216,6 @@ class UserAgentService:
confidence=0.76,
evidence="系统从用户原始描述中提取了本次费用事由,建议继续核对。",
)
inferred_reason = self._infer_reason_from_claim_groups(
claim_groups=claim_groups,
)
if inferred_reason:
return self._build_slot_value(
value=inferred_reason,
raw_value=inferred_reason,
normalized_value=inferred_reason,
source="ocr",
confidence=0.68,
evidence="系统已根据票据识别场景补全通用事由,若需更具体说明可继续修改。",
)
return self._build_slot_value()
def _build_amount_slot(

View File

@@ -35,13 +35,13 @@
"updated_at": "2026-05-17T13:00:09.485818+00:00",
"uploaded_by": "admin",
"version_number": 1,
"ingest_status": 1,
"ingest_status_updated_at": "2026-05-17T13:00:09.485818+00:00",
"ingest_status": 4,
"ingest_status_updated_at": "2026-05-19T16:00:57.418443+00:00",
"ingest_completed_at": "",
"ingest_document_name": "",
"ingest_document_updated_at": "",
"ingest_document_sha256": "",
"ingest_agent_run_id": ""
"ingest_agent_run_id": "run_57f2d8727aaa4374"
}
]
}

View File

@@ -24,5 +24,28 @@
"processing_start_time": 1779011842,
"processing_end_time": 1779012093
}
},
"a8f8465df08e455ebe133351721d49f8": {
"status": "failed",
"error_msg": "Embedding func: Worker execution timeout after 60s",
"chunks_count": 6,
"chunks_list": [
"chunk-07de6ea74f60535b689f977295770273",
"chunk-99c6f377dff2b9a37a7214b7b05ea9a8",
"chunk-1746bd83138e85e66a78e0cb9ad79272",
"chunk-ce44e4483e4119265b43eacb72e0326a",
"chunk-2187fa0609874bdda339c9850da45a26",
"chunk-2224d777c0b72d0b2dab622c79096c2c"
],
"content_summary": "# 产品需求文档\n## 文档信息\n| 项目 | 内容 |\n|------|------|\n| 项目名称 |\n无单报销\n|\n| 版本 | V1.0 |\n| 日期 | 2026-05-06 |\n| 状态 | 正式版 |\n---\n## 1. 项目概述\n### 1.1 项目背景\n面向\n大型企业\n从业务人员视角出发解决现有ERP使用体验不佳的问题。\n在ERP的发展历程中“单据化”曾是财务合规的一大进步它确保了每笔支出都有据可查。但不可否认传统的人工填单确实\n也制造了很多\n“枷锁”。在AI时代解...",
"content_length": 9088,
"created_at": "2026-05-19T15:59:57.283110+00:00",
"updated_at": "2026-05-19T16:00:57.323299+00:00",
"file_path": "/app/server/storage/knowledge/报销制度/a8f8465df08e455ebe133351721d49f8__无单需求文档0506.docx",
"track_id": "insert_20260519_155957_88c49850",
"metadata": {
"processing_start_time": 1779206397,
"processing_end_time": 1779206457
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -15,9 +15,8 @@ def test_rule_spreadsheet_onlyoffice_key_uses_safe_characters() -> None:
key = AgentAssetService._build_onlyoffice_document_key(
"asset:id",
"v1.0.0",
metadata,
)
assert key == "asset_id-v1.0.0-abc123"
assert key == "asset_id-abc123"
assert ":" not in key

View File

@@ -310,7 +310,7 @@ def test_restore_version_creates_new_working_copy_without_rewriting_published_ve
assert restored.current_version_change_note == "基于历史版本 v1.0.0 恢复生成工作稿"
def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
def test_spreadsheet_upload_records_sheet_and_cell_changes_without_versions() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
@@ -322,34 +322,33 @@ def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
service.upload_rule_spreadsheet(
rule.id,
filename="公司差旅费报销规则.xlsx",
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
content=build_workbook_bytes([["城市", "住宿"], ["北京", 500]]),
actor="finance_user",
)
base_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
service.upload_rule_spreadsheet(
rule.id,
filename="公司差旅费报销规则.xlsx",
content=build_workbook_bytes([["城市", "住宿"], ["北京", 550], ["武汉", 450]]),
actor="finance_user",
)
target_version = service.get_asset(rule.id).working_version # type: ignore[union-attr]
diff = service.compare_spreadsheet_versions(
rule.id,
base_version=base_version or "",
target_version=target_version or "",
)
records = service.list_spreadsheet_change_records(rule.id)
latest = records[0]
assert diff.changed_sheet_count == 1
assert diff.changed_cell_count == 3
assert latest.changed_sheet_count == 1
assert latest.changed_cell_count == 3
assert any(
item.cell == "B2" and item.change_type == "modified"
for item in diff.cell_changes
for item in latest.cell_changes
)
assert any(item.cell == "A3" and item.change_type == "added" for item in diff.cell_changes)
assert any(
item.cell == "A3" and item.change_type == "added"
for item in latest.cell_changes
)
assert not hasattr(latest, "version")
def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_copy() -> None:
def test_spreadsheet_content_reads_current_rule_file_without_version_snapshot() -> None:
with build_session() as db:
service = AgentAssetService(db)
rule = next(
@@ -366,7 +365,6 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
)
detail = service.get_asset(rule.id)
assert detail is not None
working_version = detail.working_version or ""
current_asset = service.repository.get(rule.id)
assert current_asset is not None
@@ -375,23 +373,13 @@ def test_working_spreadsheet_version_reads_immutable_snapshot_instead_of_live_co
assert "agent_assets" not in live_storage_key
live_path = service.spreadsheet_manager.resolve_storage_path(live_storage_key)
assert not service.spreadsheet_manager.asset_root.exists()
original_live_bytes = live_path.read_bytes()
try:
live_path.write_bytes(build_workbook_bytes([["城市", "住宿"], ["北京", 999]]))
snapshot_path, _, _ = service.get_rule_spreadsheet_content(
rule.id,
version=working_version,
)
current_path, _, _ = service.get_rule_spreadsheet_content(rule.id)
assert snapshot_path != live_path
assert FINANCE_RULES_LIBRARY in snapshot_path.parts
assert ".versions" in snapshot_path.parts
assert "agent_assets" not in snapshot_path.parts
workbook = load_workbook(snapshot_path, data_only=False)
assert workbook.active["B2"].value == 500
finally:
live_path.write_bytes(original_live_bytes)
assert current_path == live_path
assert ".versions" not in current_path.parts
workbook = load_workbook(current_path, data_only=False)
assert workbook.active["B2"].value == 500
def test_spreadsheet_change_records_return_recent_edit_details() -> None:
@@ -454,7 +442,6 @@ def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
)
detail = service.get_asset(rule.id)
assert detail is not None
first_version = detail.working_version
service.upload_rule_spreadsheet(
rule.id,
@@ -473,7 +460,7 @@ def test_spreadsheet_change_records_include_all_modified_sheets() -> None:
changed_sheets = {item.sheet_name for item in latest.sheet_changes}
changed_cell_sheets = {item.sheet_name for item in latest.cell_changes}
assert latest.version != first_version
assert not hasattr(latest, "version")
assert latest.changed_sheet_count == 2
assert {"差旅标准", "填表说明"}.issubset(changed_sheets)
assert {"差旅标准", "填表说明"}.issubset(changed_cell_sheets)
@@ -513,6 +500,8 @@ def test_editable_spreadsheet_onlyoffice_config_enables_forcesave(monkeypatch) -
customization = config.config["editorConfig"]["customization"]
assert config.config["editorConfig"]["mode"] == "edit"
assert customization["forcesave"] is True
assert "version=" not in config.config["document"]["url"]
assert "version=" not in config.config["editorConfig"]["callbackUrl"]
def test_version_timeline_contains_created_review_and_publish_events() -> None:

BIN
web/UI/流程输入.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -942,7 +942,7 @@ tbody tr.spotlight {
line-height: 1.5;
}
.spreadsheet-version-center {
.spreadsheet-change-center {
min-height: 0;
height: 100%;
align-self: stretch;
@@ -956,20 +956,20 @@ tbody tr.spotlight {
overflow: hidden;
}
.version-center-head h3,
.version-center-head p,
.version-center-section header,
.version-center-section p {
.change-center-head h3,
.change-center-head p,
.change-center-section header,
.change-center-section p {
margin: 0;
}
.version-center-head h3 {
.change-center-head h3 {
color: #0f172a;
font-size: 15px;
font-weight: 900;
}
.version-center-head p {
.change-center-head p {
margin-top: 3px;
color: #64748b;
font-size: 12px;
@@ -1030,37 +1030,37 @@ tbody tr.spotlight {
color: #2563eb;
}
.version-center-section {
.change-center-section {
display: grid;
gap: 8px;
}
.version-history-section {
.change-history-section {
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
.version-center-section > header {
.change-center-section > header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.version-center-section > header strong {
.change-center-section > header strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.version-center-section > header small,
.version-center-section > header button {
.change-center-section > header small,
.change-center-section > header button {
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.version-center-section > header button {
.change-center-section > header button {
padding: 0;
border: 0;
background: transparent;
@@ -1068,7 +1068,7 @@ tbody tr.spotlight {
cursor: pointer;
}
.version-center-list {
.change-center-list {
display: grid;
align-content: start;
gap: 8px;
@@ -1077,7 +1077,7 @@ tbody tr.spotlight {
padding-right: 2px;
}
.version-center-item {
.change-center-item {
display: grid;
gap: 8px;
padding: 10px;
@@ -1086,12 +1086,12 @@ tbody tr.spotlight {
background: #fff;
}
.version-center-item.active {
.change-center-item.active {
border-color: rgba(16, 185, 129, 0.35);
background: rgba(16, 185, 129, 0.05);
}
.version-center-item > button {
.change-center-item > button {
display: grid;
gap: 5px;
padding: 0;
@@ -1101,31 +1101,31 @@ tbody tr.spotlight {
cursor: pointer;
}
.version-center-item > button:disabled {
.change-center-item > button:disabled {
cursor: default;
}
.version-center-item > button div {
.change-center-item > button div {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.version-center-item > button strong {
.change-center-item > button strong {
color: #0f172a;
font-size: 13px;
font-weight: 900;
}
.version-center-item > button span,
.version-center-item > button p,
.version-center-item > button small {
.change-center-item > button span,
.change-center-item > button p,
.change-center-item > button small {
color: #64748b;
font-size: 11px;
}
.version-center-item > button p {
.change-center-item > button p {
margin: 0;
line-height: 1.45;
}
@@ -1250,7 +1250,7 @@ tbody tr.spotlight {
font-weight: 900;
}
.version-flow-empty {
.change-flow-empty {
color: #64748b;
font-size: 11px;
}

View File

@@ -1,9 +1,17 @@
.assistant-overlay {
/* 距屏幕边 1018px随视口微调高度用 dvh 适配笔记本浏览器工具栏 */
--assistant-viewport-inset: clamp(10px, 1.25vmin, 18px);
position: fixed;
inset: 0;
width: 100vw;
height: 100dvh;
max-height: 100dvh;
z-index: 9999;
display: grid;
place-items: center;
display: flex;
align-items: stretch;
justify-content: stretch;
padding: var(--assistant-viewport-inset);
box-sizing: border-box;
background:
radial-gradient(circle at 18% 14%, rgba(16, 185, 129, 0.18), transparent 24%),
radial-gradient(circle at 82% 12%, rgba(59, 130, 246, 0.12), transparent 28%),
@@ -13,23 +21,17 @@
}
.assistant-modal {
--assistant-base-width: 1430;
--assistant-base-height: 820;
--assistant-base-width-px: 1430px;
--assistant-base-height-px: 820px;
--assistant-safe-offset-x: 64;
--assistant-safe-offset-y: 48;
--assistant-fit-scale-width: calc((var(--desktop-viewport-width, 1440) - var(--assistant-safe-offset-x)) / var(--assistant-base-width));
--assistant-fit-scale-height: calc((var(--desktop-viewport-height, 900) - var(--assistant-safe-offset-y)) / var(--assistant-base-height));
--assistant-scale: min(1, var(--assistant-fit-scale-width), var(--assistant-fit-scale-height));
width: calc(var(--assistant-base-width-px) * var(--assistant-scale));
height: calc(var(--assistant-base-height-px) * var(--assistant-scale));
position: relative;
display: block;
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
min-height: 0;
background: transparent;
box-shadow: none;
border: 0;
border-radius: 30px;
border-radius: 24px;
backdrop-filter: none;
-webkit-backdrop-filter: none;
overflow: hidden;
@@ -37,14 +39,36 @@
}
.assistant-modal-stage {
/* 工作台字号令牌:笔记本断点见文末 @media */
--wb-fs-title: 22px;
--wb-fs-desc: 13px;
--wb-fs-badge: 12px;
--wb-fs-bubble: 14px;
--wb-fs-bubble-meta: 13px;
--wb-fs-bubble-time: 12px;
--wb-fs-chip: 12px;
--wb-fs-composer: 14px;
--wb-fs-tool-icon: 18px;
--wb-fs-md-h1: 18px;
--wb-fs-md-h2: 16px;
--wb-fs-md-h3: 14px;
--wb-fs-insight-title: 19px;
--wb-fs-insight-num: 19px;
--wb-fs-insight-body: 12px;
--wb-fs-insight-h4: 15px;
--wb-fs-metric: 13px;
--wb-fs-metric-strong: 13px;
--wb-fs-welcome: 20px;
position: relative;
width: var(--assistant-base-width-px);
height: var(--assistant-base-height-px);
flex: 1;
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
transform: scale(var(--assistant-scale));
transform-origin: top left;
border-radius: 30px;
transform: none;
border-radius: 24px;
background:
radial-gradient(circle at top right, rgba(16, 185, 129, 0.14), transparent 26%),
radial-gradient(circle at top left, rgba(59, 130, 246, 0.10), transparent 24%),
@@ -64,7 +88,8 @@
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 172px 18px 26px;
flex-shrink: 0;
padding: clamp(14px, 2vh, 22px) clamp(148px, 11vw, 172px) clamp(12px, 1.6vh, 18px) clamp(18px, 2vw, 26px);
border-bottom: 1px solid rgba(203, 213, 225, 0.78);
background: linear-gradient(180deg, rgba(247, 250, 249, 0.82) 0%, rgba(240, 246, 244, 0.7) 100%);
}
@@ -85,7 +110,7 @@
border-radius: 999px;
background: linear-gradient(135deg, #22c55e, #10b981);
color: #fff;
font-size: 12px;
font-size: var(--wb-fs-badge);
font-weight: 800;
box-shadow: 0 8px 16px rgba(16, 185, 129, 0.14);
white-space: nowrap;
@@ -98,38 +123,39 @@
.assistant-header h2 {
color: #0f172a;
font-size: 22px;
font-size: clamp(17px, 1.1vw, var(--wb-fs-title));
font-weight: 900;
letter-spacing: 0.01em;
line-height: 1.25;
}
.assistant-header p {
margin-top: 4px;
color: #64748b;
font-size: 13px;
font-size: clamp(11px, 0.85vw, var(--wb-fs-desc));
line-height: 1.55;
}
.assistant-header-actions {
position: absolute;
top: calc(22px * var(--assistant-scale));
right: calc(26px * var(--assistant-scale));
z-index: 40;
top: 16px;
right: 16px;
z-index: 60;
display: flex;
align-items: center;
gap: calc(10px * var(--assistant-scale));
gap: 10px;
pointer-events: auto;
}
.assistant-toggle-btn,
.session-trash-btn {
width: calc(38px * var(--assistant-scale));
height: calc(38px * var(--assistant-scale));
width: 38px;
height: 38px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid rgba(248, 113, 113, 0.28);
border-radius: calc(14px * var(--assistant-scale));
border-radius: 14px;
flex: none;
}
@@ -137,7 +163,7 @@
border-color: rgba(16, 185, 129, 0.18);
background: rgba(245, 252, 249, 0.96);
color: #166534;
font-size: calc(16px * var(--assistant-scale));
font-size: 16px;
box-shadow: 0 8px 18px rgba(16, 185, 129, 0.1);
}
@@ -156,7 +182,7 @@
.session-trash-btn {
background: rgba(254, 242, 242, 0.96);
color: #dc2626;
font-size: calc(16px * var(--assistant-scale));
font-size: 16px;
box-shadow: 0 8px 18px rgba(239, 68, 68, 0.12);
}
@@ -174,17 +200,17 @@
.assistant-close-btn,
.close-btn {
position: relative;
width: calc(38px * var(--assistant-scale));
height: calc(38px * var(--assistant-scale));
width: 38px;
height: 38px;
display: grid;
place-items: center;
padding: 0;
flex: none;
border: 1px solid rgba(193, 204, 216, 0.92);
border-radius: calc(14px * var(--assistant-scale));
border-radius: 14px;
background: rgba(248, 251, 251, 0.94);
color: #475569;
font-size: calc(16px * var(--assistant-scale));
font-size: 16px;
box-shadow: 0 8px 18px rgba(148, 163, 184, 0.18);
cursor: pointer;
pointer-events: auto;
@@ -193,7 +219,7 @@
}
.assistant-close-btn {
z-index: 30;
z-index: 61;
pointer-events: auto;
}
@@ -210,9 +236,11 @@
.assistant-layout {
min-height: 0;
flex: 1;
display: flex;
padding: 16px;
padding: clamp(12px, 1.5vw, 16px);
align-items: stretch;
gap: clamp(12px, 1.5vw, 16px);
}
.dialog-panel,
@@ -245,9 +273,10 @@
.insight-panel-shell {
flex: none;
width: clamp(360px, 31vw, 440px);
width: clamp(300px, 28vw, 420px);
min-width: 0;
margin-left: 16px;
max-width: 100%;
margin-left: 0;
overflow: hidden;
transition:
width 360ms cubic-bezier(0.22, 1, 0.36, 1),
@@ -277,7 +306,7 @@
border-radius: 999px;
background: rgba(255, 255, 255, 0.95);
color: #334155;
font-size: 12px;
font-size: var(--wb-fs-chip);
font-weight: 750;
box-shadow: 0 4px 12px rgba(241, 245, 249, 0.78);
white-space: nowrap;
@@ -365,18 +394,18 @@
.message-meta strong {
color: #0f172a;
font-size: 13px;
font-size: var(--wb-fs-bubble-meta);
font-weight: 850;
}
.message-meta time {
color: #94a3b8;
font-size: 12px;
font-size: var(--wb-fs-bubble-time);
}
.message-bubble p {
color: #334155;
font-size: 14px;
font-size: var(--wb-fs-bubble);
}
.message-answer-content {
@@ -402,21 +431,34 @@
}
.message-answer-markdown :deep(h1) {
font-size: 18px;
font-size: var(--wb-fs-md-h1);
}
.message-answer-markdown :deep(h2) {
font-size: 16px;
font-size: var(--wb-fs-md-h2);
}
.message-answer-markdown :deep(h3),
.message-answer-markdown :deep(h4) {
font-size: 14px;
font-size: var(--wb-fs-md-h3);
}
.message-answer-markdown {
overflow-x: auto;
font-size: var(--wb-fs-bubble);
color: #334155;
line-height: 1.65;
}
/* v-html 注入的 Markdown 节点无 scoped 标记,需用 :deep 与用户气泡 p 对齐字号 */
.message-answer-markdown :deep(p),
.message-answer-markdown :deep(li) {
line-height: 1.7;
.message-answer-markdown :deep(li),
.message-answer-markdown :deep(td),
.message-answer-markdown :deep(th),
.message-answer-markdown :deep(blockquote) {
font-size: inherit;
color: inherit;
line-height: 1.65;
}
.message-answer-markdown :deep(ul),
@@ -462,10 +504,6 @@
text-decoration: underline;
}
.message-answer-markdown {
overflow-x: auto;
}
.message-answer-markdown :deep(table) {
width: auto;
max-width: 100%;
@@ -473,7 +511,7 @@
border-radius: 16px;
border-collapse: collapse;
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
font-size: 13px;
font-size: inherit;
}
.message-answer-markdown :deep(th),
@@ -516,7 +554,7 @@
align-items: center;
padding: 0 10px;
border-radius: 999px;
font-size: 12px;
font-size: var(--wb-fs-chip);
font-weight: 800;
}
@@ -960,16 +998,216 @@
display: none;
}
.composer-row {
--composer-control-size: 44px;
}
.composer-leading-actions {
display: flex;
align-items: center;
gap: 8px;
flex: none;
}
.composer-date-anchor {
position: relative;
}
.tool-btn.composer-side-btn.active {
border-color: rgba(59, 130, 246, 0.42);
background: rgba(239, 246, 255, 0.96);
color: #2563eb;
box-shadow: 0 6px 14px rgba(59, 130, 246, 0.14);
}
.composer-date-popover {
position: absolute;
bottom: calc(100% + 10px);
left: 0;
z-index: 30;
width: min(320px, calc(100vw - 48px));
display: grid;
gap: 12px;
padding: 14px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 16px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 18px 40px rgba(15, 23, 42, 0.16),
0 4px 12px rgba(15, 23, 42, 0.06);
}
.composer-date-mode-tabs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
padding: 4px;
border-radius: 12px;
background: rgba(241, 245, 249, 0.92);
}
.composer-date-mode-btn {
min-height: 34px;
border: 0;
border-radius: 10px;
background: transparent;
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.composer-date-mode-btn.active {
background: #fff;
color: #0f172a;
box-shadow: 0 4px 10px rgba(148, 163, 184, 0.18);
}
.composer-date-fields {
display: grid;
gap: 8px;
}
.composer-date-fields-range {
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: end;
gap: 8px;
}
.composer-date-field {
display: grid;
gap: 6px;
min-width: 0;
}
.composer-date-field span {
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.composer-date-field input {
width: 100%;
min-height: 36px;
padding: 0 10px;
border: 1px solid rgba(203, 213, 225, 0.92);
border-radius: 10px;
background: #fff;
color: #0f172a;
font-size: 12px;
font-weight: 700;
}
.composer-date-range-sep {
align-self: center;
color: #94a3b8;
font-size: 12px;
font-weight: 800;
}
.composer-date-hint {
margin: 0;
color: #dc2626;
font-size: 11px;
line-height: 1.5;
}
.composer-date-popover-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
.composer-date-cancel-btn,
.composer-date-apply-btn {
min-height: 34px;
padding: 0 14px;
border-radius: 10px;
font-size: 12px;
font-weight: 800;
}
.composer-date-cancel-btn {
border: 1px solid rgba(203, 213, 225, 0.92);
background: #fff;
color: #64748b;
}
.composer-date-apply-btn {
border: 0;
background: linear-gradient(135deg, #22c55e, #10b981);
color: #fff;
}
.composer-date-apply-btn:disabled {
opacity: 0.48;
cursor: not-allowed;
}
.composer-shell {
min-width: 0;
min-height: var(--composer-control-size, 44px);
border: 1px solid rgba(214, 225, 234, 0.95);
border-radius: 20px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.98);
box-shadow:
0 10px 22px rgba(226, 232, 240, 0.24),
0 1px 4px rgba(15, 23, 42, 0.03);
}
.composer-shell-body {
min-height: var(--composer-control-size, 44px);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
padding: 4px 12px;
}
.composer-biz-time-tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: min(100%, 320px);
min-height: 28px;
padding: 0 8px 0 10px;
border-radius: 999px;
border: 1px solid rgba(59, 130, 246, 0.28);
background: linear-gradient(135deg, rgba(59, 130, 246, 0.14), rgba(16, 185, 129, 0.12));
color: #1d4ed8;
font-size: 11px;
font-weight: 800;
flex: none;
}
.composer-biz-time-tag i {
font-size: 14px;
color: #2563eb;
}
.composer-biz-time-tag-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.composer-biz-time-tag-remove {
width: 18px;
height: 18px;
display: grid;
place-items: center;
padding: 0;
border: 0;
border-radius: 999px;
background: rgba(255, 255, 255, 0.72);
color: #3b82f6;
flex: none;
}
.composer-biz-time-tag-remove:disabled {
opacity: 0.48;
}
.composer-files-panel {
display: grid;
gap: 10px;
@@ -1111,15 +1349,18 @@
}
.composer-shell textarea {
width: 100%;
min-height: 20px;
flex: 1 1 120px;
width: auto;
min-width: 0;
min-height: 36px;
max-height: 120px;
resize: none;
border: 0;
padding: 11px 14px;
padding: 8px 4px;
background: transparent;
color: #0f172a;
font-size: 14px;
line-height: 1.5;
font-size: var(--wb-fs-composer);
line-height: 20px;
}
.composer-shell textarea::placeholder {
@@ -1136,7 +1377,7 @@
.composer-row {
display: flex;
align-items: flex-end;
align-items: center;
gap: 10px;
}
@@ -1145,10 +1386,10 @@
}
.composer-side-btn,
.tool-btn,
.send-btn {
width: 44px;
height: 44px;
.composer-row .tool-btn,
.composer-row .send-btn {
width: var(--composer-control-size, 44px);
height: var(--composer-control-size, 44px);
display: grid;
place-items: center;
border: 0;
@@ -1159,7 +1400,7 @@
.tool-btn {
background: #ffffff;
color: #475569;
font-size: 18px;
font-size: var(--wb-fs-tool-icon);
border: 1px solid #dbe6f0;
box-shadow: 0 4px 12px rgba(241, 245, 249, 0.76);
}
@@ -1172,7 +1413,7 @@
.send-btn {
background: linear-gradient(135deg, #22c55e, #10b981);
color: #fff;
font-size: 16px;
font-size: var(--wb-fs-tool-icon);
box-shadow: 0 8px 18px rgba(16, 185, 129, 0.18);
}
@@ -1186,7 +1427,7 @@
position: relative;
display: grid;
grid-template-rows: auto minmax(0, 1fr);
width: clamp(360px, 31vw, 440px);
width: 100%;
height: 100%;
overflow: hidden;
background:
@@ -1338,7 +1579,7 @@
align-items: center;
padding: 0 13px;
border-radius: 999px;
font-size: 12px;
font-size: var(--wb-fs-chip);
font-weight: 800;
}
@@ -1370,7 +1611,7 @@
.insight-head h3 {
margin-top: 10px;
color: #0f172a;
font-size: 19px;
font-size: var(--wb-fs-insight-title);
font-weight: 900;
line-height: 1.25;
}
@@ -1378,7 +1619,7 @@
.insight-head p {
margin-top: 6px;
color: #64748b;
font-size: 12px;
font-size: var(--wb-fs-insight-body);
line-height: 1.6;
}
@@ -1403,7 +1644,7 @@
display: block;
margin-top: 4px;
color: #0f172a;
font-size: 19px;
font-size: var(--wb-fs-insight-num);
font-weight: 900;
}
@@ -1439,7 +1680,7 @@
gap: 8px;
flex-wrap: wrap;
color: #475569;
font-size: 13px;
font-size: var(--wb-fs-metric);
}
.review-side-intent-row i {
@@ -1449,7 +1690,7 @@
.review-side-intent-row strong {
color: #0f172a;
font-size: 14px;
font-size: var(--wb-fs-bubble);
font-weight: 850;
}
@@ -1513,7 +1754,7 @@
.review-side-metric-copy strong {
color: #0f172a;
font-size: 13px;
font-size: var(--wb-fs-metric-strong);
font-weight: 850;
line-height: 1.35;
word-break: break-word;
@@ -2256,7 +2497,7 @@
.welcome-card p,
.note-block p {
color: #64748b;
font-size: 13px;
font-size: var(--wb-fs-metric);
line-height: 1.6;
}
@@ -2279,7 +2520,7 @@
.welcome-card strong,
.note-block strong {
color: #0f172a;
font-size: 14px;
font-size: var(--wb-fs-bubble);
font-weight: 850;
}
@@ -3119,7 +3360,7 @@
}
.review-conclusion strong {
font-size: 15px;
font-size: var(--wb-fs-insight-h4);
line-height: 1.6;
}
@@ -3131,7 +3372,7 @@
.insight-text-section h4 {
color: #0f172a;
font-size: 15px;
font-size: var(--wb-fs-insight-h4);
font-weight: 850;
}
@@ -3446,7 +3687,7 @@
.welcome-card i {
color: #10b981;
font-size: 20px;
font-size: var(--wb-fs-welcome);
}
.welcome-card strong {
@@ -3486,29 +3727,76 @@
transform: translateY(8px);
}
@media (max-width: 1366px), (max-height: 780px) {
.insight-panel-shell {
width: 348px;
/* 笔记本 / 中等屏:工作台正文字号整体下调一档 */
@media (max-width: 1680px) {
.assistant-modal-stage {
--wb-fs-title: 19px;
--wb-fs-desc: 12px;
--wb-fs-badge: 11px;
--wb-fs-bubble: 13px;
--wb-fs-bubble-meta: 12px;
--wb-fs-bubble-time: 11px;
--wb-fs-chip: 11px;
--wb-fs-composer: 13px;
--wb-fs-tool-icon: 16px;
--wb-fs-md-h1: 16px;
--wb-fs-md-h2: 15px;
--wb-fs-md-h3: 13px;
--wb-fs-insight-title: 17px;
--wb-fs-insight-num: 17px;
--wb-fs-insight-body: 11px;
--wb-fs-insight-h4: 14px;
--wb-fs-metric: 12px;
--wb-fs-metric-strong: 12px;
--wb-fs-welcome: 18px;
}
.insight-panel {
width: 348px;
.assistant-modal-stage .message-answer-markdown :deep(table) {
font-size: 12px;
}
.review-side-grid.compact {
grid-template-columns: 1fr;
.assistant-modal-stage .intent-pill {
font-size: var(--wb-fs-chip);
}
}
@media (max-width: 1280px) {
@media (max-width: 1440px) {
.assistant-modal-stage {
--wb-fs-title: 18px;
--wb-fs-bubble: 12px;
--wb-fs-bubble-meta: 11px;
--wb-fs-composer: 12px;
--wb-fs-insight-title: 16px;
--wb-fs-insight-num: 16px;
--wb-fs-md-h1: 15px;
--wb-fs-md-h2: 14px;
--wb-fs-insight-h4: 13px;
--wb-fs-welcome: 17px;
}
}
/* 大屏:左右分栏;右侧详情区宽度随视口收缩 */
@media (min-width: 1441px) and (max-width: 1680px) {
.insight-panel-shell {
width: clamp(280px, 26vw, 360px);
}
}
/* 笔记本常见宽度:改为上下布局,对话区占满宽度,避免侧栏挤占 */
@media (max-width: 1440px) {
.assistant-layout {
flex-direction: column;
}
.dialog-panel {
flex: 1 1 auto;
min-height: 0;
}
.insight-panel-shell {
width: 100%;
margin-left: 0;
max-height: 720px;
flex: 0 0 auto;
max-height: min(38dvh, 400px);
transition:
max-height 320ms cubic-bezier(0.22, 1, 0.36, 1),
opacity 240ms cubic-bezier(0.22, 1, 0.36, 1),
@@ -3516,31 +3804,76 @@
}
.insight-panel-shell.collapsed {
width: 100%;
max-height: 0;
}
.insight-panel {
width: 100%;
min-height: 320px;
min-height: min(280px, 32dvh);
}
.insight-panel-shell.collapsed .insight-panel {
transform: translateY(-12px);
}
.review-side-grid.compact {
grid-template-columns: 1fr;
}
}
/* 矮屏笔记本(如 1366×768压缩顶栏与间距把高度留给对话列表 */
@media (max-height: 820px) {
.assistant-modal-stage {
--wb-fs-title: 17px;
--wb-fs-bubble: 12px;
--wb-fs-composer: 12px;
--wb-fs-insight-title: 15px;
--wb-fs-insight-num: 15px;
}
.assistant-header {
padding-top: 12px;
padding-bottom: 10px;
}
.assistant-header-actions {
top: 12px;
right: 12px;
}
.assistant-layout {
padding: 10px;
gap: 10px;
}
.dialog-toolbar {
padding: 12px 14px 10px;
}
.message-list {
padding: 12px;
gap: 10px;
}
.composer-shell-body {
padding: 4px 10px;
}
}
@media (max-width: 1280px) {
.insight-panel-shell:not(.collapsed) {
max-height: min(34dvh, 360px);
}
}
@media (max-width: 760px) {
.assistant-modal {
width: 100vw;
height: 100vh;
.assistant-overlay {
--assistant-viewport-inset: 10px;
}
.assistant-modal,
.assistant-modal-stage {
width: 100%;
height: 100%;
transform: none;
border-radius: 0;
border-radius: 18px;
}
.assistant-header {
@@ -3573,13 +3906,11 @@
.composer-row {
gap: 8px;
--composer-control-size: 40px;
}
.composer-side-btn,
.tool-btn,
.send-btn {
width: 40px;
height: 40px;
.composer-shell textarea {
min-height: 32px;
}
.dialog-toolbar {

View File

@@ -84,16 +84,12 @@ export function fetchAgentAssetDetail(assetId) {
return apiRequest(`/agent-assets/${assetId}`)
}
export function fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version = '') {
const query = buildQuery({ version })
return apiRequest(`/agent-assets/${assetId}/spreadsheet/onlyoffice-config${query}`)
export function fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId) {
return apiRequest(`/agent-assets/${assetId}/spreadsheet/onlyoffice-config`)
}
export function fetchAgentAssetSpreadsheetBlob(assetId, version = '', disposition = 'inline') {
export function fetchAgentAssetSpreadsheetBlob(assetId, disposition = 'inline') {
const search = new URLSearchParams()
if (version) {
search.set('version', String(version).trim())
}
if (disposition) {
search.set('disposition', String(disposition).trim())
}
@@ -148,14 +144,6 @@ export function saveAgentAssetRuleJson(assetId, payload, options = {}) {
})
}
export function compareAgentAssetSpreadsheetVersions(assetId, baseVersion, targetVersion) {
const query = new URLSearchParams({
base_version: String(baseVersion || '').trim(),
target_version: String(targetVersion || '').trim()
})
return apiRequest(`/agent-assets/${assetId}/versions/compare?${query.toString()}`)
}
export function fetchAgentAssetSpreadsheetChangeRecords(assetId, limit = 30) {
return apiRequest(
`/agent-assets/${assetId}/spreadsheet/change-records${buildQuery({ limit })}`

View File

@@ -103,7 +103,7 @@
<div class="spreadsheet-editor-actions">
<span class="spreadsheet-mode-pill">
{{ selectedSpreadsheetVersionModeLabel }}
{{ selectedSpreadsheetModeLabel }}
</span>
</div>
</header>
@@ -153,21 +153,21 @@
</footer>
</section>
<aside class="spreadsheet-version-center">
<header class="version-center-head">
<aside class="spreadsheet-change-center">
<header class="change-center-head">
<div>
<h3>最近修改</h3>
<p>展示最近 30 在线编辑保存后的具体改动</p>
<p>展示最近 30 次保存后的具体改动</p>
</div>
</header>
<section class="version-center-section version-history-section">
<div v-if="selectedSpreadsheetChangeRecords.length" class="version-center-list">
<section class="change-center-section change-history-section">
<div v-if="selectedSpreadsheetChangeRecords.length" class="change-center-list">
<button
v-for="item in selectedSpreadsheetChangeRecords"
:key="`spreadsheet-change-${item.id || item.changed_at}-${item.actor}`"
type="button"
class="version-center-item change-record-item"
class="change-center-item change-record-item"
@click="openSpreadsheetChangeDetail(item)"
>
<div class="change-record-head">
@@ -178,7 +178,6 @@
<b>{{ item.changeCountLabel }}</b>
</div>
<p>{{ item.summary }}</p>
<small v-if="item.version">关联版本{{ item.version }}</small>
<small v-if="item.sheetPreview.length">
涉及工作表{{ item.sheetPreview.join('') }}
<template v-if="item.remainingSheetCount"> {{ item.changedSheetNames.length }} </template>
@@ -197,7 +196,7 @@
</small>
</button>
</div>
<p v-else class="version-flow-empty">暂无修改记录</p>
<p v-else class="change-flow-empty">暂无修改记录</p>
</section>
</aside>
</div>
@@ -1088,8 +1087,6 @@
<p>{{ item.description || item.note || '暂无补充说明' }}</p>
<small>
操作人{{ item.actor }}
<template v-if="item.version"> · 关联版本{{ item.version }}</template>
<template v-if="item.source_version"> · 来源版本{{ item.source_version }}</template>
</small>
</div>
</article>
@@ -1129,10 +1126,6 @@
<span>修改时间</span>
<strong>{{ selectedSpreadsheetChangeRecord.time }}</strong>
</article>
<article v-if="selectedSpreadsheetChangeRecord.version">
<span>关联版本</span>
<strong>{{ selectedSpreadsheetChangeRecord.version }}</strong>
</article>
<article>
<span>修改工作表</span>
<strong>{{ selectedSpreadsheetChangeRecord.changed_sheet_count }}</strong>
@@ -1203,127 +1196,6 @@
</div>
</Transition>
<Transition name="drawer-fade">
<div v-if="versionCompareOpen" class="rule-drawer-backdrop" @click.self="closeVersionCompare">
<aside class="rule-drawer compare-drawer">
<header class="rule-drawer-head">
<div>
<span>版本治理</span>
<h3>版本差异对比</h3>
</div>
<button type="button" @click="closeVersionCompare">
<i class="mdi mdi-close"></i>
</button>
</header>
<section class="compare-toolbar">
<label>
<span>基准版本</span>
<select v-model="compareBaseVersion" @change="loadVersionCompare">
<option
v-for="item in selectedSkill?.history || []"
:key="`base-${item.version}`"
:value="item.version"
>
{{ item.version }} · {{ item.lifecycleMeta.label }}
</option>
</select>
</label>
<i class="mdi mdi-arrow-right"></i>
<label>
<span>对比版本</span>
<select v-model="compareTargetVersion" @change="loadVersionCompare">
<option
v-for="item in selectedSkill?.history || []"
:key="`target-${item.version}`"
:value="item.version"
>
{{ item.version }} · {{ item.lifecycleMeta.label }}
</option>
</select>
</label>
</section>
<div v-if="versionCompareLoading" class="rule-drawer-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在生成版本差异...</span>
</div>
<div v-else-if="versionCompareError" class="rule-drawer-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ versionCompareError }}</span>
</div>
<div v-else-if="versionComparePayload" class="compare-content">
<section class="compare-summary-grid">
<article>
<span>新增工作表</span>
<strong>{{ versionComparePayload.added_sheet_count }}</strong>
</article>
<article>
<span>删除工作表</span>
<strong>{{ versionComparePayload.removed_sheet_count }}</strong>
</article>
<article>
<span>修改工作表</span>
<strong>{{ versionComparePayload.changed_sheet_count }}</strong>
</article>
<article>
<span>变更单元格</span>
<strong>{{ versionComparePayload.changed_cell_count }}</strong>
</article>
</section>
<section class="compare-panel">
<header>
<strong>工作表变化</strong>
</header>
<div v-if="versionCompareSheetRows.length" class="compare-sheet-list">
<span
v-for="item in versionCompareSheetRows"
:key="`${item.sheet_name}-${item.change_type}`"
:class="item.meta.tone"
>
{{ item.sheet_name }} · {{ item.meta.label }}
</span>
</div>
<p v-else>没有新增或删除工作表</p>
</section>
<section class="compare-panel compare-cell-panel">
<header>
<strong>单元格差异</strong>
<small>最多展示前 500 </small>
</header>
<div v-if="versionCompareCellRows.length" class="compare-table-wrap">
<table>
<thead>
<tr>
<th>工作表</th>
<th>位置</th>
<th>类型</th>
<th>旧值</th>
<th>新值</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in versionCompareCellRows"
:key="`${item.sheet_name}-${item.cell}`"
>
<td>{{ item.sheet_name }}</td>
<td>{{ item.cell }}</td>
<td><b :class="item.meta.tone">{{ item.meta.label }}</b></td>
<td>{{ item.before_value ?? '-' }}</td>
<td>{{ item.after_value ?? '-' }}</td>
</tr>
</tbody>
</table>
</div>
<p v-else>两个版本内容一致没有发现单元格级差异</p>
</section>
</div>
</aside>
</div>
</Transition>
</section>
</template>

View File

@@ -1,7 +1,7 @@
<template>
<Teleport to="body">
<Transition name="assistant-modal">
<div class="assistant-overlay">
<Transition name="assistant-modal" @after-leave="emitCloseAfterLeave">
<div v-if="workbenchVisible" class="assistant-overlay">
<section class="assistant-modal">
<div class="assistant-header-actions">
<button
@@ -30,8 +30,7 @@
type="button"
title="关闭工作台"
aria-label="关闭对话工作台"
@pointerdown.stop.prevent="requestCloseWorkbench"
@click.stop.prevent="requestCloseWorkbench"
@click="requestCloseWorkbench"
>
<i class="mdi mdi-close"></i>
</button>
@@ -41,8 +40,8 @@
<header class="assistant-header">
<div class="assistant-header-main">
<div>
<h2>财务AI工作台</h2>
<p>个人工作台发起报销智能录入统一走这里右侧会根据你的意图实时切换状态视图</p>
<h2>财务助手</h2>
<p>个人财务中心 · 报销识别票据核对与制度咨询右侧会随处理进度展示识别结果与风险提示</p>
</div>
</div>
</header>
@@ -79,7 +78,7 @@
<div class="message-bubble">
<header class="message-meta">
<strong>{{ message.role === 'assistant' ? 'AI 助手' : '我' }}</strong>
<strong>{{ message.role === 'assistant' ? (message.assistantName || ASSISTANT_DISPLAY_NAME) : '我' }}</strong>
<time>{{ message.time }}</time>
</header>
<p
@@ -93,11 +92,31 @@
v-else-if="message.text && message.role === 'assistant'"
class="message-answer-content message-answer-markdown"
v-html="renderMarkdown(message.text)"
></div>
></motion>
<motion
v-if="message.role === 'assistant' && message.welcomeQuickActions?.length"
class="welcome-quick-actions"
>
<p class="welcome-quick-actions-title">您可以对我进行以下操作</p>
<div class="welcome-quick-action-grid">
<button
v-for="action in message.welcomeQuickActions"
:key="`${message.id}-${action.label}`"
type="button"
class="welcome-quick-action-btn"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@click="runWelcomeQuickAction(action)"
>
<i :class="action.icon"></i>
<span>{{ action.label }}</span>
</button>
</motion>
</motion>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.meta?.length" class="message-meta-row">
<span v-for="item in message.meta" :key="item" class="message-meta-chip">{{ item }}</span>
</div>
</motion>
<div v-if="message.role === 'assistant' && !message.reviewPayload && message.riskFlags?.length" class="message-detail-block">
<strong>风险标签</strong>
@@ -409,28 +428,120 @@
</div>
<div class="composer-row" :class="{ 'knowledge-mode': isKnowledgeSession }">
<button
v-if="!isKnowledgeSession"
type="button"
class="tool-btn composer-side-btn"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="上传附件"
@click="triggerFileUpload"
>
<div v-if="!isKnowledgeSession" class="composer-leading-actions">
<button
type="button"
class="tool-btn composer-side-btn"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="上传附件"
@click="triggerFileUpload"
>
<i class="mdi mdi-paperclip"></i>
</button>
</button>
<div class="composer-date-anchor">
<button
type="button"
class="tool-btn composer-side-btn"
:class="{ active: composerDatePickerOpen }"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="选择业务发生时间"
:aria-expanded="composerDatePickerOpen"
@click.stop="toggleComposerDatePicker"
>
<i class="mdi mdi-calendar-range"></i>
</button>
<div
v-if="composerDatePickerOpen"
class="composer-date-popover"
role="dialog"
aria-label="业务发生时间"
@click.stop
>
<div class="composer-date-mode-tabs">
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: composerDateMode === 'single' }"
@click="setComposerDateMode('single')"
>
当天
</button>
<button
type="button"
class="composer-date-mode-btn"
:class="{ active: composerDateMode === 'range' }"
@click="setComposerDateMode('range')"
>
时间段
</button>
</div>
<div v-if="composerDateMode === 'single'" class="composer-date-fields">
<label class="composer-date-field">
<span>日期</span>
<input v-model="composerSingleDate" type="date" />
</label>
</div>
<div v-else class="composer-date-fields composer-date-fields-range">
<label class="composer-date-field">
<span>开始</span>
<input v-model="composerRangeStartDate" type="date" />
</label>
<span class="composer-date-range-sep"></span>
<label class="composer-date-field">
<span>结束</span>
<input v-model="composerRangeEndDate" type="date" :min="composerRangeStartDate" />
</label>
</div>
<p v-if="composerDateMode === 'range' && !composerCanApplyDateSelection" class="composer-date-hint">
请确认结束日期不早于开始日期
</p>
<div class="composer-date-popover-actions">
<button type="button" class="composer-date-cancel-btn" @click="closeComposerDatePicker">
取消
</button>
<button
type="button"
class="composer-date-apply-btn"
:disabled="!composerCanApplyDateSelection"
@click="applyComposerDateSelection"
>
插入标签
</button>
</div>
</div>
</div>
</div>
<div class="composer-shell">
<textarea
ref="composerTextareaRef"
v-model="composerDraft"
rows="1"
:placeholder="composerPlaceholder"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@input="handleComposerInput"
@keydown.enter.exact.stop
@keydown.ctrl.enter.prevent="submitComposer"
/>
<div class="composer-shell-body">
<span
v-for="tag in composerBusinessTimeTags"
:key="tag.id"
class="composer-biz-time-tag"
>
<i class="mdi mdi-calendar-check"></i>
<span class="composer-biz-time-tag-label">{{ tag.label }}</span>
<button
type="button"
class="composer-biz-time-tag-remove"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
aria-label="移除业务发生时间"
@click="removeComposerBusinessTimeTag(tag.id)"
>
<i class="mdi mdi-close"></i>
</button>
</span>
<textarea
ref="composerTextareaRef"
v-model="composerDraft"
rows="1"
:placeholder="composerPlaceholder"
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
@input="handleComposerInput"
@keydown.enter.exact.stop
@keydown.ctrl.enter.prevent="submitComposer"
/>
</div>
</div>
<button class="send-btn composer-side-btn" type="submit" :disabled="!canSubmit || reviewActionBusy || sessionSwitchBusy" aria-label="发送">

View File

@@ -7,7 +7,6 @@ import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js'
import {
activateAgentAsset,
compareAgentAssetSpreadsheetVersions,
createAgentAssetReview,
createAgentAssetVersion,
fetchAgentAssetDetail,
@@ -969,6 +968,17 @@ function buildRowMetric(asset, typeKey) {
return normalizeText(asset.config_json?.agent) || '未配置 Agent'
}
function formatSpreadsheetChangeSummary(summary) {
const normalized = normalizeText(summary)
return (
normalized
.replace(/^(ONLYOFFICE\s*)?在线编辑[:]\s*/i, '')
.replace(/^ONLYOFFICE\s*在线编辑保存[。.]?\s*/i, '')
.replace(/^保存表格[:]\s*/i, '')
.trim() || '表格内容已保存。'
)
}
function buildListItem(asset) {
const typeKey = resolveTypeKey(asset.asset_type)
const tabId = resolveTabId(asset, typeKey)
@@ -993,6 +1003,9 @@ function buildListItem(asset) {
: ''
)
const isRiskRule = tabId === 'riskRules'
const usesSpreadsheetRule = typeKey === 'rules' && isSpreadsheetRuleSource(asset)
const usesJsonRiskRule = typeKey === 'rules' && isJsonRiskRuleSource(asset)
const ruleDocument = readRuleDocumentMeta(asset)
const riskCategory = isRiskRule ? resolveRiskRuleCategory(asset) : ''
const listSubtitle = isRiskRule
? buildRiskListSubtitle(asset.description)
@@ -1003,6 +1016,9 @@ function buildListItem(asset) {
tabId,
type: typeKey,
isPreviewMock: Boolean(asset.isPreviewMock),
usesSpreadsheetRule,
usesJsonRiskRule,
ruleDocument,
typeLabel: tabMeta.typeLabel,
short: makeShort(asset.name),
name: asset.name,
@@ -1582,12 +1598,6 @@ export default {
const versionTimelineLoading = ref(false)
const versionTimelineError = ref('')
const versionTimelineItems = ref([])
const versionCompareOpen = ref(false)
const versionCompareLoading = ref(false)
const versionCompareError = ref('')
const versionComparePayload = ref(null)
const compareBaseVersion = ref('')
const compareTargetVersion = ref('')
const spreadsheetChangeRecordsByAsset = ref({})
const spreadsheetChangeDetailOpen = ref(false)
const selectedSpreadsheetChangeRecord = ref(null)
@@ -1595,8 +1605,7 @@ export default {
let spreadsheetOnlyOfficeLoadTimer = null
let spreadsheetOnlyOfficeHadLocalEdits = false
let spreadsheetOnlyOfficeSyncSeq = 0
let spreadsheetOnlyOfficeVersionPollTimer = null
let spreadsheetOnlyOfficeRefreshTimer = null
let spreadsheetOnlyOfficeChangePollTimer = null
const assetBuckets = ref({
financialRules: [],
riskRules: [],
@@ -1649,8 +1658,7 @@ export default {
() =>
canEditSelected.value &&
selectedSkillUsesSpreadsheet.value &&
!detailBusy.value &&
selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
!detailBusy.value
)
const canDownloadSpreadsheet = computed(
() =>
@@ -1661,26 +1669,17 @@ export default {
const canEditSpreadsheetInline = computed(
() =>
selectedSkillUsesSpreadsheet.value &&
selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion &&
(selectedSkill.value?.isPreviewMock || canEditSelected.value)
)
const selectedDisplayHistory = computed(
() =>
selectedSkill.value?.history?.find((item) => item.version === selectedSkill.value?.displayVersion) || null
)
const selectedSpreadsheetFileName = computed(
() =>
normalizeText(
selectedDisplayHistory.value?.spreadsheetMeta?.file_name || selectedSkill.value?.ruleDocument?.file_name
) || '未上传规则表'
normalizeText(selectedSkill.value?.ruleDocument?.file_name) || '未上传规则表'
)
const selectedSpreadsheetVersionModeLabel = computed(() => {
const selectedSpreadsheetModeLabel = computed(() => {
if (selectedSkill.value?.isPreviewMock) {
return canEditSpreadsheetInline.value ? 'ONLYOFFICE 可编辑' : 'ONLYOFFICE 预览'
return canEditSpreadsheetInline.value ? '可编辑' : '只读'
}
return selectedSkill.value?.displayVersion === selectedSkill.value?.workingVersion
? '在线可编辑'
: '只读预览'
return canEditSpreadsheetInline.value ? '在线可编辑' : '只读'
})
const selectedVersionTimelineItems = computed(() =>
versionTimelineItems.value.map((item) => ({
@@ -1709,6 +1708,7 @@ export default {
return {
...item,
time: formatDateTime(item.changed_at),
summary: formatSpreadsheetChangeSummary(item.summary),
changeCountLabel: item.changed_cell_count
? `${item.changed_cell_count} 处改动`
: `${item.changed_sheet_count || changedSheetNames.length || 0} 个工作表`,
@@ -1736,22 +1736,6 @@ export default {
}))
: []
)
const versionCompareCellRows = computed(() =>
Array.isArray(versionComparePayload.value?.cell_changes)
? versionComparePayload.value.cell_changes.map((item) => ({
...item,
meta: resolveDiffChangeMeta(item.change_type)
}))
: []
)
const versionCompareSheetRows = computed(() =>
Array.isArray(versionComparePayload.value?.sheet_changes)
? versionComparePayload.value.sheet_changes.map((item) => ({
...item,
meta: resolveDiffChangeMeta(item.change_type)
}))
: []
)
const detailBusy = computed(() => Boolean(actionState.value))
const showReviewNote = computed(
() => selectedSkillIsRule.value && (selectedSkill.value?.reviewNote || selectedSkill.value?.reviewTimeLabel)
@@ -1922,7 +1906,6 @@ export default {
watch(
() => [
selectedSkill.value?.id || '',
selectedSkill.value?.displayVersion || '',
selectedSkill.value?.loading ? '1' : '0',
selectedSkill.value?.usesSpreadsheetRule ? '1' : '0'
],
@@ -1938,7 +1921,6 @@ export default {
)
watch(activeType, () => {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor()
selectedSkill.value = null
versionSwitchTarget.value = null
@@ -2034,8 +2016,7 @@ export default {
window.clearTimeout(spreadsheetOnlyOfficeLoadTimer)
spreadsheetOnlyOfficeLoadTimer = null
}
stopSpreadsheetOnlyOfficeVersionSync()
clearSpreadsheetPendingChangeRecord(selectedSkill.value?.id, selectedSkill.value?.displayVersion)
stopSpreadsheetOnlyOfficeChangeSync()
spreadsheetOnlyOfficeHadLocalEdits = false
spreadsheetOnlyOfficeSyncSeq += 1
if (spreadsheetOnlyOfficeEditor.value?.destroyEditor) {
@@ -2045,87 +2026,10 @@ export default {
spreadsheetOnlyOfficeReady.value = false
}
function appendSpreadsheetChangeRecord(record) {
const assetId = normalizeText(record?.assetId)
const version = normalizeText(record?.version)
if (!assetId || !version) {
return
}
const nextRecord = {
version,
operationLabel: normalizeText(record?.operationLabel) || '表格修改',
operationActor: normalizeText(record?.operationActor) || resolveActor(),
note: normalizeText(record?.note) || '用户修改了表格内容。',
time: record?.time || new Date().toISOString(),
isWorking: record?.isWorking !== false,
isPendingLocalEdit: Boolean(record?.isPendingLocalEdit),
disabledReason: normalizeText(record?.disabledReason)
}
const current = spreadsheetChangeRecordsByAsset.value[assetId] || []
const deduped = current.filter(
(item) =>
!(
item.version === nextRecord.version &&
item.operationLabel === nextRecord.operationLabel &&
item.note === nextRecord.note
)
)
spreadsheetChangeRecordsByAsset.value = {
...spreadsheetChangeRecordsByAsset.value,
[assetId]: [nextRecord, ...deduped].slice(0, 30)
}
}
function clearSpreadsheetPendingChangeRecord(assetId, version) {
const normalizedAssetId = normalizeText(assetId)
const normalizedVersion = normalizeText(version)
if (!normalizedAssetId) {
return
}
const current = spreadsheetChangeRecordsByAsset.value[normalizedAssetId] || []
spreadsheetChangeRecordsByAsset.value = {
...spreadsheetChangeRecordsByAsset.value,
[normalizedAssetId]: current.filter(
(item) => !(item.isPendingLocalEdit && (!normalizedVersion || item.version === normalizedVersion))
)
}
}
function markSpreadsheetPendingChange(assetId, version) {
const normalizedAssetId = normalizeText(assetId)
const normalizedVersion = normalizeText(version)
if (!normalizedAssetId || !normalizedVersion) {
return
}
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
appendSpreadsheetChangeRecord({
assetId: normalizedAssetId,
version: normalizedVersion,
operationLabel: '编辑中',
operationActor: resolveActor(),
note: '检测到未保存的表格改动,保存后会生成新版本并可查看差异。',
time: new Date().toISOString(),
isWorking: true,
isPendingLocalEdit: true,
disabledReason: '当前是本地未保存修改,保存后才会生成可对比的版本。'
})
}
function stopSpreadsheetOnlyOfficeVersionSync() {
if (spreadsheetOnlyOfficeVersionPollTimer) {
window.clearTimeout(spreadsheetOnlyOfficeVersionPollTimer)
spreadsheetOnlyOfficeVersionPollTimer = null
}
}
function stopSpreadsheetOnlyOfficeDeferredRefresh() {
if (spreadsheetOnlyOfficeRefreshTimer) {
window.clearTimeout(spreadsheetOnlyOfficeRefreshTimer)
spreadsheetOnlyOfficeRefreshTimer = null
function stopSpreadsheetOnlyOfficeChangeSync() {
if (spreadsheetOnlyOfficeChangePollTimer) {
window.clearTimeout(spreadsheetOnlyOfficeChangePollTimer)
spreadsheetOnlyOfficeChangePollTimer = null
}
}
@@ -2164,7 +2068,6 @@ export default {
latest.id,
latest.changed_at,
latest.actor,
latest.version,
latest.summary,
latest.changed_sheet_count,
latest.changed_cell_count,
@@ -2193,36 +2096,14 @@ export default {
return refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestKey, attempt + 1)
}
function scheduleSpreadsheetEditorRefreshAfterSave(assetId, savedVersion) {
function scheduleSpreadsheetOnlyOfficeChangeSync(assetId, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
const normalizedSavedVersion = normalizeText(savedVersion)
if (!normalizedAssetId || !normalizedSavedVersion) {
return
}
stopSpreadsheetOnlyOfficeDeferredRefresh()
spreadsheetOnlyOfficeRefreshTimer = window.setTimeout(async () => {
spreadsheetOnlyOfficeRefreshTimer = null
if (
selectedSkill.value?.id !== normalizedAssetId ||
selectedSkill.value?.displayVersion === normalizedSavedVersion
) {
return
}
await loadSelectedAssetDetail(normalizedAssetId)
}, 3200)
}
function scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version, attempt = 0) {
const normalizedAssetId = normalizeText(assetId)
const normalizedVersion = normalizeText(version)
if (!normalizedAssetId || !normalizedVersion) {
if (!normalizedAssetId) {
return
}
const syncSeq = ++spreadsheetOnlyOfficeSyncSeq
stopSpreadsheetOnlyOfficeVersionSync()
stopSpreadsheetOnlyOfficeChangeSync()
const previousLatestChangeKey = getLatestSpreadsheetChangeKey(normalizedAssetId)
const runSync = async () => {
@@ -2231,31 +2112,13 @@ export default {
}
try {
const detail = await fetchAgentAssetDetail(normalizedAssetId)
const nextWorkingVersion = normalizeText(detail?.working_version || detail?.current_version)
if (nextWorkingVersion && nextWorkingVersion !== normalizedVersion) {
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
await refreshCurrentAssets()
await refreshSpreadsheetChangeRecordsAfterSave(normalizedAssetId, previousLatestChangeKey)
if (syncSeq !== spreadsheetOnlyOfficeSyncSeq || selectedSkill.value?.id !== normalizedAssetId) {
return
}
// ONLYOFFICE 的保存回调刚结束时立即销毁并重挂编辑器,偶发会让新文档会话
// 还没完全就绪就被再次打开,表现为“加载超时”。先刷新右侧修改记录,再留
// 一个很短的缓冲窗口后切换到新工作版本,用户无需退出重进。
scheduleSpreadsheetEditorRefreshAfterSave(normalizedAssetId, nextWorkingVersion)
stopSpreadsheetOnlyOfficeVersionSync()
return
}
const changeRecordRefreshed = await refreshSpreadsheetChangeRecordsAfterSave(
normalizedAssetId,
previousLatestChangeKey
)
if (changeRecordRefreshed) {
clearSpreadsheetPendingChangeRecord(normalizedAssetId, normalizedVersion)
await refreshCurrentAssets()
stopSpreadsheetOnlyOfficeVersionSync()
stopSpreadsheetOnlyOfficeChangeSync()
return
}
} catch {
@@ -2268,22 +2131,21 @@ export default {
if (attempt >= 29) {
return
}
spreadsheetOnlyOfficeVersionPollTimer = window.setTimeout(() => {
scheduleSpreadsheetOnlyOfficeVersionSync(normalizedAssetId, normalizedVersion, attempt + 1)
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
scheduleSpreadsheetOnlyOfficeChangeSync(normalizedAssetId, attempt + 1)
}, 2000)
}
spreadsheetOnlyOfficeVersionPollTimer = window.setTimeout(() => {
spreadsheetOnlyOfficeChangePollTimer = window.setTimeout(() => {
runSync().catch(() => {})
}, attempt === 0 ? 800 : 2000)
}
function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version) {
function isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId) {
return (
mountSeq !== spreadsheetOnlyOfficeMountSeq ||
!selectedSkillUsesSpreadsheet.value ||
selectedSkill.value?.id !== assetId ||
selectedSkill.value?.displayVersion !== version ||
selectedSkill.value?.loading
)
}
@@ -2296,7 +2158,6 @@ export default {
const mountSeq = ++spreadsheetOnlyOfficeMountSeq
const assetId = selectedSkill.value.id
const version = selectedSkill.value.displayVersion
const editable = canEditSpreadsheetInline.value
spreadsheetOnlyOfficeLoading.value = true
@@ -2305,25 +2166,25 @@ export default {
destroySpreadsheetOnlyOfficeEditor()
try {
const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId, version)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
const payload = await fetchAgentAssetSpreadsheetOnlyOfficeConfig(assetId)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
await loadOnlyOfficeApi(payload.documentServerUrl)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (!window.DocsAPI?.DocEditor) {
throw new Error('ONLYOFFICE 编辑器未正确加载。')
throw new Error('表格编辑器未正确加载。')
}
// Host id must be unique for every mount. ONLYOFFICE mutates its host DOM
// during lifecycle teardown; reusing the same element can leave the next
// DocEditor instance with a dead container even though config loading succeeds.
spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${version}-${mountSeq}`
spreadsheetOnlyOfficeHostId.value = `audit-rule-onlyoffice-${assetId}-${mountSeq}`
await nextTick()
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
@@ -2334,7 +2195,7 @@ export default {
})
const upstreamEvents = config.events || {}
spreadsheetOnlyOfficeLoadTimer = window.setTimeout(() => {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (retryAttempt < 1) {
@@ -2345,14 +2206,14 @@ export default {
}, 600)
return
}
spreadsheetOnlyOfficeError.value = 'ONLYOFFICE 加载超时,请重新切换版本后重试。'
spreadsheetOnlyOfficeError.value = '表格加载超时,请退出详情后重试。'
spreadsheetOnlyOfficeLoading.value = false
destroySpreadsheetOnlyOfficeEditor()
}, 15000)
config.events = {
...upstreamEvents,
onAppReady(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
@@ -2364,7 +2225,7 @@ export default {
upstreamEvents.onAppReady?.(event)
},
onError(event) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
if (spreadsheetOnlyOfficeLoadTimer) {
@@ -2374,8 +2235,8 @@ export default {
const errorCode = event?.data?.errorCode
const errorDescription = event?.data?.errorDescription
spreadsheetOnlyOfficeError.value = errorDescription
? `ONLYOFFICE 加载失败:${errorDescription}`
: `ONLYOFFICE 加载失败${errorCode ? `(错误码 ${errorCode}` : '。'}`
? `表格加载失败:${errorDescription}`
: `表格加载失败${errorCode ? `(错误码 ${errorCode}` : '。'}`
spreadsheetOnlyOfficeLoading.value = false
upstreamEvents.onError?.(event)
},
@@ -2383,17 +2244,16 @@ export default {
const hasChanges = Boolean(event?.data)
if (hasChanges) {
spreadsheetOnlyOfficeHadLocalEdits = true
markSpreadsheetPendingChange(assetId, version)
if (!spreadsheetOnlyOfficeVersionPollTimer) {
scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version)
if (!spreadsheetOnlyOfficeChangePollTimer) {
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
}
} else if (
spreadsheetOnlyOfficeHadLocalEdits &&
editable &&
!isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)
!isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)
) {
spreadsheetOnlyOfficeHadLocalEdits = false
scheduleSpreadsheetOnlyOfficeVersionSync(assetId, version)
scheduleSpreadsheetOnlyOfficeChangeSync(assetId)
}
upstreamEvents.onDocumentStateChange?.(event)
}
@@ -2402,11 +2262,11 @@ export default {
spreadsheetOnlyOfficeHostId.value,
config
)
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
destroySpreadsheetOnlyOfficeEditor()
}
} catch (error) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId, version)) {
if (isSpreadsheetOnlyOfficeMountStale(mountSeq, assetId)) {
return
}
spreadsheetOnlyOfficeError.value = error?.message || '规则表加载失败,请稍后重试。'
@@ -2431,7 +2291,6 @@ export default {
try {
const blob = await fetchAgentAssetSpreadsheetBlob(
selectedSkill.value.id,
selectedSkill.value.displayVersion,
'attachment'
)
const objectUrl = URL.createObjectURL(blob)
@@ -2462,7 +2321,7 @@ export default {
await refreshCurrentAssets()
await loadSelectedAssetDetail(selectedSkill.value.id)
await loadSpreadsheetChangeRecords(selectedSkill.value.id)
toast(`已导入 ${file.name} 的表格内容,并生成新版本`)
toast(`已导入 ${file.name} 的表格内容,右侧会记录本次修改`)
} catch (error) {
toast(error?.message || '规则表内容导入失败,请稍后重试。')
} finally {
@@ -2560,7 +2419,7 @@ export default {
const detail = await fetchAgentAssetDetail(assetId)
selectedSkill.value = buildDetailViewModel(detail, runs.value)
if (selectedSkill.value?.type === 'rules') {
if (!selectedSkill.value.usesJsonRiskRule) {
if (!selectedSkill.value.usesSpreadsheetRule && !selectedSkill.value.usesJsonRiskRule) {
loadVersionTimeline(assetId, { silent: true }).catch(() => {})
}
if (selectedSkill.value.usesSpreadsheetRule) {
@@ -2677,7 +2536,6 @@ export default {
}
function openAssetDetail(asset) {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false
@@ -2688,17 +2546,18 @@ export default {
versionSwitchTarget.value = null
return
}
const opensSpreadsheetRule = Boolean(asset?.usesSpreadsheetRule)
selectedSkill.value = {
...asset,
configJson: {},
isPreviewMock: false,
usesSpreadsheetRule: false,
usesJsonRiskRule: false,
usesSpreadsheetRule: opensSpreadsheetRule,
usesJsonRiskRule: Boolean(asset?.usesJsonRiskRule),
riskRuleJsonText: '{}',
riskRuleSummary: null,
riskRuleDescription: '',
riskRuleSourceRef: '',
ruleDocument: null,
ruleDocument: asset?.ruleDocument || null,
scenarioList: [],
fields: [],
promptSections: [],
@@ -2714,16 +2573,18 @@ export default {
runtimeKind: 'policy_rule_draft',
displayVersion: asset.version,
displayVersionChangeNote: '无版本说明',
loading: true,
reviewStatusLabel: '加载中',
loading: !opensSpreadsheetRule,
reviewStatusLabel: opensSpreadsheetRule ? '' : '加载中',
reviewStatusTone: 'draft'
}
versionSwitchTarget.value = null
if (opensSpreadsheetRule) {
loadSpreadsheetChangeRecords(asset.id).catch(() => {})
}
loadSelectedAssetDetail(asset.id).catch(() => {})
}
function closeDetail() {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor()
spreadsheetOnlyOfficeError.value = ''
spreadsheetOnlyOfficeLoading.value = false
@@ -2732,9 +2593,7 @@ export default {
detailLoading.value = false
versionSwitchTarget.value = null
versionTimelineOpen.value = false
versionCompareOpen.value = false
versionTimelineItems.value = []
versionComparePayload.value = null
}
function openVersionSwitch(version) {
@@ -3062,66 +2921,6 @@ export default {
versionTimelineOpen.value = false
}
async function openVersionCompare(options = {}) {
if (!selectedSkill.value?.id) {
return
}
const defaultBase =
options.baseVersion || selectedSkill.value.publishedVersion || selectedSkill.value.workingVersion || ''
let defaultTarget =
options.targetVersion || selectedSkill.value.workingVersion || selectedSkill.value.publishedVersion || ''
if (!options.targetVersion && defaultBase === defaultTarget) {
defaultTarget =
selectedSkill.value.history.find((item) => item.version !== defaultBase)?.version || defaultTarget
}
compareBaseVersion.value = defaultBase
compareTargetVersion.value = defaultTarget
versionCompareOpen.value = true
await loadVersionCompare()
}
function openSpreadsheetChangeRecord(item) {
if (selectedSkill.value?.isPreviewMock) {
toast('预览数据暂不支持真实的线上差异对比。')
return
}
const publishedVersion = normalizeText(selectedSkill.value?.publishedVersion)
if (!selectedSkill.value?.id || !publishedVersion || publishedVersion === '-') {
toast('当前还没有线上版本,暂时无法查看与线上差异。')
return
}
openVersionCompare({
baseVersion: publishedVersion,
targetVersion: item.version
}).catch(() => {})
}
function closeVersionCompare() {
versionCompareOpen.value = false
}
async function loadVersionCompare() {
if (!selectedSkill.value?.id || !compareBaseVersion.value || !compareTargetVersion.value) {
return
}
versionCompareLoading.value = true
versionCompareError.value = ''
try {
versionComparePayload.value = await compareAgentAssetSpreadsheetVersions(
selectedSkill.value.id,
compareBaseVersion.value,
compareTargetVersion.value
)
} catch (error) {
versionComparePayload.value = null
versionCompareError.value = error?.message || '版本差异对比失败,请稍后重试。'
} finally {
versionCompareLoading.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleDocumentClick)
loadAssets({ force: true }).catch(() => {})
@@ -3129,7 +2928,6 @@ export default {
})
onBeforeUnmount(() => {
stopSpreadsheetOnlyOfficeDeferredRefresh()
destroySpreadsheetOnlyOfficeEditor()
document.removeEventListener('click', handleDocumentClick)
})
@@ -3186,7 +2984,7 @@ export default {
selectedSkillUsesSpreadsheet,
selectedSkillUsesJsonRisk,
selectedSpreadsheetFileName,
selectedSpreadsheetVersionModeLabel,
selectedSpreadsheetModeLabel,
selectedVersionTimelineItems,
selectedSpreadsheetChangeRecords,
detailBusy,
@@ -3205,18 +3003,10 @@ export default {
versionTimelineOpen,
versionTimelineLoading,
versionTimelineError,
versionCompareOpen,
versionCompareLoading,
versionCompareError,
versionComparePayload,
versionCompareCellRows,
versionCompareSheetRows,
spreadsheetChangeDetailOpen,
selectedSpreadsheetChangeRecord,
selectedSpreadsheetChangeSheetRows,
selectedSpreadsheetChangeCellRows,
compareBaseVersion,
compareTargetVersion,
openAssetDetail,
closeDetail,
resetFilters,
@@ -3243,12 +3033,8 @@ export default {
restoreSelectedVersion,
openVersionTimeline,
closeVersionTimeline,
openSpreadsheetChangeRecord,
openSpreadsheetChangeDetail,
closeSpreadsheetChangeDetail,
openVersionCompare,
closeVersionCompare,
loadVersionCompare,
loadAssets
}
}

View File

@@ -165,10 +165,19 @@ const REVIEW_OTHER_CATEGORY_OPTIONS = [
const REVIEW_SCENE_OTHER_OPTION = '其他场景'
const REVIEW_SCENE_OPTIONS = ['请客户吃饭', '出差行程', '住宿报销', '交通出行', '会务活动', REVIEW_SCENE_OTHER_OPTION]
const EXPENSE_CODE_TO_PRESET_SCENE = {
travel: '出差行程',
hotel: '住宿报销',
transport: '交通出行',
meeting: '会务活动',
entertainment: '请客户吃饭',
meal: '请客户吃饭'
}
const DATE_INPUT_FORMAT = 'YYYY-MM-DD'
const MAX_ATTACHMENTS = 10
const MAX_OCR_DOCUMENTS = 10
const VISIBLE_ATTACHMENT_CHIPS = 2
const COMPOSER_TEXTAREA_HEIGHT = 36
const COMPOSER_MAX_ROWS = 5
const EXPENSE_QUERY_PAGE_SIZE = 5
const SESSION_TYPE_EXPENSE = 'expense'
@@ -206,6 +215,76 @@ const FLOW_STEP_FALLBACKS = {
completedText: '结果已生成'
}
}
const ASSISTANT_DISPLAY_NAME = '财务助手'
const EXPENSE_WELCOME_QUICK_ACTIONS = [
{
label: '发起差旅报销',
prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。',
icon: 'mdi mdi-bag-suitcase-outline'
},
{
label: '招待费报销',
prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。',
icon: 'mdi mdi-food-fork-drink'
},
{
label: '交通费报销',
prompt: '我要报销交通出行费用,请帮我识别常见票据类型和报销注意事项。',
icon: 'mdi mdi-car-outline'
},
{
label: '上传票据识别',
prompt: '我已准备好票据,请帮我识别并生成报销草稿。',
icon: 'mdi mdi-file-upload-outline'
},
{
label: '查询近期报销',
prompt: '帮我查询近10天的报销记录和金额汇总。',
icon: 'mdi mdi-chart-timeline-variant'
},
{
label: '解释报销风险',
prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险。',
icon: 'mdi mdi-shield-alert-outline'
}
]
const ASSISTANT_DISPLAY_NAME = '财务助手'
const EXPENSE_WELCOME_QUICK_ACTIONS = [
{
label: '发起差旅报销',
prompt: '我要报销一笔出差费用,请帮我说明需要准备的材料,并引导我上传票据。',
icon: 'mdi mdi-bag-suitcase-outline'
},
{
label: '招待费报销',
prompt: '我要报销客户招待餐费,请告诉我需要补充的客户、参与人员和票据要求。',
icon: 'mdi mdi-food-fork-drink'
},
{
label: '交通费报销',
prompt: '我要报销交通出行费用,请帮我识别场景并列出待补充信息。',
icon: 'mdi mdi-car-outline'
},
{
label: '上传票据识别',
prompt: '我已准备好票据,请帮我识别票据内容并生成报销草稿。',
icon: 'mdi mdi-file-upload-outline'
},
{
label: '查询近期报销',
prompt: '帮我查询近10天的报销记录和金额汇总。',
icon: 'mdi mdi-chart-timeline-variant'
},
{
label: '解释报销风险',
prompt: '请结合公司制度,说明酒店超标、发票抬头不一致等常见报销风险与处理方式。',
icon: 'mdi mdi-shield-alert-outline'
}
]
const HOT_KNOWLEDGE_QUESTIONS = [
'差旅住宿标准按什么规则执行?',
'酒店超标后如何申请例外报销?',
@@ -1102,39 +1181,142 @@ function buildReviewDocumentCorrectionContext(drafts) {
}))
}
function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE) {
function buildWelcomeUserContext(user = {}) {
const username = String(user.username || '').trim()
const name = String(user.name || username || '同事').trim()
const grade = String(user.grade || '').trim()
const position = String(user.position || '').trim()
const role = String(user.role || '').trim()
const roleCodes = Array.isArray(user.roleCodes) ? user.roleCodes : []
const isAdmin =
Boolean(user.isAdmin)
|| username.toLowerCase() === 'admin'
|| roleCodes.some((item) => /admin|manager/i.test(String(item || '')))
|| /管理员|系统管理/.test(position)
|| /管理员|系统管理/.test(role)
const now = new Date()
const dateLine = now.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
weekday: 'long'
})
let honorific = name
if (isAdmin) {
honorific = name && !/^admin$/i.test(name) ? `${name} 管理员` : '管理员'
} else {
const prefix = [grade, position].filter(Boolean).join(' ')
honorific = prefix ? `${prefix} ${name}`.trim() : name
}
return {
name,
username,
grade,
position,
role,
isAdmin,
honorific,
dateLine
}
}
function buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest) {
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
return HOT_KNOWLEDGE_QUESTIONS.slice(0, 6).map((question) => ({
label: question.length > 20 ? `${question.slice(0, 20)}` : question,
prompt: question,
icon: 'mdi mdi-comment-question-outline'
}))
}
if (entrySource === 'detail' && linkedRequest?.id) {
return [
{
label: '补充当前单据票据',
prompt: `请结合单据 ${linkedRequest.id},帮我继续补充票据并更新识别结果。`,
icon: 'mdi mdi-file-plus-outline'
},
{
label: '解释本单风险',
prompt: `请解释单据 ${linkedRequest.id} 当前存在的报销风险与处理建议。`,
icon: 'mdi mdi-shield-alert-outline'
},
...EXPENSE_WELCOME_QUICK_ACTIONS.slice(0, 4)
]
}
return EXPENSE_WELCOME_QUICK_ACTIONS
}
function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
const ctx = buildWelcomeUserContext(user || {})
const greeting = ctx.isAdmin ? `${ctx.honorific},您好` : `您好,${ctx.honorific}`
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'欢迎进入 **个人财务中心 · 知识问答**。我是您的财务助手,可以帮您查制度、报销标准、票据要求和常见财务问题。',
'',
'您可以直接输入问题,或点击下方「猜你想问」快速开始。'
].join('\n')
}
if (entrySource === 'detail' && linkedRequest?.id) {
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
`我已为您打开关联单据 **${linkedRequest.id}**。您可以继续补充票据、核对识别结果,或让我解释待补项与风险。`,
'',
'如需新建其他报销,也可以直接告诉我费用场景,或上传发票、行程单开始识别。'
].join('\n')
}
return [
`${greeting}!今日是 **${ctx.dateLine}**。`,
'',
'**欢迎来到个人财务中心。** 我是您的财务助手,可以陪您完成票据识别、报销草稿整理、待补项提醒和风险说明。',
'',
'您可以描述一笔费用、上传票据,或点击下方快捷操作直接开始。'
].join('\n')
}
function buildWelcomeInsight(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
const ctx = buildWelcomeUserContext(user || {})
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
return {
intent: 'welcome',
metricLabel: '当前模式',
metricValue: '知识问答',
metricLabel: '今日',
metricValue: ctx.dateLine.split(' ')[0] || '—',
title: '财务知识问答',
summary: '这里适合处理制度解释、报销规则、票据规范和常见财务问题,右侧提供 Top 10 热门问题可直接追问。',
summary: `${ctx.honorific},右侧整理了热门制度问题,点选即可追问;左侧也可直接输入您关心的问题。`,
agent: null
}
}
return {
intent: 'welcome',
metricLabel: '当前状态',
metricValue: '待识别',
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '等待识别内容',
metricLabel: '助手状态',
metricValue: '待您吩咐',
title: entrySource === 'detail' && linkedRequest?.id ? `已关联 ${linkedRequest.id}` : '个人财务中心',
summary:
entrySource === 'detail' && linkedRequest?.id
? '发送消息后会直接结合当前单据上下文识别报销语义,右侧展示已识别内容,主对话区展示待补项和风险提示。'
: '请输入费用场景或上传票据右侧展示识别内容,主对话区会提示待补信息风险注意事项。',
? `${ctx.honorific},发送消息或上传附件后,我会结合当前单据继续识别并提示待补项。`
: `${ctx.honorific},描述费用场景或上传票据后,我会在右侧展示识别结果,并在对话中提示待补信息风险`,
agent: null
}
}
function buildWelcomeMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE) {
if (sessionType === SESSION_TYPE_KNOWLEDGE) {
return '已切换到财务知识问答会话。你可以直接提问制度、报销规则、票据要求或常见财务问题。'
}
return entrySource === 'detail' && linkedRequest?.id
? `已进入财务AI工作台当前关联单据 ${linkedRequest.id}。请描述费用场景或补充票据。`
: '这里是财务AI工作台。你发送的内容会直接进入真实 Orchestrator 和 User Agent。'
function createWelcomeAssistantMessage(entrySource, linkedRequest, sessionType = SESSION_TYPE_EXPENSE, user = null) {
return createMessage('assistant', buildWelcomeMessage(entrySource, linkedRequest, sessionType, user), [], {
assistantName: ASSISTANT_DISPLAY_NAME,
isWelcome: true,
welcomeQuickActions: buildWelcomeQuickActions(sessionType, user, entrySource, linkedRequest)
})
}
function resolveInitialSessionType(conversation) {
@@ -1360,7 +1542,8 @@ function buildDraftSavedPayload({
|| String(inlineState?.scene_label || '').trim()
|| String(draftPayload?.title || '').trim()
|| `${typeLabel}报销草稿`
const sceneLabel = String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel)).trim() || typeLabel
const sceneLabel =
String(inlineState?.scene_label || summarizeReviewScene(title, typeLabel, reviewPayload)).trim() || typeLabel
const attachmentSummary = documents.length
? `${documents.length} 条识别票据 / ${documents.length} 份材料`
: String(inlineState?.attachment_names || '').trim()
@@ -1882,34 +2065,160 @@ function buildReviewIntentText(reviewPayload) {
function buildReviewSceneValue(reviewPayload) {
const slotMap = buildReviewSlotMap(reviewPayload)
const reason = String(slotMap.reason?.value || slotMap.reason?.raw_value || '').trim()
const expenseType = String(slotMap.expense_type?.value || '').trim()
return summarizeReviewScene(reason, expenseType)
const reason = String(slotMap.reason?.raw_value || slotMap.reason?.value || '').trim()
const expenseType = String(slotMap.expense_type?.value || slotMap.expense_type?.normalized_value || '').trim()
return inferPresetSceneFromReview(reviewPayload, reason, expenseType)
}
function summarizeReviewScene(reason, expenseType = '') {
const normalizedReason = String(reason || '').trim()
const normalizedExpenseType = String(expenseType || '').trim()
const compactReason = normalizedReason.replace(/\s+/g, '')
function matchPresetSceneFromReason(reason) {
const compactReason = String(reason || '').trim().replace(/\s+/g, '')
if (!compactReason) {
return ''
}
if (/请客户.*吃饭|客户.*吃饭|招待|宴请|接待|客户接待/.test(compactReason)) {
return '请客户吃饭'
}
if (/出差行程|住宿报销|交通出行|会务活动/.test(compactReason)) {
const matchedPreset = REVIEW_SCENE_OPTIONS.find((option) => compactReason.includes(option.replace(/\s+/g, '')))
if (matchedPreset && matchedPreset !== REVIEW_SCENE_OTHER_OPTION) {
return matchedPreset
}
}
if (/出差|差旅/.test(compactReason)) {
return '出差行程'
}
if (/酒店|住宿/.test(compactReason)) {
return '住宿报销'
}
if (/交通|打车|车费|停车|网约车|出租车|地铁|公交/.test(compactReason)) {
return '交通出行'
}
if (/会务|会议|参会|论坛|展会/.test(compactReason)) {
return '会务活动'
}
return ''
}
if (compactReason) {
if (/请客户.*吃饭|客户.*吃饭|招待|宴请/.test(compactReason)) return '请客户吃饭'
if (/出差|差旅/.test(compactReason)) return '出差行程'
if (/酒店|住宿/.test(compactReason)) return '住宿报销'
if (/交通|打车|车费|停车/.test(compactReason)) return '交通出行'
if (/会务|会议|参会/.test(compactReason)) return '会务活动'
return compactReason.length > 12 ? `${compactReason.slice(0, 12)}...` : compactReason
function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') {
const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)]
if (fromCode) {
return fromCode
}
if (normalizedExpenseType === '业务招待费') return '请客户吃饭'
if (normalizedExpenseType === '差旅费') return '出差行程'
if (normalizedExpenseType === '住宿费') return '住宿报销'
if (normalizedExpenseType === '交通费') return '交通出行'
if (normalizedExpenseType === '会务费') return '会务活动'
if (normalizedExpenseType) return normalizedExpenseType
const compactLabel = String(sceneLabel || '').trim().replace(/\s+/g, '')
if (!compactLabel) {
return ''
}
if (/差旅|出差/.test(compactLabel)) {
return '出差行程'
}
if (/住宿|酒店/.test(compactLabel)) {
return '住宿报销'
}
if (/交通/.test(compactLabel)) {
return '交通出行'
}
if (/招待|餐饮|餐费|伙食/.test(compactLabel)) {
return '请客户吃饭'
}
if (/会务|会议/.test(compactLabel)) {
return '会务活动'
}
return ''
}
function mapExpenseTypeLabelToPresetScene(expenseType) {
const code = resolveExpenseTypeCode(expenseType)
if (EXPENSE_CODE_TO_PRESET_SCENE[code]) {
return EXPENSE_CODE_TO_PRESET_SCENE[code]
}
const compactLabel = String(expenseType || '').trim().replace(/\s+/g, '')
if (!compactLabel) {
return ''
}
if (compactLabel.includes('差旅') || compactLabel.includes('出差')) {
return '出差行程'
}
if (compactLabel.includes('住宿') || compactLabel.includes('酒店')) {
return '住宿报销'
}
if (compactLabel.includes('交通')) {
return '交通出行'
}
if (compactLabel.includes('招待') || compactLabel.includes('餐饮') || compactLabel.includes('伙食')) {
return '请客户吃饭'
}
if (compactLabel.includes('会务') || compactLabel.includes('会议')) {
return '会务活动'
}
return matchPresetSceneFromReason(expenseType)
}
function inferPresetSceneFromReview(reviewPayload, reasonValue = '', expenseType = '') {
const documents = Array.isArray(reviewPayload?.document_cards) ? reviewPayload.document_cards : []
if (documents.length) {
const votes = new Map()
for (const document of documents) {
const preset =
mapOcrSceneLabelToPresetScene(document.scene_label, document.suggested_expense_type)
|| mapExpenseTypeLabelToPresetScene(document.suggested_expense_type)
if (!preset) {
continue
}
votes.set(preset, (votes.get(preset) || 0) + 1)
}
if (votes.size) {
return [...votes.entries()].sort((left, right) => right[1] - left[1])[0][0]
}
}
const claimGroups = Array.isArray(reviewPayload?.claim_groups) ? reviewPayload.claim_groups : []
if (claimGroups.length === 1) {
const group = claimGroups[0]
const preset =
mapExpenseTypeLabelToPresetScene(group.expense_type)
|| mapOcrSceneLabelToPresetScene(group.scene_label, group.expense_type)
if (preset) {
return preset
}
}
const fromReason = matchPresetSceneFromReason(reasonValue)
if (fromReason) {
return fromReason
}
const fromExpenseType = mapExpenseTypeLabelToPresetScene(expenseType)
if (fromExpenseType) {
return fromExpenseType
}
if (String(reasonValue || '').trim()) {
return REVIEW_SCENE_OTHER_OPTION
}
return '待补充'
}
function formatReviewSceneDisplayValue(inlineState) {
const scene = String(inlineState?.scene_label || '').trim()
if (!scene || scene === '待补充') {
return '待补充'
}
if (scene === REVIEW_SCENE_OTHER_OPTION) {
const detail = String(inlineState?.reason_value || '').trim()
if (!detail) {
return REVIEW_SCENE_OTHER_OPTION
}
return detail.length > 18 ? `${REVIEW_SCENE_OTHER_OPTION}${detail.slice(0, 18)}...` : `${REVIEW_SCENE_OTHER_OPTION}${detail}`
}
return scene
}
function summarizeReviewScene(reason, expenseType = '', reviewPayload = null) {
return inferPresetSceneFromReview(reviewPayload, reason, expenseType)
}
function buildInlineReviewState(reviewPayload) {
const slotMap = buildReviewSlotMap(reviewPayload)
const editFieldMap = buildReviewEditFieldMap(reviewPayload?.edit_fields)
@@ -1925,8 +2234,9 @@ function buildInlineReviewState(reviewPayload) {
: 0
const expenseType = String(editFieldMap.expense_type?.value || slotMap.expense_type?.value || '').trim()
const reasonValue = String(
editFieldMap.reason?.value || slotMap.reason?.value || slotMap.reason?.raw_value || ''
editFieldMap.reason?.value || slotMap.reason?.raw_value || slotMap.reason?.value || ''
).trim()
const sceneLabel = inferPresetSceneFromReview(reviewPayload, reasonValue, expenseType)
return {
occurred_date: String(
@@ -1935,8 +2245,11 @@ function buildInlineReviewState(reviewPayload) {
amount: normalizeAmountValue(
String(editFieldMap.amount?.value || slotMap.amount?.normalized_value || slotMap.amount?.value || '').trim()
),
scene_label: summarizeReviewScene(reasonValue, expenseType),
reason_value: reasonValue,
scene_label: sceneLabel,
reason_value:
sceneLabel === REVIEW_SCENE_OTHER_OPTION
? reasonValue
: String(slotMap.reason?.raw_value || '').trim() || reasonValue,
customer_name: String(editFieldMap.customer_name?.value || slotMap.customer_name?.value || '').trim(),
location: String(
editFieldMap.business_location?.value ||
@@ -2000,7 +2313,7 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
{
key: 'scene',
label: '场景 / 事由',
value: String(inlineState.reason_value || inlineState.scene_label || '').trim() || '待补充',
value: formatReviewSceneDisplayValue(inlineState),
icon: 'mdi mdi-silverware-fork-knife',
editor: 'select',
modelKey: 'scene_label',
@@ -2524,6 +2837,7 @@ export default {
const composerSingleDate = ref(formatDateInputValue())
const composerRangeStartDate = ref(formatDateInputValue())
const composerRangeEndDate = ref(formatDateInputValue())
const composerBusinessTimeTags = ref([])
const attachedFiles = ref([])
const composerFilesExpanded = ref(false)
const submitting = ref(false)
@@ -2584,7 +2898,14 @@ export default {
let flowTickTimer = 0
const flowSimulationTimers = []
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
|| composerBusinessTimeTags.value.length
)
)
const composerCanApplyDateSelection = computed(() => {
if (composerDateMode.value === 'single') {
@@ -2655,8 +2976,8 @@ export default {
agent: '知识回答'
}
: {
welcome: '等待输入',
agent: '真实智能体'
welcome: '财务助手',
agent: '处理中'
}
return labels[currentInsight.value.intent] ?? 'AI 处理中'
})
@@ -2780,10 +3101,11 @@ export default {
sessionType,
messages: restoredMessages.length
? restoredMessages
: [createMessage('assistant', buildWelcomeMessage(props.entrySource, linkedRequest.value, sessionType))],
: [createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)],
conversationId: resolveInitialConversationId(conversation),
draftClaimId: resolveInitialDraftClaimId(conversation),
currentInsight: initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType),
currentInsight:
initialInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
reviewFilePreviews: restoredReviewFilePreviews,
composerDraft: '',
attachedFiles: [],
@@ -2796,10 +3118,17 @@ export default {
function buildEmptySessionState(sessionType) {
return {
sessionType,
messages: [createMessage('assistant', buildWelcomeMessage(props.entrySource, linkedRequest.value, sessionType))],
messages: [
createWelcomeAssistantMessage(props.entrySource, linkedRequest.value, sessionType, currentUser.value)
],
conversationId: '',
draftClaimId: '',
currentInsight: buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType),
currentInsight: buildWelcomeInsight(
props.entrySource,
linkedRequest.value,
sessionType,
currentUser.value
),
reviewFilePreviews: [],
composerDraft: '',
attachedFiles: [],
@@ -2835,10 +3164,24 @@ export default {
activeSessionType.value = nextState.sessionType || SESSION_TYPE_EXPENSE
messages.value = Array.isArray(nextState.messages) && nextState.messages.length
? nextState.messages
: [createMessage('assistant', buildWelcomeMessage(props.entrySource, linkedRequest.value, activeSessionType.value))]
: [
createWelcomeAssistantMessage(
props.entrySource,
linkedRequest.value,
activeSessionType.value,
currentUser.value
)
]
conversationId.value = String(nextState.conversationId || '').trim()
draftClaimId.value = String(nextState.draftClaimId || '').trim()
currentInsight.value = nextState.currentInsight || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value)
currentInsight.value =
nextState.currentInsight
|| buildWelcomeInsight(
props.entrySource,
linkedRequest.value,
activeSessionType.value,
currentUser.value
)
reviewFilePreviews.value = Array.isArray(nextState.reviewFilePreviews) ? nextState.reviewFilePreviews : []
composerDraft.value = String(nextState.composerDraft || '')
attachedFiles.value = Array.isArray(nextState.attachedFiles) ? nextState.attachedFiles : []
@@ -2981,6 +3324,7 @@ export default {
)
onMounted(() => {
document.addEventListener('click', handleComposerDatePickerOutside)
flowTickTimer = window.setInterval(() => {
flowTick.value = Date.now()
}, 250)
@@ -2988,7 +3332,9 @@ export default {
workbenchVisible.value = true
})
void clearKnowledgeSessionOnEntry()
currentInsight.value = currentInsight.value || buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value)
currentInsight.value =
currentInsight.value
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, activeSessionType.value, currentUser.value)
if (props.initialPrompt?.trim() || props.initialFiles.length) {
const initialMerge = mergeFilesWithLimit([], Array.from(props.initialFiles), MAX_ATTACHMENTS)
composerDraft.value = props.initialPrompt.trim()
@@ -3007,6 +3353,7 @@ export default {
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleComposerDatePickerOutside)
if (flowTickTimer) {
window.clearInterval(flowTickTimer)
}
@@ -3036,16 +3383,18 @@ export default {
function adjustComposerTextareaHeight() {
if (!composerTextareaRef.value) return
composerTextareaRef.value.style.height = 'auto'
const styles = window.getComputedStyle(composerTextareaRef.value)
const lineHeight = Number.parseFloat(styles.lineHeight) || 24
const textarea = composerTextareaRef.value
textarea.style.height = 'auto'
const styles = window.getComputedStyle(textarea)
const lineHeight = Number.parseFloat(styles.lineHeight) || 20
const verticalPadding =
Number.parseFloat(styles.paddingTop || '0') + Number.parseFloat(styles.paddingBottom || '0')
const minHeight = COMPOSER_TEXTAREA_HEIGHT
const maxHeight = lineHeight * COMPOSER_MAX_ROWS + verticalPadding
const nextHeight = Math.max(minHeight, Math.min(textarea.scrollHeight, maxHeight))
composerTextareaRef.value.style.height = `${Math.min(composerTextareaRef.value.scrollHeight, maxHeight)}px`
composerTextareaRef.value.style.overflowY =
composerTextareaRef.value.scrollHeight > maxHeight ? 'auto' : 'hidden'
textarea.style.height = `${nextHeight}px`
textarea.style.overflowY = textarea.scrollHeight > maxHeight ? 'auto' : 'hidden'
}
function handleComposerInput() {
@@ -3368,14 +3717,52 @@ export default {
return formatFlowDuration(step?.durationMs)
}
function buildComposerDateSelectionText() {
function buildComposerBusinessTimeLabel() {
if (composerDateMode.value === 'single') {
return `发生时间${composerSingleDate.value}`
return `业务发生时间:${composerSingleDate.value}`
}
if (composerRangeStartDate.value === composerRangeEndDate.value) {
return `发生时间${composerRangeStartDate.value}`
return `业务发生时间:${composerRangeStartDate.value}`
}
return `发生时间${composerRangeStartDate.value}${composerRangeEndDate.value}`
return `业务发生时间:${composerRangeStartDate.value}${composerRangeEndDate.value}`
}
function resolveComposerSubmitText(explicitRawText) {
const draftPart = String(explicitRawText ?? composerDraft.value).trim()
const tagPart = composerBusinessTimeTags.value.map((item) => item.label).join('')
if (!tagPart) {
return draftPart
}
if (!draftPart) {
return tagPart
}
return `${tagPart}${draftPart}`
}
function toggleComposerDatePicker() {
composerDatePickerOpen.value = !composerDatePickerOpen.value
}
function closeComposerDatePicker() {
composerDatePickerOpen.value = false
}
function setComposerDateMode(mode) {
composerDateMode.value = mode === 'range' ? 'range' : 'single'
}
function removeComposerBusinessTimeTag(tagId) {
composerBusinessTimeTags.value = composerBusinessTimeTags.value.filter((item) => item.id !== tagId)
}
function handleComposerDatePickerOutside(event) {
if (!composerDatePickerOpen.value) {
return
}
if (event.target instanceof Element && event.target.closest('.composer-date-anchor')) {
return
}
composerDatePickerOpen.value = false
}
async function applyComposerDateSelection() {
@@ -3383,9 +3770,12 @@ export default {
return
}
const dateText = buildComposerDateSelectionText()
const currentDraft = composerDraft.value.trim()
composerDraft.value = currentDraft ? `${currentDraft}${dateText}` : dateText
composerBusinessTimeTags.value = [
{
id: `biz-time-${Date.now()}`,
label: buildComposerBusinessTimeLabel()
}
]
composerDatePickerOpen.value = false
await nextTick()
adjustComposerTextareaHeight()
@@ -4016,7 +4406,7 @@ export default {
async function submitComposer(options = {}) {
if (sessionSwitchBusy.value) return null
const rawText = String(options.rawText ?? composerDraft.value).trim()
const rawText = resolveComposerSubmitText(options.rawText).trim()
const systemGenerated = Boolean(options.systemGenerated)
const resolvedUploadDisposition =
String(options.uploadDisposition || '').trim() ||
@@ -4084,6 +4474,7 @@ export default {
messages.value.push(pendingMessage)
composerDraft.value = ''
composerBusinessTimeTags.value = []
clearAttachedFiles()
if (fileInputRef.value) {
fileInputRef.value.value = ''
@@ -4478,6 +4869,7 @@ export default {
return {
emit,
ASSISTANT_DISPLAY_NAME,
aiAvatar,
userAvatar,
fileInputRef,
@@ -4489,7 +4881,12 @@ export default {
composerSingleDate,
composerRangeStartDate,
composerRangeEndDate,
composerBusinessTimeTags,
composerCanApplyDateSelection,
toggleComposerDatePicker,
closeComposerDatePicker,
setComposerDateMode,
removeComposerBusinessTimeTag,
flowPanelOpen,
flowSteps,
flowRunId,
@@ -4604,6 +5001,7 @@ export default {
handleComposerInput,
handleComposerEnter,
runShortcut,
runWelcomeQuickAction: runShortcut,
askHotKnowledgeQuestion,
resolveKnowledgeRankLabel,
resolveKnowledgeRankTone,