feat: 添加风险规则及 agent assets 功能增强
This commit is contained in:
Binary file not shown.
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
29
server/rules/risk-rules/risk.expense.reason_too_brief.json
Normal file
29
server/rules/risk-rules/risk.expense.reason_too_brief.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
30
server/rules/risk-rules/risk.invoice.cross_year_invoice.json
Normal file
30
server/rules/risk-rules/risk.invoice.cross_year_invoice.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
29
server/rules/risk-rules/risk.invoice.duplicate_invoice.json
Normal file
29
server/rules/risk-rules/risk.invoice.duplicate_invoice.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
28
server/scripts/sync_platform_risk_rules.py
Normal file
28
server/scripts/sync_platform_risk_rules.py
Normal 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()
|
||||
13
server/scripts/test_rule_json_api.py
Normal file
13
server/scripts/test_rule_json_api.py
Normal 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"))
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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(
|
||||
@@ -325,31 +325,30 @@ def test_spreadsheet_version_compare_returns_sheet_and_cell_changes() -> None:
|
||||
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 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
|
||||
finally:
|
||||
live_path.write_bytes(original_live_bytes)
|
||||
|
||||
|
||||
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
BIN
web/UI/流程输入.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 114 KiB |
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
.assistant-overlay {
|
||||
/* 距屏幕边 10–18px,随视口微调;高度用 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) {
|
||||
/* 笔记本 / 中等屏:工作台正文字号整体下调一档 */
|
||||
@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;
|
||||
}
|
||||
|
||||
.assistant-modal-stage .message-answer-markdown :deep(table) {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.assistant-modal-stage .intent-pill {
|
||||
font-size: var(--wb-fs-chip);
|
||||
}
|
||||
}
|
||||
|
||||
@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: 348px;
|
||||
}
|
||||
|
||||
.insight-panel {
|
||||
width: 348px;
|
||||
}
|
||||
|
||||
.review-side-grid.compact {
|
||||
grid-template-columns: 1fr;
|
||||
width: clamp(280px, 26vw, 360px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
/* 笔记本常见宽度:改为上下布局,对话区占满宽度,避免侧栏挤占 */
|
||||
@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 {
|
||||
|
||||
@@ -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 })}`
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,8 +428,8 @@
|
||||
</div>
|
||||
|
||||
<div class="composer-row" :class="{ 'knowledge-mode': isKnowledgeSession }">
|
||||
<div v-if="!isKnowledgeSession" class="composer-leading-actions">
|
||||
<button
|
||||
v-if="!isKnowledgeSession"
|
||||
type="button"
|
||||
class="tool-btn composer-side-btn"
|
||||
:disabled="submitting || reviewActionBusy || sessionSwitchBusy"
|
||||
@@ -419,8 +438,99 @@
|
||||
>
|
||||
<i class="mdi mdi-paperclip"></i>
|
||||
</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">
|
||||
<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"
|
||||
@@ -432,6 +542,7 @@
|
||||
@keydown.ctrl.enter.prevent="submitComposer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="send-btn composer-side-btn" type="submit" :disabled="!canSubmit || reviewActionBusy || sessionSwitchBusy" aria-label="发送">
|
||||
<i :class="submitting ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-send'"></i>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '')
|
||||
|
||||
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 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 (normalizedExpenseType === '业务招待费') return '请客户吃饭'
|
||||
if (normalizedExpenseType === '差旅费') return '出差行程'
|
||||
if (normalizedExpenseType === '住宿费') return '住宿报销'
|
||||
if (normalizedExpenseType === '交通费') return '交通出行'
|
||||
if (normalizedExpenseType === '会务费') return '会务活动'
|
||||
if (normalizedExpenseType) return normalizedExpenseType
|
||||
function mapOcrSceneLabelToPresetScene(sceneLabel, suggestedExpenseType = '') {
|
||||
const fromCode = EXPENSE_CODE_TO_PRESET_SCENE[resolveExpenseTypeCode(suggestedExpenseType)]
|
||||
if (fromCode) {
|
||||
return fromCode
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user