feat: 增强知识库索引与设置页面模块化拆分
扩展知识库索引任务和 RAG 检索支持增量入库和文档去重,优 化本体检测和规则匹配精度,前端设置页面拆分为 LLM、邮件 和 Hermes 员工同步子面板并重构样式,新增日志详情组件和 知识入库日志模型,补充单元测试覆盖。
This commit is contained in:
@@ -120,7 +120,7 @@ class UserAgentReviewDocumentCard(BaseModel):
|
|||||||
filename: str = Field(description="原始文件名。")
|
filename: str = Field(description="原始文件名。")
|
||||||
document_type: str = Field(default="other", description="票据候选类型。")
|
document_type: str = Field(default="other", description="票据候选类型。")
|
||||||
suggested_expense_type: str = Field(default="other", description="建议归属费用类型。")
|
suggested_expense_type: str = Field(default="other", description="建议归属费用类型。")
|
||||||
scene_label: str = Field(default="", description="面向用户展示的场景标签。")
|
scene_label: str = Field(default="", description="面向用户展示的票据类型标签。")
|
||||||
summary: str = Field(default="", description="逐票据摘要。")
|
summary: str = Field(default="", description="逐票据摘要。")
|
||||||
avg_score: float = Field(default=0.0, ge=0.0, le=1.0, description="OCR 平均得分。")
|
avg_score: float = Field(default=0.0, ge=0.0, le=1.0, description="OCR 平均得分。")
|
||||||
preview_kind: str = Field(default="", description="票据预览类型,例如 image。")
|
preview_kind: str = Field(default="", description="票据预览类型,例如 image。")
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ from __future__ import annotations
|
|||||||
import re
|
import re
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.services.expense_type_keywords import iter_expense_keywords
|
||||||
|
|
||||||
EXPENSE_TYPE_LABELS = {
|
EXPENSE_TYPE_LABELS = {
|
||||||
"travel": "差旅",
|
"travel": "差旅",
|
||||||
"train_ticket": "火车票",
|
"train_ticket": "火车票",
|
||||||
@@ -12,10 +14,10 @@ EXPENSE_TYPE_LABELS = {
|
|||||||
"travel_allowance": "出差补贴",
|
"travel_allowance": "出差补贴",
|
||||||
"hotel": "住宿",
|
"hotel": "住宿",
|
||||||
"transport": "交通",
|
"transport": "交通",
|
||||||
"meal": "餐费",
|
"meal": "业务招待",
|
||||||
"meeting": "会务",
|
"meeting": "会务",
|
||||||
"entertainment": "招待",
|
"entertainment": "招待",
|
||||||
"office": "办公",
|
"office": "办公用品",
|
||||||
"training": "培训",
|
"training": "培训",
|
||||||
"communication": "通讯",
|
"communication": "通讯",
|
||||||
"welfare": "福利",
|
"welfare": "福利",
|
||||||
@@ -131,40 +133,19 @@ DOCUMENT_ROUTE_DESTINATION_LABELS = {
|
|||||||
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES = {"", "other", "travel", "transport", "hotel"}
|
GENERIC_ATTACHMENT_BACKFILL_ITEM_TYPES = {"", "other", "travel", "transport", "hotel"}
|
||||||
LOCATION_REQUIRED_EXPENSE_TYPES = {"travel", "meeting", "entertainment"}
|
LOCATION_REQUIRED_EXPENSE_TYPES = {"travel", "meeting", "entertainment"}
|
||||||
EXPENSE_SCENE_KEYWORDS = {
|
EXPENSE_SCENE_KEYWORDS = {
|
||||||
"travel": ("差旅", "出差", "行程"),
|
code: tuple(iter_expense_keywords(code))
|
||||||
"hotel": ("酒店", "住宿", "房费", "客房", "入住", "离店"),
|
for code in (
|
||||||
"transport": (
|
"travel",
|
||||||
"交通",
|
"hotel",
|
||||||
"打车",
|
"transport",
|
||||||
"出租车",
|
"meal",
|
||||||
"网约车",
|
"entertainment",
|
||||||
"滴滴",
|
"office",
|
||||||
"出行",
|
"meeting",
|
||||||
"乘车",
|
"training",
|
||||||
"用车",
|
"communication",
|
||||||
"叫车",
|
"welfare",
|
||||||
"车费",
|
)
|
||||||
"车资",
|
|
||||||
"的士",
|
|
||||||
"高铁",
|
|
||||||
"动车",
|
|
||||||
"火车",
|
|
||||||
"机票",
|
|
||||||
"航班",
|
|
||||||
"行程单",
|
|
||||||
"登机",
|
|
||||||
"客票",
|
|
||||||
"公交",
|
|
||||||
"地铁",
|
|
||||||
"过路费",
|
|
||||||
"通行费",
|
|
||||||
"停车",
|
|
||||||
),
|
|
||||||
"meal": ("餐饮", "餐费", "用餐", "外卖", "快餐", "酒楼", "饭店", "饭馆", "食品", "咖啡"),
|
|
||||||
"entertainment": ("招待", "宴请", "接待", "客户餐", "商务餐", "业务招待"),
|
|
||||||
"office": ("办公", "办公用品", "文具", "耗材", "打印", "纸张", "硒鼓", "墨盒", "鼠标", "键盘", "电脑"),
|
|
||||||
"meeting": ("会议", "会务", "会展", "会议室", "会场", "场地费", "论坛"),
|
|
||||||
"training": ("培训", "课程", "讲师", "教材", "学费", "认证"),
|
|
||||||
}
|
}
|
||||||
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = {
|
EXPENSE_TYPE_ALLOWED_DOCUMENT_SCENES = {
|
||||||
"travel": {"travel", "hotel", "transport", "meal"},
|
"travel": {"travel", "hotel", "transport", "meal"},
|
||||||
@@ -185,7 +166,7 @@ DOCUMENT_SCENE_LABELS = {
|
|||||||
"travel": "差旅",
|
"travel": "差旅",
|
||||||
"hotel": "住宿",
|
"hotel": "住宿",
|
||||||
"transport": "交通",
|
"transport": "交通",
|
||||||
"meal": "餐饮",
|
"meal": "业务招待",
|
||||||
"entertainment": "业务招待",
|
"entertainment": "业务招待",
|
||||||
"office": "办公用品",
|
"office": "办公用品",
|
||||||
"meeting": "会务",
|
"meeting": "会务",
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ from app.services.expense_claim_constants import (
|
|||||||
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
|
||||||
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
|
||||||
)
|
)
|
||||||
|
from app.services.expense_type_keywords import resolve_expense_type_code_from_text
|
||||||
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
|
||||||
from app.services.expense_amounts import (
|
from app.services.expense_amounts import (
|
||||||
extract_amount_candidates,
|
extract_amount_candidates,
|
||||||
@@ -209,26 +210,7 @@ class ExpenseClaimOntologyResolverMixin:
|
|||||||
or ""
|
or ""
|
||||||
).replace(" ", "")
|
).replace(" ", "")
|
||||||
if compact:
|
if compact:
|
||||||
if "招待" in compact or ("客户" in compact and any(word in compact for word in ("吃饭", "宴请", "请客", "用餐"))):
|
return resolve_expense_type_code_from_text(compact)
|
||||||
return "entertainment"
|
|
||||||
if any(word in compact for word in ("差旅", "出差", "机票", "行程")):
|
|
||||||
return "travel"
|
|
||||||
if any(word in compact for word in ("住宿", "酒店", "宾馆")):
|
|
||||||
return "hotel"
|
|
||||||
if any(word in compact for word in ("交通", "打车", "网约车", "出租车", "乘车", "用车", "叫车", "车费", "车资", "的士", "停车")):
|
|
||||||
return "transport"
|
|
||||||
if any(word in compact for word in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")):
|
|
||||||
return "meal"
|
|
||||||
if "会务" in compact:
|
|
||||||
return "meeting"
|
|
||||||
if any(word in compact for word in ("办公费", "办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板")):
|
|
||||||
return "office"
|
|
||||||
if any(word in compact for word in ("培训费", "培训", "讲师费", "课时费", "课程费")):
|
|
||||||
return "training"
|
|
||||||
if any(word in compact for word in ("通讯费", "话费", "流量费", "宽带费")):
|
|
||||||
return "communication"
|
|
||||||
if any(word in compact for word in ("福利费", "团建", "慰问", "节日福利", "体检费")):
|
|
||||||
return "welfare"
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
|
|||||||
@@ -538,8 +538,8 @@ class ExpenseRuleRuntimeService:
|
|||||||
if any(keyword in normalized for keyword in ("市内交通", "打车", "网约车", "出租车")):
|
if any(keyword in normalized for keyword in ("市内交通", "打车", "网约车", "出租车")):
|
||||||
return "transport"
|
return "transport"
|
||||||
if "招待" in normalized and "餐" in normalized:
|
if "招待" in normalized and "餐" in normalized:
|
||||||
return "entertainment"
|
return "meal"
|
||||||
if "餐补" in normalized or normalized == "餐费":
|
if "餐补" in normalized or normalized in {"餐费", "业务招待费"}:
|
||||||
return "meal"
|
return "meal"
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@@ -547,7 +547,7 @@ class ExpenseRuleRuntimeService:
|
|||||||
def _spreadsheet_metric_label(expense_type: str) -> str:
|
def _spreadsheet_metric_label(expense_type: str) -> str:
|
||||||
return {
|
return {
|
||||||
"transport": "单笔交通金额",
|
"transport": "单笔交通金额",
|
||||||
"meal": "差旅餐补金额",
|
"meal": "业务招待费金额",
|
||||||
"entertainment": "人均招待餐费",
|
"entertainment": "人均招待餐费",
|
||||||
}.get(expense_type, "金额")
|
}.get(expense_type, "金额")
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ EXPENSE_RULE_CODE_BLOCK_PATTERN = re.compile(r"```expense-rule\s*(\{.*?\})\s*```
|
|||||||
DOCUMENT_TYPE_LABELS = {
|
DOCUMENT_TYPE_LABELS = {
|
||||||
"flight_itinerary": "机票/航班行程单",
|
"flight_itinerary": "机票/航班行程单",
|
||||||
"train_ticket": "火车/高铁票",
|
"train_ticket": "火车/高铁票",
|
||||||
|
"ship_ticket": "轮船票",
|
||||||
"hotel_invoice": "酒店住宿票据",
|
"hotel_invoice": "酒店住宿票据",
|
||||||
"taxi_receipt": "出租车/网约车票据",
|
"taxi_receipt": "出租车/网约车票据",
|
||||||
"parking_toll_receipt": "停车/通行费票据",
|
"parking_toll_receipt": "停车/通行费票据",
|
||||||
@@ -24,9 +25,9 @@ SCENE_LABELS = {
|
|||||||
"travel": "差旅",
|
"travel": "差旅",
|
||||||
"hotel": "住宿",
|
"hotel": "住宿",
|
||||||
"transport": "交通",
|
"transport": "交通",
|
||||||
"meal": "餐饮",
|
"meal": "业务招待",
|
||||||
"entertainment": "业务招待",
|
"entertainment": "业务招待",
|
||||||
"office": "办公",
|
"office": "办公用品",
|
||||||
"meeting": "会务",
|
"meeting": "会务",
|
||||||
"training": "培训",
|
"training": "培训",
|
||||||
"communication": "通讯",
|
"communication": "通讯",
|
||||||
@@ -73,7 +74,7 @@ DEFAULT_SCENE_MATRIX_CONFIG: dict[str, Any] = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"meal": {
|
"meal": {
|
||||||
"label": "餐费",
|
"label": "业务招待费",
|
||||||
"location_required": False,
|
"location_required": False,
|
||||||
"min_attachment_count": 1,
|
"min_attachment_count": 1,
|
||||||
"allowed_scene_codes": ["meal"],
|
"allowed_scene_codes": ["meal"],
|
||||||
@@ -84,7 +85,7 @@ DEFAULT_SCENE_MATRIX_CONFIG: dict[str, Any] = {
|
|||||||
"warn_amount": "300.00",
|
"warn_amount": "300.00",
|
||||||
"block_amount": "800.00",
|
"block_amount": "800.00",
|
||||||
"exception_keywords": ["客户接待", "团队活动", "加班", "展会", "超标说明"],
|
"exception_keywords": ["客户接待", "团队活动", "加班", "展会", "超标说明"],
|
||||||
"metric_label": "餐费合计",
|
"metric_label": "业务招待费合计",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"entertainment": {
|
"entertainment": {
|
||||||
@@ -103,7 +104,7 @@ DEFAULT_SCENE_MATRIX_CONFIG: dict[str, Any] = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
"office": {
|
"office": {
|
||||||
"label": "办公费",
|
"label": "办公用品费",
|
||||||
"location_required": False,
|
"location_required": False,
|
||||||
"min_attachment_count": 1,
|
"min_attachment_count": 1,
|
||||||
"allowed_scene_codes": ["office"],
|
"allowed_scene_codes": ["office"],
|
||||||
@@ -114,7 +115,7 @@ DEFAULT_SCENE_MATRIX_CONFIG: dict[str, Any] = {
|
|||||||
"warn_amount": "1500.00",
|
"warn_amount": "1500.00",
|
||||||
"block_amount": "5000.00",
|
"block_amount": "5000.00",
|
||||||
"exception_keywords": ["批量采购", "固定资产", "部门集中采购", "超标说明"],
|
"exception_keywords": ["批量采购", "固定资产", "部门集中采购", "超标说明"],
|
||||||
"metric_label": "办公费合计",
|
"metric_label": "办公用品费合计",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"meeting": {
|
"meeting": {
|
||||||
|
|||||||
245
server/src/app/services/expense_type_keywords.py
Normal file
245
server/src/app/services/expense_type_keywords.py
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
|
||||||
|
EXPENSE_TYPE_KEYWORD_GROUPS: tuple[tuple[str, str, tuple[str, ...]], ...] = (
|
||||||
|
(
|
||||||
|
"travel",
|
||||||
|
"差旅费",
|
||||||
|
(
|
||||||
|
"差旅费",
|
||||||
|
"差旅",
|
||||||
|
"出差",
|
||||||
|
"外地出差",
|
||||||
|
"跨城交通",
|
||||||
|
"往返车票",
|
||||||
|
"机票",
|
||||||
|
"飞机票",
|
||||||
|
"航班",
|
||||||
|
"登机牌",
|
||||||
|
"行程单",
|
||||||
|
"火车票",
|
||||||
|
"高铁票",
|
||||||
|
"动车票",
|
||||||
|
"铁路客票",
|
||||||
|
"客票",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"hotel",
|
||||||
|
"住宿费",
|
||||||
|
(
|
||||||
|
"住宿费",
|
||||||
|
"住宿",
|
||||||
|
"酒店发票",
|
||||||
|
"酒店",
|
||||||
|
"宾馆",
|
||||||
|
"民宿",
|
||||||
|
"房费",
|
||||||
|
"客房",
|
||||||
|
"住店",
|
||||||
|
"入住",
|
||||||
|
"离店",
|
||||||
|
"住宿清单",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"transport",
|
||||||
|
"交通费",
|
||||||
|
(
|
||||||
|
"交通费",
|
||||||
|
"交通",
|
||||||
|
"市内交通",
|
||||||
|
"打车",
|
||||||
|
"网约车",
|
||||||
|
"出租车票",
|
||||||
|
"出租车",
|
||||||
|
"的士票",
|
||||||
|
"的士",
|
||||||
|
"滴滴",
|
||||||
|
"曹操出行",
|
||||||
|
"T3出行",
|
||||||
|
"出行",
|
||||||
|
"乘车费",
|
||||||
|
"乘车",
|
||||||
|
"用车",
|
||||||
|
"叫车",
|
||||||
|
"车费",
|
||||||
|
"车资",
|
||||||
|
"公交",
|
||||||
|
"地铁",
|
||||||
|
"停车费",
|
||||||
|
"停车",
|
||||||
|
"过路费",
|
||||||
|
"通行费",
|
||||||
|
"高速费",
|
||||||
|
"燃油费",
|
||||||
|
"油费",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"meal",
|
||||||
|
"业务招待费",
|
||||||
|
(
|
||||||
|
"业务招待费",
|
||||||
|
"业务招待",
|
||||||
|
"招待费",
|
||||||
|
"招待",
|
||||||
|
"客户招待",
|
||||||
|
"客户接待",
|
||||||
|
"商务接待",
|
||||||
|
"商务宴请",
|
||||||
|
"宴请",
|
||||||
|
"请客",
|
||||||
|
"请客户",
|
||||||
|
"客户用餐",
|
||||||
|
"客户餐",
|
||||||
|
"客户吃饭",
|
||||||
|
"陪同用餐",
|
||||||
|
"接待餐",
|
||||||
|
"餐费",
|
||||||
|
"伙食费",
|
||||||
|
"伙食",
|
||||||
|
"工作餐",
|
||||||
|
"餐饮",
|
||||||
|
"用餐",
|
||||||
|
"早餐",
|
||||||
|
"午餐",
|
||||||
|
"晚餐",
|
||||||
|
"夜宵",
|
||||||
|
"盒饭",
|
||||||
|
"茶歇",
|
||||||
|
"餐票",
|
||||||
|
"饭票",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"meeting",
|
||||||
|
"会务费",
|
||||||
|
(
|
||||||
|
"会务费",
|
||||||
|
"会务",
|
||||||
|
"会议费",
|
||||||
|
"会议",
|
||||||
|
"参会",
|
||||||
|
"会场",
|
||||||
|
"场地费",
|
||||||
|
"论坛",
|
||||||
|
"展会",
|
||||||
|
"研讨会",
|
||||||
|
"峰会",
|
||||||
|
"布展",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"office",
|
||||||
|
"办公用品费",
|
||||||
|
(
|
||||||
|
"办公用品费",
|
||||||
|
"办公费",
|
||||||
|
"办公用品",
|
||||||
|
"办公耗材",
|
||||||
|
"办公设备",
|
||||||
|
"办公",
|
||||||
|
"文具",
|
||||||
|
"耗材",
|
||||||
|
"打印纸",
|
||||||
|
"打印",
|
||||||
|
"纸张",
|
||||||
|
"硒鼓",
|
||||||
|
"墨盒",
|
||||||
|
"键盘",
|
||||||
|
"鼠标",
|
||||||
|
"白板",
|
||||||
|
"电脑配件",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"training",
|
||||||
|
"培训费",
|
||||||
|
(
|
||||||
|
"培训费",
|
||||||
|
"培训",
|
||||||
|
"讲师费",
|
||||||
|
"讲师",
|
||||||
|
"课时费",
|
||||||
|
"课程费",
|
||||||
|
"课程",
|
||||||
|
"教材",
|
||||||
|
"学费",
|
||||||
|
"考试费",
|
||||||
|
"认证费",
|
||||||
|
"认证",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"communication",
|
||||||
|
"通讯费",
|
||||||
|
(
|
||||||
|
"通讯费",
|
||||||
|
"通讯",
|
||||||
|
"话费",
|
||||||
|
"电话费",
|
||||||
|
"手机费",
|
||||||
|
"流量费",
|
||||||
|
"流量",
|
||||||
|
"宽带费",
|
||||||
|
"宽带",
|
||||||
|
"网络费",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"welfare",
|
||||||
|
"福利费",
|
||||||
|
(
|
||||||
|
"福利费",
|
||||||
|
"福利",
|
||||||
|
"团建",
|
||||||
|
"慰问",
|
||||||
|
"节日福利",
|
||||||
|
"体检费",
|
||||||
|
"体检",
|
||||||
|
"员工关怀",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
EXPENSE_TYPE_LABEL_BY_CODE = {
|
||||||
|
code: label for code, label, _keywords in EXPENSE_TYPE_KEYWORD_GROUPS
|
||||||
|
}
|
||||||
|
EXPENSE_TYPE_LABEL_BY_CODE.setdefault("entertainment", "业务招待费")
|
||||||
|
|
||||||
|
|
||||||
|
def build_expense_type_keyword_map() -> dict[str, str]:
|
||||||
|
mapping: dict[str, str] = {}
|
||||||
|
for code, _label, keywords in EXPENSE_TYPE_KEYWORD_GROUPS:
|
||||||
|
for keyword in keywords:
|
||||||
|
mapping.setdefault(keyword, code)
|
||||||
|
return mapping
|
||||||
|
|
||||||
|
|
||||||
|
def iter_expense_keywords(*codes: str) -> Iterable[str]:
|
||||||
|
allowed_codes = {str(code or "").strip() for code in codes if str(code or "").strip()}
|
||||||
|
for code, _label, keywords in EXPENSE_TYPE_KEYWORD_GROUPS:
|
||||||
|
if allowed_codes and code not in allowed_codes:
|
||||||
|
continue
|
||||||
|
yield from keywords
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_expense_type_code_from_text(value: str) -> str | None:
|
||||||
|
compact = str(value or "").replace(" ", "")
|
||||||
|
if not compact:
|
||||||
|
return None
|
||||||
|
for code, _label, keywords in EXPENSE_TYPE_KEYWORD_GROUPS:
|
||||||
|
if any(keyword in compact for keyword in keywords):
|
||||||
|
return code
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_expense_type_label_from_text(value: str) -> tuple[str, str] | None:
|
||||||
|
code = resolve_expense_type_code_from_text(value)
|
||||||
|
if not code:
|
||||||
|
return None
|
||||||
|
return code, EXPENSE_TYPE_LABEL_BY_CODE.get(code, str(value or "").strip())
|
||||||
@@ -63,6 +63,7 @@ class KnowledgeIndexTaskManager:
|
|||||||
heartbeat_stop = threading.Event()
|
heartbeat_stop = threading.Event()
|
||||||
heartbeat_thread: threading.Thread | None = None
|
heartbeat_thread: threading.Thread | None = None
|
||||||
tool_call_id = ""
|
tool_call_id = ""
|
||||||
|
knowledge_ingest: dict[str, Any] | None = None
|
||||||
tool_request_json = {
|
tool_request_json = {
|
||||||
"agent": AgentName.HERMES.value,
|
"agent": AgentName.HERMES.value,
|
||||||
"folder": folder,
|
"folder": folder,
|
||||||
@@ -74,6 +75,10 @@ class KnowledgeIndexTaskManager:
|
|||||||
run_service = AgentRunService(db)
|
run_service = AgentRunService(db)
|
||||||
knowledge_service = KnowledgeService(db=db)
|
knowledge_service = KnowledgeService(db=db)
|
||||||
rag_service = KnowledgeRagService(db=db)
|
rag_service = KnowledgeRagService(db=db)
|
||||||
|
knowledge_ingest = _build_initial_knowledge_ingest_state(
|
||||||
|
knowledge_service,
|
||||||
|
document_ids=document_ids,
|
||||||
|
)
|
||||||
|
|
||||||
run_service.merge_route_json(
|
run_service.merge_route_json(
|
||||||
agent_run_id,
|
agent_run_id,
|
||||||
@@ -93,7 +98,18 @@ class KnowledgeIndexTaskManager:
|
|||||||
"skipped_documents": 0,
|
"skipped_documents": 0,
|
||||||
"percent": 10 if document_ids else 100,
|
"percent": 10 if document_ids else 100,
|
||||||
},
|
},
|
||||||
|
"knowledge_ingest": knowledge_ingest,
|
||||||
},
|
},
|
||||||
|
result_summary=_build_ingest_running_summary(
|
||||||
|
knowledge_ingest,
|
||||||
|
{
|
||||||
|
"total_documents": len(document_ids),
|
||||||
|
"completed_documents": 0,
|
||||||
|
"failed_documents": 0,
|
||||||
|
"skipped_documents": 0,
|
||||||
|
"percent": 10 if document_ids else 100,
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
tool_call = run_service.record_tool_call(
|
tool_call = run_service.record_tool_call(
|
||||||
run_id=agent_run_id,
|
run_id=agent_run_id,
|
||||||
@@ -134,44 +150,159 @@ class KnowledgeIndexTaskManager:
|
|||||||
)
|
)
|
||||||
heartbeat_thread.start()
|
heartbeat_thread.start()
|
||||||
|
|
||||||
response = rag_service.index_documents(document_ids=document_ids, force=force)
|
responses: list[dict[str, Any]] = []
|
||||||
succeeded_document_ids = [
|
succeeded_document_ids: list[str] = []
|
||||||
str(item).strip()
|
failed_documents: list[dict[str, str]] = []
|
||||||
for item in list(response.get("succeeded_document_ids") or [])
|
total_documents = len(document_ids)
|
||||||
if str(item).strip()
|
|
||||||
]
|
for index, document_id in enumerate(document_ids, start=1):
|
||||||
failed_documents = [
|
_patch_ingest_document(
|
||||||
item
|
knowledge_ingest,
|
||||||
for item in list(response.get("failed_documents") or [])
|
document_id,
|
||||||
if isinstance(item, dict)
|
{
|
||||||
]
|
"status": "running",
|
||||||
|
"phase": "indexing",
|
||||||
|
"started_at": datetime.now(UTC).isoformat(),
|
||||||
|
},
|
||||||
|
event=f"开始处理第 {index}/{total_documents} 个文件,正在写入 LightRAG。",
|
||||||
|
)
|
||||||
|
knowledge_ingest["current_document_id"] = document_id
|
||||||
|
_sync_ingest_route_json(
|
||||||
|
run_service,
|
||||||
|
agent_run_id,
|
||||||
|
knowledge_ingest,
|
||||||
|
progress=_build_ingest_progress(knowledge_ingest, total_documents),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = rag_service.index_documents(document_ids=[document_id], force=force)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"Knowledge document index failed run_id=%s doc_id=%s",
|
||||||
|
agent_run_id,
|
||||||
|
document_id,
|
||||||
|
)
|
||||||
|
failed_documents.append(
|
||||||
|
{
|
||||||
|
"document_id": document_id,
|
||||||
|
"status": "exception",
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
_patch_ingest_document(
|
||||||
|
knowledge_ingest,
|
||||||
|
document_id,
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"phase": "failed",
|
||||||
|
"finished_at": datetime.now(UTC).isoformat(),
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
event=f"归集失败:{exc}",
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
knowledge_service.set_document_ingest_statuses(
|
||||||
|
[document_id],
|
||||||
|
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||||
|
agent_run_id=agent_run_id,
|
||||||
|
)
|
||||||
|
_refresh_ingest_graph(knowledge_ingest)
|
||||||
|
_sync_ingest_route_json(
|
||||||
|
run_service,
|
||||||
|
agent_run_id,
|
||||||
|
knowledge_ingest,
|
||||||
|
progress=_build_ingest_progress(knowledge_ingest, total_documents),
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
responses.append(response)
|
||||||
|
response_failed_documents = _extract_failed_documents(response, document_id)
|
||||||
|
document_summary = _extract_document_summary(response, document_id)
|
||||||
|
if response_failed_documents:
|
||||||
|
failed_documents.extend(response_failed_documents)
|
||||||
|
error_text = (
|
||||||
|
response_failed_documents[0].get("error") or "LightRAG 未返回可查询状态"
|
||||||
|
)
|
||||||
|
_patch_ingest_document(
|
||||||
|
knowledge_ingest,
|
||||||
|
document_id,
|
||||||
|
{
|
||||||
|
**document_summary,
|
||||||
|
"status": "failed",
|
||||||
|
"phase": "failed",
|
||||||
|
"finished_at": datetime.now(UTC).isoformat(),
|
||||||
|
"error": error_text,
|
||||||
|
"track_id": str(response.get("track_id") or "").strip(),
|
||||||
|
},
|
||||||
|
event=f"LightRAG 索引失败:{error_text}",
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
knowledge_service.set_document_ingest_statuses(
|
||||||
|
[document_id],
|
||||||
|
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||||
|
agent_run_id=agent_run_id,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
succeeded_document_ids.append(document_id)
|
||||||
|
chunk_count = int(document_summary.get("chunk_count") or 0)
|
||||||
|
entity_count = int(document_summary.get("entity_count") or 0)
|
||||||
|
relation_count = int(document_summary.get("relation_count") or 0)
|
||||||
|
_patch_ingest_document(
|
||||||
|
knowledge_ingest,
|
||||||
|
document_id,
|
||||||
|
{
|
||||||
|
**document_summary,
|
||||||
|
"status": "succeeded",
|
||||||
|
"phase": "indexed",
|
||||||
|
"finished_at": datetime.now(UTC).isoformat(),
|
||||||
|
"track_id": str(response.get("track_id") or "").strip(),
|
||||||
|
},
|
||||||
|
event=(
|
||||||
|
"LightRAG 索引完成:"
|
||||||
|
f"{chunk_count} 个 chunk,{entity_count} 个实体,"
|
||||||
|
f"{relation_count} 条关系。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
knowledge_service.set_document_ingest_statuses(
|
||||||
|
[document_id],
|
||||||
|
KNOWLEDGE_INGEST_STATUS_INGESTED,
|
||||||
|
agent_run_id=agent_run_id,
|
||||||
|
)
|
||||||
|
_refresh_ingest_graph(knowledge_ingest)
|
||||||
|
_sync_ingest_route_json(
|
||||||
|
run_service,
|
||||||
|
agent_run_id,
|
||||||
|
knowledge_ingest,
|
||||||
|
progress=_build_ingest_progress(knowledge_ingest, total_documents),
|
||||||
|
)
|
||||||
|
|
||||||
failed_document_ids = [
|
failed_document_ids = [
|
||||||
str(item.get("document_id") or "").strip()
|
str(item.get("document_id") or "").strip()
|
||||||
for item in failed_documents
|
for item in failed_documents
|
||||||
if str(item.get("document_id") or "").strip()
|
if str(item.get("document_id") or "").strip()
|
||||||
]
|
]
|
||||||
|
|
||||||
if succeeded_document_ids:
|
|
||||||
knowledge_service.set_document_ingest_statuses(
|
|
||||||
succeeded_document_ids,
|
|
||||||
KNOWLEDGE_INGEST_STATUS_INGESTED,
|
|
||||||
agent_run_id=agent_run_id,
|
|
||||||
)
|
|
||||||
if failed_document_ids:
|
|
||||||
knowledge_service.set_document_ingest_statuses(
|
|
||||||
failed_document_ids,
|
|
||||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
|
||||||
agent_run_id=agent_run_id,
|
|
||||||
)
|
|
||||||
|
|
||||||
duration_ms = int((perf_counter() - started) * 1000)
|
duration_ms = int((perf_counter() - started) * 1000)
|
||||||
tool_status = "succeeded" if not failed_document_ids else "failed"
|
tool_status = "succeeded" if not failed_document_ids else "failed"
|
||||||
|
latest_track_id = _resolve_latest_track_id(responses)
|
||||||
|
knowledge_ingest["current_document_id"] = ""
|
||||||
|
knowledge_ingest["status"] = tool_status
|
||||||
|
knowledge_ingest["phase"] = "completed"
|
||||||
|
knowledge_ingest["finished_at"] = datetime.now(UTC).isoformat()
|
||||||
|
knowledge_ingest["graph"] = _build_ingest_graph(knowledge_ingest)
|
||||||
heartbeat_stop.set()
|
heartbeat_stop.set()
|
||||||
if heartbeat_thread is not None:
|
if heartbeat_thread is not None:
|
||||||
heartbeat_thread.join(timeout=1)
|
heartbeat_thread.join(timeout=1)
|
||||||
run_service.update_tool_call(
|
run_service.update_tool_call(
|
||||||
tool_call_id,
|
tool_call_id,
|
||||||
response_json=response,
|
response_json={
|
||||||
|
"track_id": latest_track_id,
|
||||||
|
"requested_document_ids": document_ids,
|
||||||
|
"succeeded_document_ids": succeeded_document_ids,
|
||||||
|
"failed_documents": failed_documents,
|
||||||
|
"documents": knowledge_ingest.get("documents", []),
|
||||||
|
"responses": responses,
|
||||||
|
},
|
||||||
status=tool_status,
|
status=tool_status,
|
||||||
duration_ms=duration_ms,
|
duration_ms=duration_ms,
|
||||||
error_message=None if tool_status == "succeeded" else "部分文档索引失败。",
|
error_message=None if tool_status == "succeeded" else "部分文档索引失败。",
|
||||||
@@ -183,14 +314,17 @@ class KnowledgeIndexTaskManager:
|
|||||||
summary = (
|
summary = (
|
||||||
f"LightRAG 已完成 {completed_documents}/{total_documents} 个知识文档索引。"
|
f"LightRAG 已完成 {completed_documents}/{total_documents} 个知识文档索引。"
|
||||||
if failed_count == 0
|
if failed_count == 0
|
||||||
else f"LightRAG 已完成 {completed_documents}/{total_documents} 个知识文档索引,失败 {failed_count} 个。"
|
else (
|
||||||
|
f"LightRAG 已完成 {completed_documents}/{total_documents} 个知识文档索引,"
|
||||||
|
f"失败 {failed_count} 个。"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
run_service.merge_route_json(
|
run_service.merge_route_json(
|
||||||
agent_run_id,
|
agent_run_id,
|
||||||
{
|
{
|
||||||
"job_type": "knowledge_index_sync",
|
"job_type": "knowledge_index_sync",
|
||||||
"phase": "completed",
|
"phase": "completed",
|
||||||
"track_id": str(response.get("track_id") or "").strip(),
|
"track_id": latest_track_id,
|
||||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||||
"progress": {
|
"progress": {
|
||||||
"total_documents": total_documents,
|
"total_documents": total_documents,
|
||||||
@@ -199,6 +333,7 @@ class KnowledgeIndexTaskManager:
|
|||||||
"skipped_documents": 0,
|
"skipped_documents": 0,
|
||||||
"percent": 100,
|
"percent": 100,
|
||||||
},
|
},
|
||||||
|
"knowledge_ingest": knowledge_ingest,
|
||||||
},
|
},
|
||||||
status=(
|
status=(
|
||||||
AgentRunStatus.SUCCEEDED.value
|
AgentRunStatus.SUCCEEDED.value
|
||||||
@@ -234,24 +369,50 @@ class KnowledgeIndexTaskManager:
|
|||||||
error_message=str(exc),
|
error_message=str(exc),
|
||||||
)
|
)
|
||||||
KnowledgeService(db=db).set_document_ingest_statuses(
|
KnowledgeService(db=db).set_document_ingest_statuses(
|
||||||
document_ids,
|
_resolve_failed_ingest_document_ids(knowledge_ingest, document_ids),
|
||||||
KNOWLEDGE_INGEST_STATUS_FAILED,
|
KNOWLEDGE_INGEST_STATUS_FAILED,
|
||||||
agent_run_id=agent_run_id,
|
agent_run_id=agent_run_id,
|
||||||
)
|
)
|
||||||
|
if knowledge_ingest is not None:
|
||||||
|
for document_id in document_ids:
|
||||||
|
document = _find_ingest_document(knowledge_ingest, document_id)
|
||||||
|
if document is None or document.get("status") in {"succeeded", "failed"}:
|
||||||
|
continue
|
||||||
|
_patch_ingest_document(
|
||||||
|
knowledge_ingest,
|
||||||
|
document_id,
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"phase": "failed",
|
||||||
|
"finished_at": datetime.now(UTC).isoformat(),
|
||||||
|
"error": str(exc),
|
||||||
|
},
|
||||||
|
event=f"归集任务中断:{exc}",
|
||||||
|
level="error",
|
||||||
|
)
|
||||||
|
knowledge_ingest["status"] = "failed"
|
||||||
|
knowledge_ingest["phase"] = "failed"
|
||||||
|
knowledge_ingest["current_document_id"] = ""
|
||||||
|
knowledge_ingest["finished_at"] = datetime.now(UTC).isoformat()
|
||||||
|
knowledge_ingest["graph"] = _build_ingest_graph(knowledge_ingest)
|
||||||
|
|
||||||
|
route_payload: dict[str, Any] = {
|
||||||
|
"job_type": "knowledge_index_sync",
|
||||||
|
"phase": "failed",
|
||||||
|
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||||
|
"progress": {
|
||||||
|
"total_documents": len(document_ids),
|
||||||
|
"completed_documents": 0,
|
||||||
|
"failed_documents": len(document_ids),
|
||||||
|
"skipped_documents": 0,
|
||||||
|
"percent": 100,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if knowledge_ingest is not None:
|
||||||
|
route_payload["knowledge_ingest"] = knowledge_ingest
|
||||||
AgentRunService(db).merge_route_json(
|
AgentRunService(db).merge_route_json(
|
||||||
agent_run_id,
|
agent_run_id,
|
||||||
{
|
route_payload,
|
||||||
"job_type": "knowledge_index_sync",
|
|
||||||
"phase": "failed",
|
|
||||||
"heartbeat_at": datetime.now(UTC).isoformat(),
|
|
||||||
"progress": {
|
|
||||||
"total_documents": len(document_ids),
|
|
||||||
"completed_documents": 0,
|
|
||||||
"failed_documents": len(document_ids),
|
|
||||||
"skipped_documents": 0,
|
|
||||||
"percent": 100,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
status=AgentRunStatus.FAILED.value,
|
status=AgentRunStatus.FAILED.value,
|
||||||
result_summary=str(exc),
|
result_summary=str(exc),
|
||||||
error_message=str(exc),
|
error_message=str(exc),
|
||||||
@@ -267,4 +428,312 @@ class KnowledgeIndexTaskManager:
|
|||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_initial_knowledge_ingest_state(
|
||||||
|
knowledge_service: KnowledgeService,
|
||||||
|
*,
|
||||||
|
document_ids: list[str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
now = datetime.now(UTC).isoformat()
|
||||||
|
documents = [
|
||||||
|
_build_initial_knowledge_ingest_document(knowledge_service, document_id, now=now)
|
||||||
|
for document_id in document_ids
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"schema_version": 1,
|
||||||
|
"status": "running",
|
||||||
|
"phase": "queued",
|
||||||
|
"started_at": now,
|
||||||
|
"finished_at": None,
|
||||||
|
"current_document_id": documents[0]["document_id"] if documents else "",
|
||||||
|
"documents": documents,
|
||||||
|
"graph": _build_ingest_graph({"documents": documents}),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _build_initial_knowledge_ingest_document(
|
||||||
|
knowledge_service: KnowledgeService,
|
||||||
|
document_id: str,
|
||||||
|
*,
|
||||||
|
now: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
entry = knowledge_service.get_document_entry(document_id)
|
||||||
|
except Exception:
|
||||||
|
entry = {}
|
||||||
|
return {
|
||||||
|
"document_id": document_id,
|
||||||
|
"name": str(entry.get("original_name") or document_id).strip(),
|
||||||
|
"folder": str(entry.get("folder") or "").strip(),
|
||||||
|
"extension": str(entry.get("extension") or "").strip(),
|
||||||
|
"mime_type": str(entry.get("mime_type") or "").strip(),
|
||||||
|
"status": "queued",
|
||||||
|
"phase": "queued",
|
||||||
|
"started_at": None,
|
||||||
|
"finished_at": None,
|
||||||
|
"text_chars": 0,
|
||||||
|
"indexed_text_chars": 0,
|
||||||
|
"section_count": 0,
|
||||||
|
"sections": [],
|
||||||
|
"chunk_count": 0,
|
||||||
|
"chunk_ids": [],
|
||||||
|
"chunks": [],
|
||||||
|
"entity_count": 0,
|
||||||
|
"relation_count": 0,
|
||||||
|
"entities": [],
|
||||||
|
"relations": [],
|
||||||
|
"events": [
|
||||||
|
{
|
||||||
|
"at": now,
|
||||||
|
"level": "info",
|
||||||
|
"message": "已进入知识归集队列,等待 LightRAG 处理。",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_ingest_document(
|
||||||
|
knowledge_ingest: dict[str, Any],
|
||||||
|
document_id: str,
|
||||||
|
updates: dict[str, Any],
|
||||||
|
*,
|
||||||
|
event: str = "",
|
||||||
|
level: str = "info",
|
||||||
|
) -> None:
|
||||||
|
document = _find_ingest_document(knowledge_ingest, document_id)
|
||||||
|
if document is None:
|
||||||
|
return
|
||||||
|
document.update(updates)
|
||||||
|
if event:
|
||||||
|
_append_ingest_event(document, event, level=level)
|
||||||
|
|
||||||
|
|
||||||
|
def _append_ingest_event(document: dict[str, Any], message: str, *, level: str) -> None:
|
||||||
|
events = document.get("events")
|
||||||
|
if not isinstance(events, list):
|
||||||
|
events = []
|
||||||
|
events.append(
|
||||||
|
{
|
||||||
|
"at": datetime.now(UTC).isoformat(),
|
||||||
|
"level": level,
|
||||||
|
"message": message,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
document["events"] = events[-30:]
|
||||||
|
|
||||||
|
|
||||||
|
def _find_ingest_document(
|
||||||
|
knowledge_ingest: dict[str, Any],
|
||||||
|
document_id: str,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
for document in list(knowledge_ingest.get("documents") or []):
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
continue
|
||||||
|
if str(document.get("document_id") or "").strip() == document_id:
|
||||||
|
return document
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_ingest_route_json(
|
||||||
|
run_service: AgentRunService,
|
||||||
|
agent_run_id: str,
|
||||||
|
knowledge_ingest: dict[str, Any],
|
||||||
|
*,
|
||||||
|
progress: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
run_service.merge_route_json(
|
||||||
|
agent_run_id,
|
||||||
|
{
|
||||||
|
"job_type": "knowledge_index_sync",
|
||||||
|
"phase": "indexing",
|
||||||
|
"heartbeat_at": datetime.now(UTC).isoformat(),
|
||||||
|
"progress": progress,
|
||||||
|
"knowledge_ingest": knowledge_ingest,
|
||||||
|
},
|
||||||
|
result_summary=_build_ingest_running_summary(knowledge_ingest, progress),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ingest_running_summary(
|
||||||
|
knowledge_ingest: dict[str, Any],
|
||||||
|
progress: dict[str, int],
|
||||||
|
) -> str:
|
||||||
|
total_documents = int(progress.get("total_documents") or 0)
|
||||||
|
completed_documents = int(progress.get("completed_documents") or 0)
|
||||||
|
failed_documents = int(progress.get("failed_documents") or 0)
|
||||||
|
current_document_id = str(knowledge_ingest.get("current_document_id") or "").strip()
|
||||||
|
current_document = (
|
||||||
|
_find_ingest_document(knowledge_ingest, current_document_id)
|
||||||
|
if current_document_id
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if current_document is not None:
|
||||||
|
name = str(current_document.get("name") or current_document_id).strip()
|
||||||
|
current_index = _resolve_ingest_document_index(knowledge_ingest, current_document_id)
|
||||||
|
return (
|
||||||
|
f"知识归纳正在处理 {current_index}/{total_documents}:{name}。"
|
||||||
|
f"已完成 {completed_documents} 个,失败 {failed_documents} 个。"
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"知识归纳正在运行,已完成 {completed_documents}/{total_documents} 个文档,"
|
||||||
|
f"失败 {failed_documents} 个。"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_ingest_document_index(
|
||||||
|
knowledge_ingest: dict[str, Any],
|
||||||
|
document_id: str,
|
||||||
|
) -> int:
|
||||||
|
documents = [
|
||||||
|
item for item in list(knowledge_ingest.get("documents") or []) if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
for index, document in enumerate(documents, start=1):
|
||||||
|
if str(document.get("document_id") or "").strip() == document_id:
|
||||||
|
return index
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ingest_progress(
|
||||||
|
knowledge_ingest: dict[str, Any],
|
||||||
|
total_documents: int,
|
||||||
|
) -> dict[str, int]:
|
||||||
|
documents = [
|
||||||
|
item for item in list(knowledge_ingest.get("documents") or []) if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
completed_documents = sum(1 for item in documents if item.get("status") == "succeeded")
|
||||||
|
failed_documents = sum(1 for item in documents if item.get("status") == "failed")
|
||||||
|
skipped_documents = sum(1 for item in documents if item.get("status") == "skipped")
|
||||||
|
done_documents = completed_documents + failed_documents + skipped_documents
|
||||||
|
if total_documents <= 0:
|
||||||
|
percent = 100
|
||||||
|
else:
|
||||||
|
percent = min(95, max(10, 10 + int(done_documents * 85 / total_documents)))
|
||||||
|
return {
|
||||||
|
"total_documents": total_documents,
|
||||||
|
"completed_documents": completed_documents,
|
||||||
|
"failed_documents": failed_documents,
|
||||||
|
"skipped_documents": skipped_documents,
|
||||||
|
"percent": percent,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_document_summary(response: dict[str, Any], document_id: str) -> dict[str, Any]:
|
||||||
|
for item in list(response.get("document_summaries") or []):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
if str(item.get("document_id") or "").strip() == document_id:
|
||||||
|
return dict(item)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_failed_documents(
|
||||||
|
response: dict[str, Any],
|
||||||
|
document_id: str,
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
failed_documents: list[dict[str, str]] = []
|
||||||
|
for item in list(response.get("failed_documents") or []):
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
item_document_id = str(item.get("document_id") or "").strip()
|
||||||
|
if item_document_id and item_document_id != document_id:
|
||||||
|
continue
|
||||||
|
failed_documents.append(
|
||||||
|
{
|
||||||
|
"document_id": item_document_id or document_id,
|
||||||
|
"status": str(item.get("status") or "failed").strip(),
|
||||||
|
"error": str(item.get("error") or "LightRAG 索引失败").strip(),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return failed_documents
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_failed_ingest_document_ids(
|
||||||
|
knowledge_ingest: dict[str, Any] | None,
|
||||||
|
document_ids: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
if knowledge_ingest is None:
|
||||||
|
return document_ids
|
||||||
|
failed_document_ids: list[str] = []
|
||||||
|
seen_document_ids: set[str] = set()
|
||||||
|
for document in list(knowledge_ingest.get("documents") or []):
|
||||||
|
if not isinstance(document, dict):
|
||||||
|
continue
|
||||||
|
document_id = str(document.get("document_id") or "").strip()
|
||||||
|
if not document_id:
|
||||||
|
continue
|
||||||
|
seen_document_ids.add(document_id)
|
||||||
|
if document.get("status") != "succeeded":
|
||||||
|
failed_document_ids.append(document_id)
|
||||||
|
failed_document_ids.extend(
|
||||||
|
document_id for document_id in document_ids if document_id not in seen_document_ids
|
||||||
|
)
|
||||||
|
return failed_document_ids
|
||||||
|
|
||||||
|
|
||||||
|
def _refresh_ingest_graph(knowledge_ingest: dict[str, Any]) -> None:
|
||||||
|
knowledge_ingest["graph"] = _build_ingest_graph(knowledge_ingest)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ingest_graph(knowledge_ingest: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
documents = [
|
||||||
|
item for item in list(knowledge_ingest.get("documents") or []) if isinstance(item, dict)
|
||||||
|
]
|
||||||
|
entities = _dedupe_text_items(
|
||||||
|
entity for document in documents for entity in list(document.get("entities") or [])
|
||||||
|
)
|
||||||
|
relations = _dedupe_relations(
|
||||||
|
relation for document in documents for relation in list(document.get("relations") or [])
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"chunk_count": sum(_to_int(document.get("chunk_count")) for document in documents),
|
||||||
|
"entity_count": sum(_to_int(document.get("entity_count")) for document in documents),
|
||||||
|
"relation_count": sum(_to_int(document.get("relation_count")) for document in documents),
|
||||||
|
"entities": entities[:60],
|
||||||
|
"relations": relations[:60],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_text_items(items: Any) -> list[str]:
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in items:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
if not text or text in seen:
|
||||||
|
continue
|
||||||
|
seen.add(text)
|
||||||
|
deduped.append(text)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_relations(items: Any) -> list[dict[str, str]]:
|
||||||
|
deduped: list[dict[str, str]] = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
for item in items:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
source = str(item.get("source") or "").strip()
|
||||||
|
target = str(item.get("target") or "").strip()
|
||||||
|
relation_type = str(item.get("type") or "关联").strip()
|
||||||
|
key = (source, target, relation_type)
|
||||||
|
if not source or not target or key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
deduped.append({"source": source, "target": target, "type": relation_type})
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_latest_track_id(responses: list[dict[str, Any]]) -> str:
|
||||||
|
for response in reversed(responses):
|
||||||
|
track_id = str(response.get("track_id") or "").strip()
|
||||||
|
if track_id:
|
||||||
|
return track_id
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value: Any) -> int:
|
||||||
|
try:
|
||||||
|
return int(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
knowledge_index_task_manager = KnowledgeIndexTaskManager()
|
knowledge_index_task_manager = KnowledgeIndexTaskManager()
|
||||||
|
|||||||
224
server/src/app/services/knowledge_ingest_log.py
Normal file
224
server/src/app/services/knowledge_ingest_log.py
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
MAX_INGEST_LOG_CHUNKS = 24
|
||||||
|
MAX_INGEST_LOG_ENTITIES = 24
|
||||||
|
MAX_INGEST_LOG_RELATIONS = 24
|
||||||
|
MAX_INGEST_LOG_SECTIONS = 12
|
||||||
|
MAX_INGEST_LOG_TEXT_PREVIEW = 180
|
||||||
|
|
||||||
|
INGEST_SECTION_HEADING_PATTERN = re.compile(
|
||||||
|
r"^(?:#{1,4}\s+.+|第[一二三四五六七八九十百零0-9]+[章节条]\s*.*)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_ingest_document_summary(
|
||||||
|
*,
|
||||||
|
document_id: str,
|
||||||
|
entry: dict[str, Any],
|
||||||
|
raw_text: str,
|
||||||
|
indexed_text: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
raw_text_value = str(raw_text or "")
|
||||||
|
indexed_text_value = str(indexed_text or "")
|
||||||
|
sections = _extract_ingest_sections(indexed_text_value)
|
||||||
|
return {
|
||||||
|
"document_id": document_id,
|
||||||
|
"name": str(entry.get("original_name") or "").strip(),
|
||||||
|
"folder": str(entry.get("folder") or "").strip(),
|
||||||
|
"extension": str(entry.get("extension") or "").strip(),
|
||||||
|
"mime_type": str(entry.get("mime_type") or "").strip(),
|
||||||
|
"text_chars": len(raw_text_value),
|
||||||
|
"indexed_text_chars": len(indexed_text_value),
|
||||||
|
"section_count": len(sections),
|
||||||
|
"sections": sections,
|
||||||
|
"chunk_count": 0,
|
||||||
|
"chunk_ids": [],
|
||||||
|
"chunks": [],
|
||||||
|
"entity_count": 0,
|
||||||
|
"relation_count": 0,
|
||||||
|
"entities": [],
|
||||||
|
"relations": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_ingest_status_summary(
|
||||||
|
*,
|
||||||
|
status_payload: dict[str, Any],
|
||||||
|
graph_summary: dict[str, Any],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
chunk_ids = _normalize_chunk_ids(status_payload)
|
||||||
|
chunk_count = _resolve_chunk_count(status_payload, chunk_ids)
|
||||||
|
return {
|
||||||
|
"lightrag_status": str(status_payload.get("status") or "").strip(),
|
||||||
|
"query_ready": bool(status_payload.get("query_ready")),
|
||||||
|
"chunk_count": chunk_count,
|
||||||
|
"chunk_ids": chunk_ids[:MAX_INGEST_LOG_CHUNKS],
|
||||||
|
**graph_summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_document_graph_summary(
|
||||||
|
storage_root: Path,
|
||||||
|
*,
|
||||||
|
workspace: str,
|
||||||
|
document_id: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
workspace_dir = (
|
||||||
|
Path(storage_root) / "knowledge" / ".lightrag" / str(workspace).strip()
|
||||||
|
).resolve()
|
||||||
|
entities_payload = _load_json_file(workspace_dir / "kv_store_full_entities.json")
|
||||||
|
relations_payload = _load_json_file(workspace_dir / "kv_store_full_relations.json")
|
||||||
|
chunks_payload = _load_json_file(workspace_dir / "kv_store_text_chunks.json")
|
||||||
|
|
||||||
|
entities = _normalize_document_entities(entities_payload, document_id)
|
||||||
|
relations = _normalize_document_relations(relations_payload, document_id)
|
||||||
|
chunks = _normalize_document_chunks(chunks_payload, document_id)
|
||||||
|
return {
|
||||||
|
"entity_count": len(entities),
|
||||||
|
"relation_count": len(relations),
|
||||||
|
"entities": entities[:MAX_INGEST_LOG_ENTITIES],
|
||||||
|
"relations": relations[:MAX_INGEST_LOG_RELATIONS],
|
||||||
|
"chunks": chunks[:MAX_INGEST_LOG_CHUNKS],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_ingest_sections(text: str) -> list[dict[str, str]]:
|
||||||
|
sections: list[dict[str, str]] = []
|
||||||
|
lines = [line.strip() for line in str(text or "").splitlines()]
|
||||||
|
for index, line in enumerate(lines):
|
||||||
|
if len(sections) >= MAX_INGEST_LOG_SECTIONS:
|
||||||
|
break
|
||||||
|
if not line or len(line) > 90 or not INGEST_SECTION_HEADING_PATTERN.match(line):
|
||||||
|
continue
|
||||||
|
sections.append(
|
||||||
|
{
|
||||||
|
"title": line.lstrip("#").strip(),
|
||||||
|
"excerpt": _find_following_excerpt(lines[index + 1 :]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sections
|
||||||
|
|
||||||
|
|
||||||
|
def _find_following_excerpt(lines: list[str]) -> str:
|
||||||
|
collected: list[str] = []
|
||||||
|
for line in lines:
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
if INGEST_SECTION_HEADING_PATTERN.match(line):
|
||||||
|
break
|
||||||
|
collected.append(line)
|
||||||
|
if len(" ".join(collected)) >= MAX_INGEST_LOG_TEXT_PREVIEW:
|
||||||
|
break
|
||||||
|
return _truncate_text(" ".join(collected), max_length=MAX_INGEST_LOG_TEXT_PREVIEW)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_chunk_ids(status_payload: dict[str, Any]) -> list[str]:
|
||||||
|
chunks_list = status_payload.get("chunks_list")
|
||||||
|
if not isinstance(chunks_list, list):
|
||||||
|
return []
|
||||||
|
return [str(item).strip() for item in chunks_list if str(item or "").strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_chunk_count(status_payload: dict[str, Any], chunk_ids: list[str]) -> int:
|
||||||
|
try:
|
||||||
|
return int(status_payload.get("chunks_count") or len(chunk_ids))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return len(chunk_ids)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_json_file(path: Path) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (FileNotFoundError, json.JSONDecodeError, OSError):
|
||||||
|
return {}
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_document_entities(payload: dict[str, Any], document_id: str) -> list[str]:
|
||||||
|
document_payload = payload.get(document_id) if isinstance(payload, dict) else {}
|
||||||
|
entity_names = (
|
||||||
|
document_payload.get("entity_names") if isinstance(document_payload, dict) else []
|
||||||
|
)
|
||||||
|
if not isinstance(entity_names, list):
|
||||||
|
return []
|
||||||
|
return _dedupe_text_items(entity_names)
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_document_relations(
|
||||||
|
payload: dict[str, Any], document_id: str
|
||||||
|
) -> list[dict[str, str]]:
|
||||||
|
document_payload = payload.get(document_id) if isinstance(payload, dict) else {}
|
||||||
|
relation_pairs = (
|
||||||
|
document_payload.get("relation_pairs") if isinstance(document_payload, dict) else []
|
||||||
|
)
|
||||||
|
if not isinstance(relation_pairs, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
relations: list[dict[str, str]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for pair in relation_pairs:
|
||||||
|
if not isinstance(pair, (list, tuple)) or len(pair) < 2:
|
||||||
|
continue
|
||||||
|
source = str(pair[0] or "").strip()
|
||||||
|
target = str(pair[1] or "").strip()
|
||||||
|
if not source or not target or (source, target) in seen:
|
||||||
|
continue
|
||||||
|
seen.add((source, target))
|
||||||
|
relations.append({"source": source, "target": target, "type": "关联"})
|
||||||
|
return relations
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_document_chunks(payload: dict[str, Any], document_id: str) -> list[dict[str, Any]]:
|
||||||
|
chunks: list[dict[str, Any]] = []
|
||||||
|
for chunk_id, raw_chunk in payload.items():
|
||||||
|
if not isinstance(raw_chunk, dict):
|
||||||
|
continue
|
||||||
|
if str(raw_chunk.get("full_doc_id") or "").strip() != document_id:
|
||||||
|
continue
|
||||||
|
content = str(raw_chunk.get("content") or "").strip()
|
||||||
|
chunks.append(
|
||||||
|
{
|
||||||
|
"id": str(raw_chunk.get("_id") or chunk_id).strip(),
|
||||||
|
"order": _to_int(raw_chunk.get("chunk_order_index")),
|
||||||
|
"tokens": _to_int(raw_chunk.get("tokens")),
|
||||||
|
"summary": _build_chunk_summary(content),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sorted(chunks, key=lambda item: (item["order"], item["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def _build_chunk_summary(content: str) -> str:
|
||||||
|
lines = [line.strip() for line in str(content or "").splitlines() if line.strip()]
|
||||||
|
text = next((line for line in lines if len(line) >= 12), lines[0] if lines else "")
|
||||||
|
return _truncate_text(text, max_length=MAX_INGEST_LOG_TEXT_PREVIEW)
|
||||||
|
|
||||||
|
|
||||||
|
def _dedupe_text_items(items: list[Any]) -> list[str]:
|
||||||
|
deduped: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for item in items:
|
||||||
|
text = str(item or "").strip()
|
||||||
|
if not text or text in seen:
|
||||||
|
continue
|
||||||
|
seen.add(text)
|
||||||
|
deduped.append(text)
|
||||||
|
return deduped
|
||||||
|
|
||||||
|
|
||||||
|
def _to_int(value: Any) -> int:
|
||||||
|
try:
|
||||||
|
return int(value or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate_text(text: str, *, max_length: int) -> str:
|
||||||
|
normalized = " ".join(str(text or "").split()).strip()
|
||||||
|
if len(normalized) <= max_length:
|
||||||
|
return normalized
|
||||||
|
return f"{normalized[: max_length - 3].rstrip()}..."
|
||||||
@@ -12,24 +12,15 @@ from sqlalchemy.orm import Session
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.logging import get_logger
|
from app.core.logging import get_logger
|
||||||
from app.db.session import get_session_factory
|
from app.db.session import get_session_factory
|
||||||
|
from app.services.knowledge_ingest_log import (
|
||||||
|
build_document_graph_summary,
|
||||||
|
build_ingest_document_summary,
|
||||||
|
build_ingest_status_summary,
|
||||||
|
)
|
||||||
from app.services.knowledge_rag_runtime import (
|
from app.services.knowledge_rag_runtime import (
|
||||||
DEFAULT_EMBEDDING_TIMEOUT_SECONDS,
|
|
||||||
DEFAULT_LIGHTRAG_QUERY_MODE,
|
|
||||||
DEFAULT_LLM_TIMEOUT_SECONDS,
|
|
||||||
KnowledgeRagError,
|
KnowledgeRagError,
|
||||||
RuntimeModelConfig,
|
RuntimeModelConfig,
|
||||||
_LightRagRuntime,
|
_LightRagRuntime,
|
||||||
_build_ali_rerank_request,
|
|
||||||
_build_azure_deployment_base,
|
|
||||||
_build_headers,
|
|
||||||
_ensure_path,
|
|
||||||
_extract_chat_text,
|
|
||||||
_extract_embedding_vectors,
|
|
||||||
_extract_error_message,
|
|
||||||
_extract_rerank_results,
|
|
||||||
_normalize_endpoint,
|
|
||||||
_parse_json_body,
|
|
||||||
_send_json_request,
|
|
||||||
)
|
)
|
||||||
from app.services.settings import SettingsService
|
from app.services.settings import SettingsService
|
||||||
|
|
||||||
@@ -76,11 +67,9 @@ STRUCTURED_APPENDIX_LEADING_MARKERS = (
|
|||||||
"# 结构化表格补充",
|
"# 结构化表格补充",
|
||||||
)
|
)
|
||||||
STRUCTURED_APPENDIX_LEADING_WINDOW = 220
|
STRUCTURED_APPENDIX_LEADING_WINDOW = 220
|
||||||
|
|
||||||
|
|
||||||
_runtime_lock = threading.RLock()
|
_runtime_lock = threading.RLock()
|
||||||
_runtime_instance: _LightRagRuntime | None = None
|
_runtime_instances: dict[int, _LightRagRuntime] = {}
|
||||||
_runtime_signature: tuple[Any, ...] | None = None
|
_runtime_signatures: dict[int, tuple[Any, ...]] = {}
|
||||||
|
|
||||||
|
|
||||||
class KnowledgeRagService:
|
class KnowledgeRagService:
|
||||||
@@ -147,7 +136,11 @@ class KnowledgeRagService:
|
|||||||
"query": normalized_query,
|
"query": normalized_query,
|
||||||
"record_count": len(hits),
|
"record_count": len(hits),
|
||||||
"hits": hits,
|
"hits": hits,
|
||||||
"references": [str(item.get("code") or "").strip() for item in hits if str(item.get("code") or "").strip()],
|
"references": [
|
||||||
|
str(item.get("code") or "").strip()
|
||||||
|
for item in hits
|
||||||
|
if str(item.get("code") or "").strip()
|
||||||
|
],
|
||||||
"raw_references": references,
|
"raw_references": references,
|
||||||
"metadata": raw.get("metadata") if isinstance(raw, dict) else {},
|
"metadata": raw.get("metadata") if isinstance(raw, dict) else {},
|
||||||
"message": f"已从知识库中检索到 {len(hits)} 条相关内容。",
|
"message": f"已从知识库中检索到 {len(hits)} 条相关内容。",
|
||||||
@@ -172,6 +165,7 @@ class KnowledgeRagService:
|
|||||||
)
|
)
|
||||||
texts: list[str] = []
|
texts: list[str] = []
|
||||||
file_paths: list[str] = []
|
file_paths: list[str] = []
|
||||||
|
document_summaries: list[dict[str, Any]] = []
|
||||||
|
|
||||||
runtime = self._get_runtime()
|
runtime = self._get_runtime()
|
||||||
existing_statuses = runtime.get_document_statuses(normalized_ids)
|
existing_statuses = runtime.get_document_statuses(normalized_ids)
|
||||||
@@ -182,12 +176,29 @@ class KnowledgeRagService:
|
|||||||
try:
|
try:
|
||||||
runtime.delete_document(document_id)
|
runtime.delete_document(document_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning("Delete existing LightRAG document failed doc_id=%s: %s", document_id, exc)
|
logger.warning(
|
||||||
|
"Delete existing LightRAG document failed doc_id=%s: %s", document_id, exc
|
||||||
|
)
|
||||||
text = knowledge_service.extract_document_text(document_id)
|
text = knowledge_service.extract_document_text(document_id)
|
||||||
|
raw_text = text
|
||||||
if normalization_service is not None:
|
if normalization_service is not None:
|
||||||
text = normalization_service.build_enriched_text(text)
|
text = normalization_service.build_enriched_text(text)
|
||||||
texts.append(text)
|
texts.append(text)
|
||||||
file_paths.append(str((knowledge_service.library_root / entry["folder"] / entry["stored_name"]).resolve()))
|
file_paths.append(
|
||||||
|
str(
|
||||||
|
(
|
||||||
|
knowledge_service.library_root / entry["folder"] / entry["stored_name"]
|
||||||
|
).resolve()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
document_summaries.append(
|
||||||
|
build_ingest_document_summary(
|
||||||
|
document_id=document_id,
|
||||||
|
entry=entry,
|
||||||
|
raw_text=raw_text,
|
||||||
|
indexed_text=text,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
track_id = runtime.insert_documents(
|
track_id = runtime.insert_documents(
|
||||||
texts=texts,
|
texts=texts,
|
||||||
@@ -198,10 +209,32 @@ class KnowledgeRagService:
|
|||||||
statuses = runtime.get_document_statuses(normalized_ids)
|
statuses = runtime.get_document_statuses(normalized_ids)
|
||||||
succeeded_document_ids: list[str] = []
|
succeeded_document_ids: list[str] = []
|
||||||
failed_documents: list[dict[str, str]] = []
|
failed_documents: list[dict[str, str]] = []
|
||||||
|
summary_by_id = {
|
||||||
|
str(item.get("document_id") or "").strip(): item
|
||||||
|
for item in document_summaries
|
||||||
|
if str(item.get("document_id") or "").strip()
|
||||||
|
}
|
||||||
|
|
||||||
for document_id in normalized_ids:
|
for document_id in normalized_ids:
|
||||||
status_obj = statuses.get(document_id)
|
status_obj = statuses.get(document_id)
|
||||||
status_text = self._status_value(status_obj)
|
status_text = self._status_value(status_obj)
|
||||||
|
status_payload = self._serialize_status(status_obj)
|
||||||
|
workspace = (
|
||||||
|
os.environ.get("LIGHTRAG_WORKSPACE", DEFAULT_LIGHTRAG_WORKSPACE).strip()
|
||||||
|
or DEFAULT_LIGHTRAG_WORKSPACE
|
||||||
|
)
|
||||||
|
graph_summary = build_document_graph_summary(
|
||||||
|
self.storage_root,
|
||||||
|
workspace=workspace,
|
||||||
|
document_id=document_id,
|
||||||
|
)
|
||||||
|
if document_id in summary_by_id:
|
||||||
|
summary_by_id[document_id].update(
|
||||||
|
build_ingest_status_summary(
|
||||||
|
status_payload=status_payload,
|
||||||
|
graph_summary=graph_summary,
|
||||||
|
)
|
||||||
|
)
|
||||||
if self.is_query_ready_status(status_obj):
|
if self.is_query_ready_status(status_obj):
|
||||||
succeeded_document_ids.append(document_id)
|
succeeded_document_ids.append(document_id)
|
||||||
continue
|
continue
|
||||||
@@ -218,13 +251,18 @@ class KnowledgeRagService:
|
|||||||
"requested_document_ids": normalized_ids,
|
"requested_document_ids": normalized_ids,
|
||||||
"succeeded_document_ids": succeeded_document_ids,
|
"succeeded_document_ids": succeeded_document_ids,
|
||||||
"failed_documents": failed_documents,
|
"failed_documents": failed_documents,
|
||||||
|
"document_summaries": [
|
||||||
|
summary_by_id.get(document_id, {}) for document_id in normalized_ids
|
||||||
|
],
|
||||||
"status_snapshot": {
|
"status_snapshot": {
|
||||||
document_id: self._serialize_status(status_obj)
|
document_id: self._serialize_status(status_obj)
|
||||||
for document_id, status_obj in statuses.items()
|
for document_id, status_obj in statuses.items()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
def get_document_status_map(self, document_ids: list[str] | None = None) -> dict[str, dict[str, Any]]:
|
def get_document_status_map(
|
||||||
|
self, document_ids: list[str] | None = None
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
target_ids = [str(item).strip() for item in document_ids or [] if str(item).strip()]
|
target_ids = [str(item).strip() for item in document_ids or [] if str(item).strip()]
|
||||||
if not target_ids:
|
if not target_ids:
|
||||||
return {}
|
return {}
|
||||||
@@ -248,28 +286,32 @@ class KnowledgeRagService:
|
|||||||
logger.warning("Delete LightRAG document ignored doc_id=%s: %s", normalized_id, exc)
|
logger.warning("Delete LightRAG document ignored doc_id=%s: %s", normalized_id, exc)
|
||||||
|
|
||||||
def _get_runtime(self) -> _LightRagRuntime:
|
def _get_runtime(self) -> _LightRagRuntime:
|
||||||
global _runtime_instance, _runtime_signature
|
|
||||||
|
|
||||||
signature, runtime_kwargs = self._build_runtime_signature()
|
signature, runtime_kwargs = self._build_runtime_signature()
|
||||||
|
thread_id = threading.get_ident()
|
||||||
with _runtime_lock:
|
with _runtime_lock:
|
||||||
if _runtime_instance is not None and _runtime_signature == signature:
|
runtime = _runtime_instances.get(thread_id)
|
||||||
return _runtime_instance
|
if runtime is not None and _runtime_signatures.get(thread_id) == signature:
|
||||||
|
return runtime
|
||||||
|
|
||||||
if _runtime_instance is not None:
|
if runtime is not None:
|
||||||
try:
|
try:
|
||||||
_runtime_instance.finalize()
|
runtime.finalize()
|
||||||
except Exception as exc: # pragma: no cover - best effort cleanup
|
except Exception as exc: # pragma: no cover - best effort cleanup
|
||||||
logger.warning("Finalize previous LightRAG runtime failed: %s", exc)
|
logger.warning("Finalize previous LightRAG runtime failed: %s", exc)
|
||||||
|
|
||||||
_runtime_instance = _LightRagRuntime(**runtime_kwargs)
|
runtime = _LightRagRuntime(**runtime_kwargs)
|
||||||
_runtime_signature = signature
|
_runtime_instances[thread_id] = runtime
|
||||||
return _runtime_instance
|
_runtime_signatures[thread_id] = signature
|
||||||
|
return runtime
|
||||||
|
|
||||||
def _build_runtime_signature(self) -> tuple[tuple[Any, ...], dict[str, Any]]:
|
def _build_runtime_signature(self) -> tuple[tuple[Any, ...], dict[str, Any]]:
|
||||||
configs = self._load_runtime_configs()
|
configs = self._load_runtime_configs()
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
working_dir = (self.storage_root / "knowledge" / ".lightrag").resolve()
|
working_dir = (self.storage_root / "knowledge" / ".lightrag").resolve()
|
||||||
workspace = os.environ.get("LIGHTRAG_WORKSPACE", DEFAULT_LIGHTRAG_WORKSPACE).strip() or DEFAULT_LIGHTRAG_WORKSPACE
|
workspace = (
|
||||||
|
os.environ.get("LIGHTRAG_WORKSPACE", DEFAULT_LIGHTRAG_WORKSPACE).strip()
|
||||||
|
or DEFAULT_LIGHTRAG_WORKSPACE
|
||||||
|
)
|
||||||
qdrant_url = os.environ.get("QDRANT_URL", "").strip() or _resolve_default_qdrant_url()
|
qdrant_url = os.environ.get("QDRANT_URL", "").strip() or _resolve_default_qdrant_url()
|
||||||
qdrant_api_key = os.environ.get("QDRANT_API_KEY", "").strip()
|
qdrant_api_key = os.environ.get("QDRANT_API_KEY", "").strip()
|
||||||
|
|
||||||
@@ -318,7 +360,9 @@ class KnowledgeRagService:
|
|||||||
try:
|
try:
|
||||||
settings_service = SettingsService(session)
|
settings_service = SettingsService(session)
|
||||||
main = self._normalize_runtime_model(settings_service.get_runtime_model_config("main"))
|
main = self._normalize_runtime_model(settings_service.get_runtime_model_config("main"))
|
||||||
embedding = self._normalize_runtime_model(settings_service.get_runtime_model_config("embedding"))
|
embedding = self._normalize_runtime_model(
|
||||||
|
settings_service.get_runtime_model_config("embedding")
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
backup_raw = settings_service.get_runtime_model_config("backup")
|
backup_raw = settings_service.get_runtime_model_config("backup")
|
||||||
backup = self._normalize_runtime_model(backup_raw)
|
backup = self._normalize_runtime_model(backup_raw)
|
||||||
@@ -405,7 +449,9 @@ class KnowledgeRagService:
|
|||||||
|
|
||||||
document_id, document_name = _parse_document_identity(file_path)
|
document_id, document_name = _parse_document_identity(file_path)
|
||||||
normalized_chunk_id = chunk_id or f"path-{rank}"
|
normalized_chunk_id = chunk_id or f"path-{rank}"
|
||||||
normalized_content = _truncate_text(content, max_length=MAX_KNOWLEDGE_HIT_CONTENT_LENGTH)
|
normalized_content = _truncate_text(
|
||||||
|
content, max_length=MAX_KNOWLEDGE_HIT_CONTENT_LENGTH
|
||||||
|
)
|
||||||
excerpt = _build_query_focused_excerpt(
|
excerpt = _build_query_focused_excerpt(
|
||||||
normalized_content,
|
normalized_content,
|
||||||
query_terms=query_terms,
|
query_terms=query_terms,
|
||||||
@@ -510,17 +556,14 @@ class KnowledgeRagService:
|
|||||||
|
|
||||||
|
|
||||||
def shutdown_knowledge_rag_runtime() -> None:
|
def shutdown_knowledge_rag_runtime() -> None:
|
||||||
global _runtime_instance, _runtime_signature
|
|
||||||
|
|
||||||
with _runtime_lock:
|
with _runtime_lock:
|
||||||
if _runtime_instance is None:
|
for runtime in list(_runtime_instances.values()):
|
||||||
return
|
try:
|
||||||
try:
|
runtime.finalize()
|
||||||
_runtime_instance.finalize()
|
except Exception as exc: # pragma: no cover - best effort cleanup
|
||||||
except Exception as exc: # pragma: no cover - best effort cleanup
|
logger.warning("Finalize LightRAG runtime failed during shutdown: %s", exc)
|
||||||
logger.warning("Finalize LightRAG runtime failed during shutdown: %s", exc)
|
_runtime_instances.clear()
|
||||||
_runtime_instance = None
|
_runtime_signatures.clear()
|
||||||
_runtime_signature = None
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_document_identity(file_path: str) -> tuple[str, str]:
|
def _parse_document_identity(file_path: str) -> tuple[str, str]:
|
||||||
@@ -551,9 +594,7 @@ def _build_query_focused_excerpt(
|
|||||||
|
|
||||||
lowered = normalized.lower()
|
lowered = normalized.lower()
|
||||||
match_positions = [
|
match_positions = [
|
||||||
lowered.find(term)
|
lowered.find(term) for term in query_terms if term and lowered.find(term) >= 0
|
||||||
for term in query_terms
|
|
||||||
if term and lowered.find(term) >= 0
|
|
||||||
]
|
]
|
||||||
if not match_positions:
|
if not match_positions:
|
||||||
return _build_excerpt(normalized, max_length=max_length)
|
return _build_excerpt(normalized, max_length=max_length)
|
||||||
@@ -649,7 +690,9 @@ def _score_knowledge_hit(
|
|||||||
elif leading_appendix_marker == "# 重点章节摘录":
|
elif leading_appendix_marker == "# 重点章节摘录":
|
||||||
score += 4 if matched_terms else -12
|
score += 4 if matched_terms else -12
|
||||||
elif leading_appendix_marker == "# 问答线索补充":
|
elif leading_appendix_marker == "# 问答线索补充":
|
||||||
score += 8 if matched_terms and not prefers_tabular_evidence else 2 if matched_terms else -20
|
score += (
|
||||||
|
8 if matched_terms and not prefers_tabular_evidence else 2 if matched_terms else -20
|
||||||
|
)
|
||||||
elif leading_appendix_marker == "# 结构化表格补充":
|
elif leading_appendix_marker == "# 结构化表格补充":
|
||||||
if prefers_tabular_evidence and matched_terms:
|
if prefers_tabular_evidence and matched_terms:
|
||||||
score += 16
|
score += 16
|
||||||
@@ -666,7 +709,11 @@ def _score_knowledge_hit(
|
|||||||
score += 4
|
score += 4
|
||||||
if matched_terms and any(marker in content for marker in ("附表", "第", "条")):
|
if matched_terms and any(marker in content for marker in ("附表", "第", "条")):
|
||||||
score += 4
|
score += 4
|
||||||
if not prefers_tabular_evidence and matched_terms and any(marker in content for marker in ("第", "条", ":", "-", "•")):
|
if (
|
||||||
|
not prefers_tabular_evidence
|
||||||
|
and matched_terms
|
||||||
|
and any(marker in content for marker in ("第", "条", ":", "-", "•"))
|
||||||
|
):
|
||||||
score += 4
|
score += 4
|
||||||
if title and any(term in title for term in query_terms):
|
if title and any(term in title for term in query_terms):
|
||||||
score += 6
|
score += 6
|
||||||
|
|||||||
@@ -170,6 +170,7 @@ class SemanticOntologyService(
|
|||||||
entities = self._merge_entities(
|
entities = self._merge_entities(
|
||||||
entities,
|
entities,
|
||||||
model_parse.entity_hints if model_parse is not None else [],
|
model_parse.entity_hints if model_parse is not None else [],
|
||||||
|
compact_query,
|
||||||
)
|
)
|
||||||
intent = self._resolve_intent(
|
intent = self._resolve_intent(
|
||||||
compact_query,
|
compact_query,
|
||||||
@@ -193,6 +194,11 @@ class SemanticOntologyService(
|
|||||||
context_json=context_json,
|
context_json=context_json,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
missing_slots = self._filter_expense_missing_slots(
|
||||||
|
compact_query=compact_query,
|
||||||
|
entities=entities,
|
||||||
|
missing_slots=missing_slots,
|
||||||
|
)
|
||||||
relax_knowledge_follow_up = self._should_relax_knowledge_follow_up_clarification(
|
relax_knowledge_follow_up = self._should_relax_knowledge_follow_up_clarification(
|
||||||
compact_query=compact_query,
|
compact_query=compact_query,
|
||||||
scenario=scenario,
|
scenario=scenario,
|
||||||
@@ -306,6 +312,45 @@ class SemanticOntologyService(
|
|||||||
follow_up_markers = ("那", "那么", "这个", "这种", "呢", "的话", "p", "P")
|
follow_up_markers = ("那", "那么", "这个", "这种", "呢", "的话", "p", "P")
|
||||||
return any(marker in compact_query for marker in follow_up_markers)
|
return any(marker in compact_query for marker in follow_up_markers)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _filter_expense_missing_slots(
|
||||||
|
*,
|
||||||
|
compact_query: str,
|
||||||
|
entities: list[object],
|
||||||
|
missing_slots: list[str],
|
||||||
|
) -> list[str]:
|
||||||
|
expense_types = {
|
||||||
|
str(getattr(item, "normalized_value", "") or getattr(item, "value", "") or "").strip()
|
||||||
|
for item in entities
|
||||||
|
if getattr(item, "type", "") == "expense_type"
|
||||||
|
}
|
||||||
|
has_transport = "transport" in expense_types
|
||||||
|
has_entertainment = "entertainment" in expense_types
|
||||||
|
explicit_entertainment = any(
|
||||||
|
keyword in compact_query
|
||||||
|
for keyword in (
|
||||||
|
"业务招待",
|
||||||
|
"招待费",
|
||||||
|
"招待",
|
||||||
|
"宴请",
|
||||||
|
"请客",
|
||||||
|
"请客户吃饭",
|
||||||
|
"客户吃饭",
|
||||||
|
"客户用餐",
|
||||||
|
"客户餐",
|
||||||
|
"商务接待",
|
||||||
|
"商务宴请",
|
||||||
|
"接待餐",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if has_transport and not has_entertainment and not explicit_entertainment:
|
||||||
|
return [
|
||||||
|
item
|
||||||
|
for item in missing_slots
|
||||||
|
if item not in {"customer_name", "participants"}
|
||||||
|
]
|
||||||
|
return missing_slots
|
||||||
|
|
||||||
def _record_semantic_parse(
|
def _record_semantic_parse(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -37,6 +37,39 @@ from app.services.ontology_rules import (
|
|||||||
logger = get_logger("app.services.ontology")
|
logger = get_logger("app.services.ontology")
|
||||||
|
|
||||||
|
|
||||||
|
TRANSPORT_EXPENSE_OVERRIDE_KEYWORDS = (
|
||||||
|
"打车",
|
||||||
|
"网约车",
|
||||||
|
"出租车票",
|
||||||
|
"出租车",
|
||||||
|
"的士票",
|
||||||
|
"的士",
|
||||||
|
"滴滴",
|
||||||
|
"市内交通",
|
||||||
|
"乘车",
|
||||||
|
"乘车费",
|
||||||
|
"用车",
|
||||||
|
"叫车",
|
||||||
|
"车费",
|
||||||
|
"车资",
|
||||||
|
"机场",
|
||||||
|
)
|
||||||
|
EXPLICIT_ENTERTAINMENT_KEYWORDS = (
|
||||||
|
"业务招待",
|
||||||
|
"招待费",
|
||||||
|
"招待",
|
||||||
|
"宴请",
|
||||||
|
"请客",
|
||||||
|
"请客户吃饭",
|
||||||
|
"客户吃饭",
|
||||||
|
"客户用餐",
|
||||||
|
"客户餐",
|
||||||
|
"商务接待",
|
||||||
|
"商务宴请",
|
||||||
|
"接待餐",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OntologyDetectionMixin:
|
class OntologyDetectionMixin:
|
||||||
def _detect_scenario(self, compact_query: str) -> tuple[str, float]:
|
def _detect_scenario(self, compact_query: str) -> tuple[str, float]:
|
||||||
scores = {key: 0.0 for key in SCENARIO_KEYWORDS}
|
scores = {key: 0.0 for key in SCENARIO_KEYWORDS}
|
||||||
@@ -337,6 +370,9 @@ class OntologyDetectionMixin:
|
|||||||
"出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。"
|
"出现“客户”不等于应收,出现“供应商”不等于应付,必须结合动作词和业务目标判断。"
|
||||||
"只有明确查询、统计、列出、多少、明细、对比时才优先使用 query 或 compare。"
|
"只有明确查询、统计、列出、多少、明细、对比时才优先使用 query 或 compare。"
|
||||||
"附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。"
|
"附件名称和 OCR 摘要只作为辅助证据,不能编造未出现的事实。"
|
||||||
|
"如果用户明确提到打车、的士票、出租车票、网约车、乘车费、车费等交通票据,"
|
||||||
|
"即使句子里出现“客户”,也必须优先识别为 transport,不要推断为 entertainment。"
|
||||||
|
"不要输出用户原文未出现、且与规则候选冲突的费用类型。"
|
||||||
"信息不足时 clarification_required=true,并给出一句简短中文追问。"
|
"信息不足时 clarification_required=true,并给出一句简短中文追问。"
|
||||||
"missing_slots 使用简短 snake_case,例如 expense_type, amount, "
|
"missing_slots 使用简短 snake_case,例如 expense_type, amount, "
|
||||||
"customer_name, participants, attachments。"
|
"customer_name, participants, attachments。"
|
||||||
@@ -351,12 +387,12 @@ class OntologyDetectionMixin:
|
|||||||
' "intent": "draft",\n'
|
' "intent": "draft",\n'
|
||||||
' "confidence": 0.88,\n'
|
' "confidence": 0.88,\n'
|
||||||
' "clarification_required": true,\n'
|
' "clarification_required": true,\n'
|
||||||
' "clarification_question": "请补充客户单位、参与人员和票据附件。",\n'
|
' "clarification_question": "请补充发生时间、金额和票据附件。",\n'
|
||||||
' "missing_slots": ["customer_name", "participants", "attachments"],\n'
|
' "missing_slots": ["time_range", "amount", "attachments"],\n'
|
||||||
' "ambiguity": [],\n'
|
' "ambiguity": [],\n'
|
||||||
' "entity_hints": [\n'
|
' "entity_hints": [\n'
|
||||||
' {"type": "expense_type", "value": "招待", '
|
' {"type": "expense_type", "value": "交通费", '
|
||||||
'"normalized_value": "entertainment", "role": "filter", '
|
'"normalized_value": "transport", "role": "filter", '
|
||||||
'"confidence": 0.86}\n'
|
'"confidence": 0.86}\n'
|
||||||
" ]\n"
|
" ]\n"
|
||||||
"}"
|
"}"
|
||||||
@@ -432,6 +468,7 @@ class OntologyDetectionMixin:
|
|||||||
def _merge_entities(
|
def _merge_entities(
|
||||||
base_entities: list[OntologyEntity],
|
base_entities: list[OntologyEntity],
|
||||||
entity_hints: list[LlmOntologyEntityHint],
|
entity_hints: list[LlmOntologyEntityHint],
|
||||||
|
compact_query: str = "",
|
||||||
) -> list[OntologyEntity]:
|
) -> list[OntologyEntity]:
|
||||||
merged: dict[tuple[str, str], OntologyEntity] = {
|
merged: dict[tuple[str, str], OntologyEntity] = {
|
||||||
(item.type, item.normalized_value): item for item in base_entities
|
(item.type, item.normalized_value): item for item in base_entities
|
||||||
@@ -454,7 +491,36 @@ class OntologyDetectionMixin:
|
|||||||
if existing is None or existing.confidence < candidate.confidence:
|
if existing is None or existing.confidence < candidate.confidence:
|
||||||
merged[key] = candidate
|
merged[key] = candidate
|
||||||
|
|
||||||
return list(merged.values())
|
items = list(merged.values())
|
||||||
|
if OntologyDetectionMixin._should_transport_override_entertainment(
|
||||||
|
compact_query,
|
||||||
|
items,
|
||||||
|
):
|
||||||
|
items = [
|
||||||
|
item
|
||||||
|
for item in items
|
||||||
|
if not (
|
||||||
|
item.type == "expense_type"
|
||||||
|
and item.normalized_value == "entertainment"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
return items
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_transport_override_entertainment(
|
||||||
|
compact_query: str,
|
||||||
|
entities: list[OntologyEntity],
|
||||||
|
) -> bool:
|
||||||
|
expense_types = {
|
||||||
|
str(item.normalized_value or item.value or "").strip()
|
||||||
|
for item in entities
|
||||||
|
if item.type == "expense_type"
|
||||||
|
}
|
||||||
|
if not {"transport", "entertainment"}.issubset(expense_types):
|
||||||
|
return False
|
||||||
|
if not any(keyword in compact_query for keyword in TRANSPORT_EXPENSE_OVERRIDE_KEYWORDS):
|
||||||
|
return False
|
||||||
|
return not any(keyword in compact_query for keyword in EXPLICIT_ENTERTAINMENT_KEYWORDS)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_short_text_list(values: list[str]) -> list[str]:
|
def _normalize_short_text_list(values: list[str]) -> list[str]:
|
||||||
|
|||||||
@@ -59,11 +59,16 @@ class OntologyExtractionMixin:
|
|||||||
missing_slots.append("attachments")
|
missing_slots.append("attachments")
|
||||||
return missing_slots
|
return missing_slots
|
||||||
|
|
||||||
if any(
|
has_entertainment_type = any(
|
||||||
item.normalized_value == "entertainment"
|
item.normalized_value == "entertainment"
|
||||||
for item in entities
|
for item in entities
|
||||||
if item.type == "expense_type"
|
if item.type == "expense_type"
|
||||||
):
|
)
|
||||||
|
has_explicit_entertainment_text = "客户" in compact_query and any(
|
||||||
|
keyword in compact_query
|
||||||
|
for keyword in ("招待", "接待", "吃饭", "用餐", "宴请", "请客", "客户餐")
|
||||||
|
)
|
||||||
|
if has_entertainment_type or has_explicit_entertainment_text:
|
||||||
if "customer" not in entity_types:
|
if "customer" not in entity_types:
|
||||||
missing_slots.append("customer_name")
|
missing_slots.append("customer_name")
|
||||||
missing_slots.append("participants")
|
missing_slots.append("participants")
|
||||||
@@ -171,14 +176,14 @@ class OntologyExtractionMixin:
|
|||||||
upsert(self._make_entity("expense_type", label, normalized, role="filter"))
|
upsert(self._make_entity("expense_type", label, normalized, role="filter"))
|
||||||
|
|
||||||
has_customer_entertainment_signal = "客户" in query and any(
|
has_customer_entertainment_signal = "客户" in query and any(
|
||||||
keyword in query for keyword in ("吃饭", "用餐", "餐饮", "宴请", "请客", "招待")
|
keyword in query for keyword in ("吃饭", "用餐", "餐饮", "宴请", "请客", "招待", "接待")
|
||||||
)
|
)
|
||||||
if has_customer_entertainment_signal:
|
if has_customer_entertainment_signal:
|
||||||
upsert(
|
upsert(
|
||||||
self._make_entity(
|
self._make_entity(
|
||||||
"expense_type",
|
"expense_type",
|
||||||
"客户招待",
|
"业务招待费",
|
||||||
"entertainment",
|
"meal",
|
||||||
role="filter",
|
role="filter",
|
||||||
confidence=0.96,
|
confidence=0.96,
|
||||||
)
|
)
|
||||||
@@ -189,46 +194,52 @@ class OntologyExtractionMixin:
|
|||||||
for keyword in (
|
for keyword in (
|
||||||
"打车",
|
"打车",
|
||||||
"网约车",
|
"网约车",
|
||||||
"出租车",
|
|
||||||
"出租车票",
|
"出租车票",
|
||||||
|
"出租车",
|
||||||
"车费",
|
"车费",
|
||||||
"乘车",
|
"乘车",
|
||||||
"用车",
|
"用车",
|
||||||
"叫车",
|
"叫车",
|
||||||
"车资",
|
"车资",
|
||||||
"的士",
|
|
||||||
"的士票",
|
"的士票",
|
||||||
|
"的士",
|
||||||
|
"滴滴",
|
||||||
|
"市内交通",
|
||||||
|
"地铁",
|
||||||
|
"公交",
|
||||||
"停车费",
|
"停车费",
|
||||||
"过路费",
|
"过路费",
|
||||||
|
"通行费",
|
||||||
|
"高速费",
|
||||||
)
|
)
|
||||||
):
|
):
|
||||||
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
|
upsert(self._make_entity("expense_type", "交通", "transport", role="filter", confidence=0.9))
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("出差", "机票", "火车", "高铁", "行程单")):
|
if any(keyword in query for keyword in ("出差", "机票", "飞机票", "航班", "火车票", "火车", "高铁票", "高铁", "动车", "行程单")):
|
||||||
upsert(self._make_entity("expense_type", "差旅", "travel", role="filter", confidence=0.88))
|
upsert(self._make_entity("expense_type", "差旅", "travel", role="filter", confidence=0.88))
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("酒店", "住宿", "宾馆")):
|
if any(keyword in query for keyword in ("酒店", "酒店发票", "住宿", "住宿费", "宾馆", "民宿", "房费", "客房")):
|
||||||
upsert(self._make_entity("expense_type", "住宿", "hotel", role="filter", confidence=0.86))
|
upsert(self._make_entity("expense_type", "住宿", "hotel", role="filter", confidence=0.86))
|
||||||
|
|
||||||
if (
|
if (
|
||||||
not has_customer_entertainment_signal
|
not has_customer_entertainment_signal
|
||||||
and any(keyword in query for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "餐饮"))
|
and any(keyword in query for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "餐饮"))
|
||||||
):
|
):
|
||||||
upsert(self._make_entity("expense_type", "餐费", "meal", role="filter", confidence=0.84))
|
upsert(self._make_entity("expense_type", "业务招待费", "meal", role="filter", confidence=0.84))
|
||||||
|
|
||||||
if any(
|
if any(
|
||||||
keyword in query
|
keyword in query
|
||||||
for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板")
|
for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板", "硒鼓", "墨盒")
|
||||||
):
|
):
|
||||||
upsert(self._make_entity("expense_type", "办公费", "office", role="filter", confidence=0.87))
|
upsert(self._make_entity("expense_type", "办公用品费", "office", role="filter", confidence=0.87))
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("培训", "讲师费", "课时费", "课程费")):
|
if any(keyword in query for keyword in ("培训", "讲师费", "课时费", "课程费", "教材", "认证费", "考试费")):
|
||||||
upsert(self._make_entity("expense_type", "培训费", "training", role="filter", confidence=0.84))
|
upsert(self._make_entity("expense_type", "培训费", "training", role="filter", confidence=0.84))
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("通讯费", "话费", "流量费", "宽带费")):
|
if any(keyword in query for keyword in ("通讯费", "话费", "电话费", "手机费", "流量费", "宽带费", "网络费")):
|
||||||
upsert(self._make_entity("expense_type", "通讯费", "communication", role="filter", confidence=0.84))
|
upsert(self._make_entity("expense_type", "通讯费", "communication", role="filter", confidence=0.84))
|
||||||
|
|
||||||
if any(keyword in query for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费")):
|
if any(keyword in query for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费", "员工关怀")):
|
||||||
upsert(self._make_entity("expense_type", "福利费", "welfare", role="filter", confidence=0.84))
|
upsert(self._make_entity("expense_type", "福利费", "welfare", role="filter", confidence=0.84))
|
||||||
|
|
||||||
for amount in self._extract_amount_entities(query):
|
for amount in self._extract_amount_entities(query):
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass
|
|||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
from app.schemas.ontology import OntologyIntent, OntologyScenario
|
from app.schemas.ontology import OntologyIntent, OntologyScenario
|
||||||
|
from app.services.expense_type_keywords import build_expense_type_keyword_map
|
||||||
|
|
||||||
DATE_RANGE_PATTERN = re.compile(
|
DATE_RANGE_PATTERN = re.compile(
|
||||||
r"(?P<start>\d{4}-\d{1,2}-\d{1,2})\s*(?:到|至|~|-)\s*(?P<end>\d{4}-\d{1,2}-\d{1,2})"
|
r"(?P<start>\d{4}-\d{1,2}-\d{1,2})\s*(?:到|至|~|-)\s*(?P<end>\d{4}-\d{1,2}-\d{1,2})"
|
||||||
@@ -128,44 +129,7 @@ OPERATE_KEYWORDS = (
|
|||||||
"删除",
|
"删除",
|
||||||
)
|
)
|
||||||
|
|
||||||
EXPENSE_TYPE_KEYWORDS = {
|
EXPENSE_TYPE_KEYWORDS = build_expense_type_keyword_map()
|
||||||
"差旅": "travel",
|
|
||||||
"出差": "travel",
|
|
||||||
"住宿": "hotel",
|
|
||||||
"酒店": "hotel",
|
|
||||||
"交通": "transport",
|
|
||||||
"打车": "transport",
|
|
||||||
"网约车": "transport",
|
|
||||||
"出租车": "transport",
|
|
||||||
"出租车票": "transport",
|
|
||||||
"乘车": "transport",
|
|
||||||
"乘车费": "transport",
|
|
||||||
"用车": "transport",
|
|
||||||
"叫车": "transport",
|
|
||||||
"车资": "transport",
|
|
||||||
"的士": "transport",
|
|
||||||
"的士票": "transport",
|
|
||||||
"停车费": "transport",
|
|
||||||
"餐费": "meal",
|
|
||||||
"用餐": "meal",
|
|
||||||
"会务": "meeting",
|
|
||||||
"招待费": "entertainment",
|
|
||||||
"招待": "entertainment",
|
|
||||||
"宴请": "entertainment",
|
|
||||||
"办公费": "office",
|
|
||||||
"办公用品": "office",
|
|
||||||
"文具": "office",
|
|
||||||
"耗材": "office",
|
|
||||||
"办公耗材": "office",
|
|
||||||
"打印纸": "office",
|
|
||||||
"办公设备": "office",
|
|
||||||
"培训费": "training",
|
|
||||||
"培训": "training",
|
|
||||||
"通讯费": "communication",
|
|
||||||
"话费": "communication",
|
|
||||||
"福利费": "welfare",
|
|
||||||
"团建": "welfare",
|
|
||||||
}
|
|
||||||
|
|
||||||
EXPENSE_NARRATIVE_KEYWORDS = (
|
EXPENSE_NARRATIVE_KEYWORDS = (
|
||||||
"报销",
|
"报销",
|
||||||
|
|||||||
@@ -74,16 +74,16 @@ EXPENSE_RISK_LEVEL_LABELS = {
|
|||||||
"medium": "中风险",
|
"medium": "中风险",
|
||||||
"warning": "中风险",
|
"warning": "中风险",
|
||||||
"low": "低风险",
|
"low": "低风险",
|
||||||
"info": "低风险",
|
"info": "提示",
|
||||||
}
|
}
|
||||||
EXPENSE_TYPE_LABELS = {
|
EXPENSE_TYPE_LABELS = {
|
||||||
"travel": "差旅费",
|
"travel": "差旅费",
|
||||||
"hotel": "住宿费",
|
"hotel": "住宿费",
|
||||||
"transport": "交通费",
|
"transport": "交通费",
|
||||||
"meal": "餐费",
|
"meal": "业务招待费",
|
||||||
"meeting": "会务费",
|
"meeting": "会务费",
|
||||||
"entertainment": "业务招待费",
|
"entertainment": "业务招待费",
|
||||||
"office": "办公费",
|
"office": "办公用品费",
|
||||||
"training": "培训费",
|
"training": "培训费",
|
||||||
"communication": "通讯费",
|
"communication": "通讯费",
|
||||||
"welfare": "福利费",
|
"welfare": "福利费",
|
||||||
|
|||||||
@@ -35,10 +35,10 @@ EXPENSE_TYPE_LABELS = {
|
|||||||
"travel": "差旅费",
|
"travel": "差旅费",
|
||||||
"hotel": "住宿费",
|
"hotel": "住宿费",
|
||||||
"transport": "交通费",
|
"transport": "交通费",
|
||||||
"meal": "餐费",
|
"meal": "业务招待费",
|
||||||
"meeting": "会务费",
|
"meeting": "会务费",
|
||||||
"entertainment": "业务招待费",
|
"entertainment": "业务招待费",
|
||||||
"office": "办公费",
|
"office": "办公用品费",
|
||||||
"training": "培训费",
|
"training": "培训费",
|
||||||
"communication": "通讯费",
|
"communication": "通讯费",
|
||||||
"welfare": "福利费",
|
"welfare": "福利费",
|
||||||
@@ -48,10 +48,10 @@ EXPENSE_TYPE_LABELS = {
|
|||||||
GROUP_SCENE_LABELS = {
|
GROUP_SCENE_LABELS = {
|
||||||
"travel": "差旅费",
|
"travel": "差旅费",
|
||||||
"entertainment": "业务招待费",
|
"entertainment": "业务招待费",
|
||||||
"meal": "伙食费",
|
"meal": "业务招待费",
|
||||||
"transport": "交通费",
|
"transport": "交通费",
|
||||||
"hotel": "住宿费",
|
"hotel": "住宿费",
|
||||||
"office": "办公费",
|
"office": "办公用品费",
|
||||||
"training": "培训费",
|
"training": "培训费",
|
||||||
"communication": "通讯费",
|
"communication": "通讯费",
|
||||||
"welfare": "福利费",
|
"welfare": "福利费",
|
||||||
@@ -62,8 +62,12 @@ EXPENSE_SCENE_SELECTION_OPTIONS = (
|
|||||||
("travel", "差旅费", "出差、长途交通、住宿、差旅补贴等场景。"),
|
("travel", "差旅费", "出差、长途交通、住宿、差旅补贴等场景。"),
|
||||||
("transport", "交通费", "市内打车、停车、过路费等日常交通场景。"),
|
("transport", "交通费", "市内打车、停车、过路费等日常交通场景。"),
|
||||||
("hotel", "住宿费", "单独住宿、酒店发票等场景。"),
|
("hotel", "住宿费", "单独住宿、酒店发票等场景。"),
|
||||||
("entertainment", "业务招待费", "客户接待、宴请、招待等场景。"),
|
("meal", "业务招待费", "客户接待、工作餐、加班餐、餐饮票据等场景。"),
|
||||||
("office", "办公费", "办公用品、耗材、办公设备等采购场景。"),
|
("meeting", "会务费", "会议、论坛、会场、参会等场景。"),
|
||||||
|
("office", "办公用品费", "办公用品、耗材、办公设备等采购场景。"),
|
||||||
|
("training", "培训费", "培训课程、讲师费、教材、认证等场景。"),
|
||||||
|
("communication", "通讯费", "话费、流量、宽带、网络等场景。"),
|
||||||
|
("welfare", "福利费", "团建、体检、慰问、节日福利等场景。"),
|
||||||
("other", "其他费用", "暂不属于以上分类的报销场景。"),
|
("other", "其他费用", "暂不属于以上分类的报销场景。"),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,10 +134,10 @@ INFERRED_REASON_LABELS = {
|
|||||||
"travel": "出差行程",
|
"travel": "出差行程",
|
||||||
"hotel": "住宿报销",
|
"hotel": "住宿报销",
|
||||||
"transport": "交通出行",
|
"transport": "交通出行",
|
||||||
"meal": "餐饮用餐",
|
"meal": "业务招待",
|
||||||
"meeting": "会务活动",
|
"meeting": "会务活动",
|
||||||
"entertainment": "客户接待",
|
"entertainment": "客户接待",
|
||||||
"office": "办公采购",
|
"office": "办公用品采购",
|
||||||
"training": "培训学习",
|
"training": "培训学习",
|
||||||
"communication": "通讯使用",
|
"communication": "通讯使用",
|
||||||
"welfare": "员工福利",
|
"welfare": "员工福利",
|
||||||
|
|||||||
@@ -9,16 +9,32 @@ from app.schemas.user_agent import UserAgentRequest, UserAgentReviewDocumentCard
|
|||||||
DEFAULT_GROUP_SCENE_LABELS = {
|
DEFAULT_GROUP_SCENE_LABELS = {
|
||||||
"travel": "差旅费",
|
"travel": "差旅费",
|
||||||
"entertainment": "业务招待费",
|
"entertainment": "业务招待费",
|
||||||
"meal": "伙食费",
|
"meal": "业务招待费",
|
||||||
"transport": "交通费",
|
"transport": "交通费",
|
||||||
"hotel": "住宿费",
|
"hotel": "住宿费",
|
||||||
"office": "办公费",
|
"office": "办公用品费",
|
||||||
"training": "培训费",
|
"training": "培训费",
|
||||||
"communication": "通讯费",
|
"communication": "通讯费",
|
||||||
"welfare": "福利费",
|
"welfare": "福利费",
|
||||||
"other": "其他费用",
|
"other": "其他费用",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
DOCUMENT_SCENE_LABELS = {
|
||||||
|
"flight_itinerary": "机票/航班行程单",
|
||||||
|
"train_ticket": "火车/高铁票",
|
||||||
|
"ship_ticket": "轮船票",
|
||||||
|
"travel_ticket": "交通出行票据",
|
||||||
|
"hotel_invoice": "酒店住宿票据",
|
||||||
|
"taxi_receipt": "出租车/网约车票据",
|
||||||
|
"transport_receipt": "乘车票据",
|
||||||
|
"parking_toll_receipt": "停车/通行费票据",
|
||||||
|
"meal_receipt": "餐饮发票",
|
||||||
|
"office_invoice": "文具/办公用品发票",
|
||||||
|
"meeting_invoice": "会议/会务票据",
|
||||||
|
"training_invoice": "培训票据",
|
||||||
|
"other": "其他票据",
|
||||||
|
}
|
||||||
|
|
||||||
DOCUMENT_DATE_TEXT_PATTERN = re.compile(
|
DOCUMENT_DATE_TEXT_PATTERN = re.compile(
|
||||||
r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?(?:\s*[T ]?\s*(?:[01]?\d|2[0-3])[::][0-5]\d)?)"
|
r"(\d{4}[年/-]\d{1,2}[月/-]\d{1,2}日?(?:\s*[T ]?\s*(?:[01]?\d|2[0-3])[::][0-5]\d)?)"
|
||||||
)
|
)
|
||||||
@@ -48,55 +64,55 @@ class UserAgentDocumentService:
|
|||||||
provided_type = str(item.get("document_type") or "").strip().lower()
|
provided_type = str(item.get("document_type") or "").strip().lower()
|
||||||
normalized_expense_type = str(expense_type_code or "").strip().lower()
|
normalized_expense_type = str(expense_type_code or "").strip().lower()
|
||||||
if provided_type:
|
if provided_type:
|
||||||
if provided_type in {"flight_itinerary", "train_ticket"}:
|
if provided_type in {"flight_itinerary", "train_ticket", "ship_ticket"}:
|
||||||
return {
|
return {
|
||||||
"document_type": provided_type,
|
"document_type": provided_type,
|
||||||
"expense_type": "travel",
|
"expense_type": "travel",
|
||||||
"group_code": "travel",
|
"group_code": "travel",
|
||||||
"scene_label": "差旅票据",
|
"scene_label": DOCUMENT_SCENE_LABELS.get(provided_type, "交通出行票据"),
|
||||||
}
|
}
|
||||||
if provided_type == "hotel_invoice":
|
if provided_type == "hotel_invoice":
|
||||||
return {
|
return {
|
||||||
"document_type": provided_type,
|
"document_type": provided_type,
|
||||||
"expense_type": "hotel",
|
"expense_type": "hotel",
|
||||||
"group_code": "travel",
|
"group_code": "travel",
|
||||||
"scene_label": "住宿票据",
|
"scene_label": DOCUMENT_SCENE_LABELS["hotel_invoice"],
|
||||||
}
|
}
|
||||||
if provided_type in {"taxi_receipt", "parking_toll_receipt"}:
|
if provided_type in {"taxi_receipt", "transport_receipt", "parking_toll_receipt"}:
|
||||||
return {
|
return {
|
||||||
"document_type": provided_type,
|
"document_type": provided_type,
|
||||||
"expense_type": "transport",
|
"expense_type": "transport",
|
||||||
"group_code": "travel",
|
"group_code": "travel",
|
||||||
"scene_label": "交通票据",
|
"scene_label": DOCUMENT_SCENE_LABELS.get(provided_type, "乘车票据"),
|
||||||
}
|
}
|
||||||
if provided_type == "meal_receipt":
|
if provided_type == "meal_receipt":
|
||||||
group_code = "entertainment" if normalized_expense_type == "entertainment" or has_customer else "meal"
|
group_code = "meal"
|
||||||
return {
|
return {
|
||||||
"document_type": provided_type,
|
"document_type": provided_type,
|
||||||
"expense_type": group_code,
|
"expense_type": group_code,
|
||||||
"group_code": group_code,
|
"group_code": group_code,
|
||||||
"scene_label": "餐饮票据",
|
"scene_label": DOCUMENT_SCENE_LABELS["meal_receipt"],
|
||||||
}
|
}
|
||||||
if provided_type == "office_invoice":
|
if provided_type == "office_invoice":
|
||||||
return {
|
return {
|
||||||
"document_type": provided_type,
|
"document_type": provided_type,
|
||||||
"expense_type": "office",
|
"expense_type": "office",
|
||||||
"group_code": "office",
|
"group_code": "office",
|
||||||
"scene_label": "办公用品票据",
|
"scene_label": DOCUMENT_SCENE_LABELS["office_invoice"],
|
||||||
}
|
}
|
||||||
if provided_type == "meeting_invoice":
|
if provided_type == "meeting_invoice":
|
||||||
return {
|
return {
|
||||||
"document_type": provided_type,
|
"document_type": provided_type,
|
||||||
"expense_type": "meeting",
|
"expense_type": "meeting",
|
||||||
"group_code": "meeting",
|
"group_code": "meeting",
|
||||||
"scene_label": "会务票据",
|
"scene_label": DOCUMENT_SCENE_LABELS["meeting_invoice"],
|
||||||
}
|
}
|
||||||
if provided_type == "training_invoice":
|
if provided_type == "training_invoice":
|
||||||
return {
|
return {
|
||||||
"document_type": provided_type,
|
"document_type": provided_type,
|
||||||
"expense_type": "training",
|
"expense_type": "training",
|
||||||
"group_code": "training",
|
"group_code": "training",
|
||||||
"scene_label": "培训票据",
|
"scene_label": DOCUMENT_SCENE_LABELS["training_invoice"],
|
||||||
}
|
}
|
||||||
|
|
||||||
text = " ".join(
|
text = " ".join(
|
||||||
@@ -108,41 +124,69 @@ class UserAgentDocumentService:
|
|||||||
).lower()
|
).lower()
|
||||||
compact = text.replace(" ", "")
|
compact = text.replace(" ", "")
|
||||||
|
|
||||||
if any(keyword in compact for keyword in ("机票", "航班", "火车", "高铁", "行程单")):
|
if any(keyword in compact for keyword in ("火车", "高铁", "动车", "铁路", "车次")):
|
||||||
return {
|
return {
|
||||||
"document_type": "travel_ticket",
|
"document_type": "train_ticket",
|
||||||
"expense_type": "travel",
|
"expense_type": "travel",
|
||||||
"group_code": "travel",
|
"group_code": "travel",
|
||||||
"scene_label": "差旅票据",
|
"scene_label": DOCUMENT_SCENE_LABELS["train_ticket"],
|
||||||
|
}
|
||||||
|
if any(keyword in compact for keyword in ("过路费", "停车", "通行费", "收费站")):
|
||||||
|
return {
|
||||||
|
"document_type": "parking_toll_receipt",
|
||||||
|
"expense_type": "transport",
|
||||||
|
"group_code": "travel",
|
||||||
|
"scene_label": DOCUMENT_SCENE_LABELS["parking_toll_receipt"],
|
||||||
|
}
|
||||||
|
if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "叫车", "车费", "车资", "的士")):
|
||||||
|
return {
|
||||||
|
"document_type": "taxi_receipt",
|
||||||
|
"expense_type": "transport",
|
||||||
|
"group_code": "travel",
|
||||||
|
"scene_label": DOCUMENT_SCENE_LABELS["taxi_receipt"],
|
||||||
|
}
|
||||||
|
if any(keyword in compact for keyword in ("乘车", "用车")):
|
||||||
|
return {
|
||||||
|
"document_type": "transport_receipt",
|
||||||
|
"expense_type": "transport",
|
||||||
|
"group_code": "travel",
|
||||||
|
"scene_label": DOCUMENT_SCENE_LABELS["transport_receipt"],
|
||||||
|
}
|
||||||
|
if any(keyword in compact for keyword in ("机票", "航班", "登机", "航空", "客票")):
|
||||||
|
return {
|
||||||
|
"document_type": "flight_itinerary",
|
||||||
|
"expense_type": "travel",
|
||||||
|
"group_code": "travel",
|
||||||
|
"scene_label": DOCUMENT_SCENE_LABELS["flight_itinerary"],
|
||||||
|
}
|
||||||
|
if any(keyword in compact for keyword in ("轮船", "船票", "客轮", "渡轮", "航运")):
|
||||||
|
return {
|
||||||
|
"document_type": "ship_ticket",
|
||||||
|
"expense_type": "travel",
|
||||||
|
"group_code": "travel",
|
||||||
|
"scene_label": DOCUMENT_SCENE_LABELS["ship_ticket"],
|
||||||
}
|
}
|
||||||
if any(keyword in compact for keyword in ("酒店", "住宿", "宾馆")):
|
if any(keyword in compact for keyword in ("酒店", "住宿", "宾馆")):
|
||||||
return {
|
return {
|
||||||
"document_type": "hotel_invoice",
|
"document_type": "hotel_invoice",
|
||||||
"expense_type": "hotel",
|
"expense_type": "hotel",
|
||||||
"group_code": "travel",
|
"group_code": "travel",
|
||||||
"scene_label": "住宿票据",
|
"scene_label": DOCUMENT_SCENE_LABELS["hotel_invoice"],
|
||||||
}
|
|
||||||
if any(keyword in compact for keyword in ("打车", "出租车", "滴滴", "网约车", "乘车", "用车", "叫车", "车费", "车资", "的士", "过路费", "停车")):
|
|
||||||
return {
|
|
||||||
"document_type": "transport_receipt",
|
|
||||||
"expense_type": "transport",
|
|
||||||
"group_code": "travel",
|
|
||||||
"scene_label": "交通票据",
|
|
||||||
}
|
}
|
||||||
if any(keyword in compact for keyword in ("餐", "饭店", "酒楼", "酒家", "餐饮", "meal")):
|
if any(keyword in compact for keyword in ("餐", "饭店", "酒楼", "酒家", "餐饮", "meal")):
|
||||||
group_code = "entertainment" if normalized_expense_type == "entertainment" or has_customer else "meal"
|
group_code = "meal"
|
||||||
return {
|
return {
|
||||||
"document_type": "meal_receipt",
|
"document_type": "meal_receipt",
|
||||||
"expense_type": group_code,
|
"expense_type": group_code,
|
||||||
"group_code": group_code,
|
"group_code": group_code,
|
||||||
"scene_label": "餐饮票据",
|
"scene_label": DOCUMENT_SCENE_LABELS["meal_receipt"],
|
||||||
}
|
}
|
||||||
if any(keyword in compact for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "键盘", "鼠标", "白板", "墨盒", "硒鼓")):
|
if any(keyword in compact for keyword in ("办公用品", "文具", "耗材", "办公耗材", "打印纸", "键盘", "鼠标", "白板", "墨盒", "硒鼓")):
|
||||||
return {
|
return {
|
||||||
"document_type": "other",
|
"document_type": "office_invoice",
|
||||||
"expense_type": "office",
|
"expense_type": "office",
|
||||||
"group_code": "office",
|
"group_code": "office",
|
||||||
"scene_label": "办公用品票据",
|
"scene_label": DOCUMENT_SCENE_LABELS["office_invoice"],
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"document_type": "other",
|
"document_type": "other",
|
||||||
|
|||||||
@@ -314,10 +314,7 @@ class UserAgentReviewCoreMixin:
|
|||||||
filename=str(item.get("filename") or f"document-{index}"),
|
filename=str(item.get("filename") or f"document-{index}"),
|
||||||
document_type=classified["document_type"],
|
document_type=classified["document_type"],
|
||||||
suggested_expense_type=classified["expense_type"],
|
suggested_expense_type=classified["expense_type"],
|
||||||
scene_label=GROUP_SCENE_LABELS.get(
|
scene_label=self._resolve_review_document_scene_label(item, classified),
|
||||||
classified["group_code"],
|
|
||||||
classified["scene_label"],
|
|
||||||
),
|
|
||||||
summary=str(item.get("summary") or item.get("text") or "").strip(),
|
summary=str(item.get("summary") or item.get("text") or "").strip(),
|
||||||
avg_score=float(item.get("avg_score") or 0.0),
|
avg_score=float(item.get("avg_score") or 0.0),
|
||||||
preview_kind=str(item.get("preview_kind") or "").strip(),
|
preview_kind=str(item.get("preview_kind") or "").strip(),
|
||||||
@@ -338,6 +335,25 @@ class UserAgentReviewCoreMixin:
|
|||||||
return cards
|
return cards
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_review_document_scene_label(item: dict[str, object], classified: dict[str, str]) -> str:
|
||||||
|
provided_label = str(item.get("document_type_label") or "").strip()
|
||||||
|
if provided_label and provided_label != "其他单据":
|
||||||
|
return provided_label
|
||||||
|
|
||||||
|
classified_scene_label = str(classified.get("scene_label") or "").strip()
|
||||||
|
if classified_scene_label:
|
||||||
|
return classified_scene_label
|
||||||
|
|
||||||
|
document_type = str(classified.get("document_type") or item.get("document_type") or "").strip()
|
||||||
|
document_type_label = resolve_document_type_label(document_type)
|
||||||
|
if document_type_label and document_type_label not in {"其他单据", document_type}:
|
||||||
|
return document_type_label
|
||||||
|
|
||||||
|
scene_label = str(item.get("scene_label") or "").strip()
|
||||||
|
return scene_label or "其他票据"
|
||||||
|
|
||||||
|
|
||||||
def _build_review_claim_groups(
|
def _build_review_claim_groups(
|
||||||
self,
|
self,
|
||||||
payload: UserAgentRequest,
|
payload: UserAgentRequest,
|
||||||
|
|||||||
@@ -59,6 +59,20 @@ class UserAgentReviewProfileMixin:
|
|||||||
manager_name = self._resolve_manager_name(employee)
|
manager_name = self._resolve_manager_name(employee)
|
||||||
reason = slot_map.get("reason").value if slot_map.get("reason") else ""
|
reason = slot_map.get("reason").value if slot_map.get("reason") else ""
|
||||||
attachments = "、".join(self._resolve_attachment_names(payload))
|
attachments = "、".join(self._resolve_attachment_names(payload))
|
||||||
|
expense_type_code = str(slot_map.get("expense_type").normalized_value if slot_map.get("expense_type") else "").strip()
|
||||||
|
customer_name = str(slot_map.get("customer_name").value if slot_map.get("customer_name") else "").strip()
|
||||||
|
merchant_name = str(slot_map.get("merchant_name").value if slot_map.get("merchant_name") else "").strip()
|
||||||
|
participants = str(slot_map.get("participants").value if slot_map.get("participants") else "").strip()
|
||||||
|
customer_slot = slot_map.get("customer_name")
|
||||||
|
participants_slot = slot_map.get("participants")
|
||||||
|
customer_required = bool(
|
||||||
|
customer_slot
|
||||||
|
and (customer_slot.required or customer_slot.status == "missing")
|
||||||
|
)
|
||||||
|
participants_required = bool(
|
||||||
|
participants_slot
|
||||||
|
and (participants_slot.required or participants_slot.status == "missing")
|
||||||
|
)
|
||||||
|
|
||||||
fields = [
|
fields = [
|
||||||
UserAgentReviewEditField(
|
UserAgentReviewEditField(
|
||||||
@@ -98,13 +112,20 @@ class UserAgentReviewProfileMixin:
|
|||||||
required=False,
|
required=False,
|
||||||
group="basic",
|
group="basic",
|
||||||
),
|
),
|
||||||
UserAgentReviewEditField(
|
]
|
||||||
key="customer_name",
|
|
||||||
label="客户名称",
|
if expense_type_code == "entertainment" or customer_required or customer_name:
|
||||||
value=slot_map.get("customer_name").value if slot_map.get("customer_name") else "",
|
fields.append(
|
||||||
placeholder="请输入客户名称",
|
UserAgentReviewEditField(
|
||||||
group="business",
|
key="customer_name",
|
||||||
),
|
label="客户名称",
|
||||||
|
value=customer_name,
|
||||||
|
placeholder="请输入客户名称",
|
||||||
|
group="business",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fields.append(
|
||||||
UserAgentReviewEditField(
|
UserAgentReviewEditField(
|
||||||
key="business_location",
|
key="business_location",
|
||||||
label="业务地点",
|
label="业务地点",
|
||||||
@@ -112,15 +133,22 @@ class UserAgentReviewProfileMixin:
|
|||||||
placeholder="例如:北京 / 客户现场",
|
placeholder="例如:北京 / 客户现场",
|
||||||
required=False,
|
required=False,
|
||||||
group="business",
|
group="business",
|
||||||
),
|
)
|
||||||
UserAgentReviewEditField(
|
)
|
||||||
key="merchant_name",
|
|
||||||
label="酒店/商户",
|
if expense_type_code == "hotel" or merchant_name:
|
||||||
value=slot_map.get("merchant_name").value if slot_map.get("merchant_name") else "",
|
fields.append(
|
||||||
placeholder="请输入酒店或商户名称",
|
UserAgentReviewEditField(
|
||||||
required=False,
|
key="merchant_name",
|
||||||
group="business",
|
label="酒店/商户",
|
||||||
),
|
value=merchant_name,
|
||||||
|
placeholder="请输入酒店或商户名称",
|
||||||
|
required=False,
|
||||||
|
group="business",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fields.extend([
|
||||||
UserAgentReviewEditField(
|
UserAgentReviewEditField(
|
||||||
key="amount",
|
key="amount",
|
||||||
label="金额",
|
label="金额",
|
||||||
@@ -128,13 +156,20 @@ class UserAgentReviewProfileMixin:
|
|||||||
placeholder="例如:200.00元",
|
placeholder="例如:200.00元",
|
||||||
group="business",
|
group="business",
|
||||||
),
|
),
|
||||||
UserAgentReviewEditField(
|
])
|
||||||
key="participants",
|
|
||||||
label="参与人员",
|
if expense_type_code == "entertainment" or participants_required or participants:
|
||||||
value=slot_map.get("participants").value if slot_map.get("participants") else "",
|
fields.append(
|
||||||
placeholder="例如:客户 2 人,我方 1 人",
|
UserAgentReviewEditField(
|
||||||
group="business",
|
key="participants",
|
||||||
),
|
label="参与人员",
|
||||||
|
value=participants,
|
||||||
|
placeholder="例如:客户 2 人,我方 1 人",
|
||||||
|
group="business",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
fields.extend([
|
||||||
UserAgentReviewEditField(
|
UserAgentReviewEditField(
|
||||||
key="reason",
|
key="reason",
|
||||||
label="事由",
|
label="事由",
|
||||||
@@ -152,7 +187,7 @@ class UserAgentReviewProfileMixin:
|
|||||||
field_type="textarea",
|
field_type="textarea",
|
||||||
group="attachments",
|
group="attachments",
|
||||||
),
|
),
|
||||||
]
|
])
|
||||||
return fields
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ from app.services.expense_claims import ExpenseClaimService
|
|||||||
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
|
from app.services.expense_rule_runtime import ExpenseRuleRuntimeService, RuntimeTravelPolicy, resolve_document_type_label
|
||||||
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
|
from app.services.risk_ontology_bridge import resolve_rule_codes_for_risk_check
|
||||||
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
from app.services.travel_reimbursement_calculator import TravelReimbursementCalculatorService
|
||||||
|
from app.services.expense_type_keywords import resolve_expense_type_label_from_text
|
||||||
from app.services.user_agent_constants import *
|
from app.services.user_agent_constants import *
|
||||||
|
|
||||||
|
|
||||||
@@ -568,27 +569,9 @@ class UserAgentReviewSlotMixin:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_expense_type_input(value: str) -> tuple[str, str]:
|
def _normalize_expense_type_input(value: str) -> tuple[str, str]:
|
||||||
compact = str(value or "").replace(" ", "")
|
resolved = resolve_expense_type_label_from_text(value)
|
||||||
if "招待" in compact or ("客户" in compact and any(keyword in compact for keyword in ("吃饭", "用餐", "宴请", "请客"))):
|
if resolved is not None:
|
||||||
return "entertainment", "业务招待费"
|
return resolved
|
||||||
if any(keyword in compact for keyword in ("差旅", "出差", "机票", "行程")):
|
|
||||||
return "travel", "差旅费"
|
|
||||||
if any(keyword in compact for keyword in ("住宿", "酒店", "宾馆")):
|
|
||||||
return "hotel", "住宿费"
|
|
||||||
if any(keyword in compact for keyword in ("交通", "打车", "网约车", "出租车", "乘车", "用车", "叫车", "车费", "车资", "的士", "停车")):
|
|
||||||
return "transport", "交通费"
|
|
||||||
if any(keyword in compact for keyword in ("餐费", "用餐", "午餐", "晚餐", "早餐", "伙食")):
|
|
||||||
return "meal", "餐费"
|
|
||||||
if "会务" in compact:
|
|
||||||
return "meeting", "会务费"
|
|
||||||
if any(keyword in compact for keyword in ("办公费", "办公用品", "文具", "耗材", "办公耗材", "打印纸", "办公设备", "键盘", "鼠标", "白板")):
|
|
||||||
return "office", "办公费"
|
|
||||||
if any(keyword in compact for keyword in ("培训费", "培训", "讲师费", "课时费", "课程费")):
|
|
||||||
return "training", "培训费"
|
|
||||||
if any(keyword in compact for keyword in ("通讯费", "话费", "流量费", "宽带费")):
|
|
||||||
return "communication", "通讯费"
|
|
||||||
if any(keyword in compact for keyword in ("福利费", "团建", "慰问", "节日福利", "体检费")):
|
|
||||||
return "welfare", "福利费"
|
|
||||||
return "other", str(value or "").strip() or "其他费用"
|
return "other", str(value or "").strip() or "其他费用"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -137,14 +137,13 @@ class UserAgentReviewTravelPolicyMixin:
|
|||||||
continue
|
continue
|
||||||
night_count = self._extract_review_hotel_night_count(card)
|
night_count = self._extract_review_hotel_night_count(card)
|
||||||
nightly_amount = (amount / Decimal(max(night_count, 1))).quantize(Decimal("0.01"))
|
nightly_amount = (amount / Decimal(max(night_count, 1))).quantize(Decimal("0.01"))
|
||||||
|
if nightly_amount <= cap:
|
||||||
|
continue
|
||||||
amount_measurement_lines.append(
|
amount_measurement_lines.append(
|
||||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元,"
|
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元,"
|
||||||
f"按 {night_count} 晚折算 {nightly_amount:.2f} 元/晚;"
|
f"按 {night_count} 晚折算 {nightly_amount:.2f} 元/晚;"
|
||||||
f"适用标准为 {band_label}{city_tier_label} {cap:.2f} 元/晚,"
|
f"适用标准为 {band_label}{city_tier_label} {cap:.2f} 元/晚,超出标准。"
|
||||||
f"{'超出标准' if nightly_amount > cap else '测算通过'}。"
|
|
||||||
)
|
)
|
||||||
if nightly_amount <= cap:
|
|
||||||
continue
|
|
||||||
|
|
||||||
basis = (
|
basis = (
|
||||||
f"依据《{standard_rule_name}》({standard_rule_version}),{band_label} 在{city_tier_label}"
|
f"依据《{standard_rule_name}》({standard_rule_version}),{band_label} 在{city_tier_label}"
|
||||||
@@ -200,12 +199,11 @@ class UserAgentReviewTravelPolicyMixin:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
amount_measurement_lines.append(
|
|
||||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
|
|
||||||
f"适用《{standard_rule_name}》{region_label}伙食补助标准 {standard_amount:.2f} 元/天,"
|
|
||||||
f"{'超出标准' if amount > standard_amount else '测算通过'}。"
|
|
||||||
)
|
|
||||||
if amount > standard_amount:
|
if amount > standard_amount:
|
||||||
|
amount_measurement_lines.append(
|
||||||
|
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
|
||||||
|
f"适用《{standard_rule_name}》{region_label}伙食补助标准 {standard_amount:.2f} 元/天,超出标准。"
|
||||||
|
)
|
||||||
append_once(
|
append_once(
|
||||||
f"travel-meal-allowance-over-limit-{card.index}",
|
f"travel-meal-allowance-over-limit-{card.index}",
|
||||||
UserAgentReviewRiskBrief(
|
UserAgentReviewRiskBrief(
|
||||||
@@ -251,13 +249,6 @@ class UserAgentReviewTravelPolicyMixin:
|
|||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if standard_amount is not None:
|
|
||||||
amount_measurement_lines.append(
|
|
||||||
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
|
|
||||||
f"适用《{scene_policy.rule_name}》{metric_label}标准 {standard_amount:.2f} 元,"
|
|
||||||
f"{'超出标准' if amount > standard_amount else '测算通过'}。"
|
|
||||||
)
|
|
||||||
|
|
||||||
amount_risk = self._evaluate_review_scene_amount(
|
amount_risk = self._evaluate_review_scene_amount(
|
||||||
amount=amount,
|
amount=amount,
|
||||||
limit_config=scene_limit,
|
limit_config=scene_limit,
|
||||||
@@ -265,6 +256,11 @@ class UserAgentReviewTravelPolicyMixin:
|
|||||||
)
|
)
|
||||||
if amount_risk is not None:
|
if amount_risk is not None:
|
||||||
severity, threshold = amount_risk
|
severity, threshold = amount_risk
|
||||||
|
if standard_amount is not None:
|
||||||
|
amount_measurement_lines.append(
|
||||||
|
f"{card.filename}:识别为{document_type_label},金额 {amount:.2f} 元;"
|
||||||
|
f"适用《{scene_policy.rule_name}》{metric_label}标准 {standard_amount:.2f} 元,超出标准。"
|
||||||
|
)
|
||||||
append_once(
|
append_once(
|
||||||
f"{scene_code}-amount-over-limit-{card.index}",
|
f"{scene_code}-amount-over-limit-{card.index}",
|
||||||
UserAgentReviewRiskBrief(
|
UserAgentReviewRiskBrief(
|
||||||
@@ -348,11 +344,11 @@ class UserAgentReviewTravelPolicyMixin:
|
|||||||
briefs.insert(
|
briefs.insert(
|
||||||
0,
|
0,
|
||||||
UserAgentReviewRiskBrief(
|
UserAgentReviewRiskBrief(
|
||||||
title="附件金额测算结果",
|
title="附件金额测算异常",
|
||||||
level="info",
|
level="warning",
|
||||||
content="系统已根据首轮上传附件识别金额,并匹配当前可执行的报销标准进行测算。",
|
content="系统根据首轮上传附件识别金额后,发现有需要进一步核查或说明的测算结果。",
|
||||||
detail=";".join(dict.fromkeys(amount_measurement_lines)),
|
detail=";".join(dict.fromkeys(amount_measurement_lines)),
|
||||||
suggestion="如测算结果超标,请补充超标说明、调整金额或更正票据类型后再继续。",
|
suggestion="请补充超标说明、调整金额或更正票据类型后再继续。",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
"uploaded_by": "admin",
|
"uploaded_by": "admin",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 1,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T07:04:12.388160+00:00",
|
"ingest_status_updated_at": "2026-05-22T15:12:34.420412+00:00",
|
||||||
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
|
"ingest_completed_at": "2026-05-17T10:01:33.272539+00:00",
|
||||||
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
"ingest_document_name": "远光《公司支出管理办法(2024)》.pdf",
|
||||||
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
"ingest_document_updated_at": "2026-05-17T09:28:28.999515+00:00",
|
||||||
@@ -36,12 +36,12 @@
|
|||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 1,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T07:03:57.851719+00:00",
|
"ingest_status_updated_at": "2026-05-22T15:12:34.423374+00:00",
|
||||||
"ingest_completed_at": "",
|
"ingest_completed_at": "2026-05-22T09:22:26.072669+00:00",
|
||||||
"ingest_document_name": "",
|
"ingest_document_name": "远光软件会计科目使用说明.xlsx",
|
||||||
"ingest_document_updated_at": "",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.328877+00:00",
|
||||||
"ingest_document_sha256": "",
|
"ingest_document_sha256": "",
|
||||||
"ingest_agent_run_id": ""
|
"ingest_agent_run_id": "run_8c1ab050c9734d96"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "b0277cd76034437997fbf5219662725a",
|
"id": "b0277cd76034437997fbf5219662725a",
|
||||||
@@ -57,12 +57,12 @@
|
|||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 1,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T07:03:57.861469+00:00",
|
"ingest_status_updated_at": "2026-05-22T15:12:34.426517+00:00",
|
||||||
"ingest_completed_at": "",
|
"ingest_completed_at": "2026-05-22T09:22:52.729264+00:00",
|
||||||
"ingest_document_name": "",
|
"ingest_document_name": "远光软件财务基础知识手册.docx",
|
||||||
"ingest_document_updated_at": "",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.011016+00:00",
|
||||||
"ingest_document_sha256": "",
|
"ingest_document_sha256": "",
|
||||||
"ingest_agent_run_id": ""
|
"ingest_agent_run_id": "run_8c1ab050c9734d96"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "23f56f159a3e4bc3b2338056544120dd",
|
"id": "23f56f159a3e4bc3b2338056544120dd",
|
||||||
@@ -78,12 +78,12 @@
|
|||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 1,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T07:03:57.870777+00:00",
|
"ingest_status_updated_at": "2026-05-22T15:12:34.429968+00:00",
|
||||||
"ingest_completed_at": "",
|
"ingest_completed_at": "2026-05-22T09:22:58.498888+00:00",
|
||||||
"ingest_document_name": "",
|
"ingest_document_name": "远光软件财务术语解释手册.docx",
|
||||||
"ingest_document_updated_at": "",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.352133+00:00",
|
||||||
"ingest_document_sha256": "",
|
"ingest_document_sha256": "",
|
||||||
"ingest_agent_run_id": ""
|
"ingest_agent_run_id": "run_8c1ab050c9734d96"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "09fbcae74d3b41e498a47e05b45262cb",
|
"id": "09fbcae74d3b41e498a47e05b45262cb",
|
||||||
@@ -99,12 +99,12 @@
|
|||||||
"uploaded_by": "系统导入",
|
"uploaded_by": "系统导入",
|
||||||
"version_number": 1,
|
"version_number": 1,
|
||||||
"ingest_status": 1,
|
"ingest_status": 1,
|
||||||
"ingest_status_updated_at": "2026-05-22T07:03:57.879239+00:00",
|
"ingest_status_updated_at": "2026-05-22T15:12:34.433141+00:00",
|
||||||
"ingest_completed_at": "",
|
"ingest_completed_at": "2026-05-22T09:24:19.530985+00:00",
|
||||||
"ingest_document_name": "",
|
"ingest_document_name": "远光软件高新技术企业税收优惠政策汇总.pdf",
|
||||||
"ingest_document_updated_at": "",
|
"ingest_document_updated_at": "2026-05-22T07:00:22.304623+00:00",
|
||||||
"ingest_document_sha256": "",
|
"ingest_document_sha256": "",
|
||||||
"ingest_agent_run_id": ""
|
"ingest_agent_run_id": "run_8c1ab050c9734d96"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "5fb3c63fbfe244a280cf3316a20150cd",
|
"id": "5fb3c63fbfe244a280cf3316a20150cd",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -63,5 +63,75 @@
|
|||||||
"original_doc_id": "a8f8465df08e455ebe133351721d49f8",
|
"original_doc_id": "a8f8465df08e455ebe133351721d49f8",
|
||||||
"original_track_id": "insert_20260519_155957_88c49850"
|
"original_track_id": "insert_20260519_155957_88c49850"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"c7601043d9944ef2bcf4d3f67ed253f7": {
|
||||||
|
"status": "processed",
|
||||||
|
"chunks_count": 2,
|
||||||
|
"chunks_list": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"content_summary": "# Excel 工作簿:远光软件会计科目使用说明.xlsx\n\n## 工作表 1:会计科目说明\n\n| 远光软件股份有限公司常用会计科目使用说明 | 列2 | 列3 | 列4 | 列5 |\n| --- | --- | --- | --- | --- |\n| 科目编码 | 科目名称 | 科目类别 | 使用说明 | 备注 |\n| 1001 | 库存现金 | 资产类 | 核算公司库存现金 | 日清月结 |\n| 1002 | 银行存款 | 资产类 | 核算存入银行的各项存款 | 按开户行明细 |\n| 112...",
|
||||||
|
"content_length": 2808,
|
||||||
|
"created_at": "2026-05-22T09:21:01.230400+00:00",
|
||||||
|
"updated_at": "2026-05-22T09:22:25.565409+00:00",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx",
|
||||||
|
"track_id": "insert_20260522_092101_e754a15e",
|
||||||
|
"metadata": {
|
||||||
|
"processing_start_time": 1779441661,
|
||||||
|
"processing_end_time": 1779441745
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"b0277cd76034437997fbf5219662725a": {
|
||||||
|
"status": "processed",
|
||||||
|
"chunks_count": 1,
|
||||||
|
"chunks_list": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"content_summary": "远光软件股份有限公司\n财务基础知识手册\n第一部分 会计基础知识\n一、会计要素\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。\n会计恒等式:资产 = 负债 + 所有者权益\n二、常用会计科目\n科目类别\n科目名称\n说明\n资产类\n库存现金\n公司持有的现金\n资产类\n银行存款\n存放在银行的资金\n资产类\n应收账款\n因销售商品或提供劳务应收的款项\n资产类\n固定资产\n使用年限超过一年的有形资产\n负债类\n应付账款\n因购买商品或接受劳务应付的款项\n负债类\n应交税费\n应缴纳的各种税费\n负债类\n应付职工薪酬\n...",
|
||||||
|
"content_length": 1082,
|
||||||
|
"created_at": "2026-05-22T09:22:31.538281+00:00",
|
||||||
|
"updated_at": "2026-05-22T09:22:52.110824+00:00",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx",
|
||||||
|
"track_id": "insert_20260522_092231_e1b9d415",
|
||||||
|
"metadata": {
|
||||||
|
"processing_start_time": 1779441751,
|
||||||
|
"processing_end_time": 1779441772
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"23f56f159a3e4bc3b2338056544120dd": {
|
||||||
|
"status": "processed",
|
||||||
|
"chunks_count": 1,
|
||||||
|
"chunks_list": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"content_summary": "远光软件股份有限公司\n财务术语解释手册\n权责发生制\n以权利和责任的发生来决定收入和费用归属期的会计基础。即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用。\n收付实现制\n以现金收到或付出为标准来记录收入的实现和费用的发生。即凡是当期收到和支付的现金,都作为当期的收入和费用。\n固定资产折旧\n固定资产在使用过程中因磨损而逐渐转移的价值。公司采用年限平均法计提折旧。\n摊销\n将无形资产或长期待摊费用按照规定期限分期计入当期损益的过程。\n增值税进项税额\n企业购...",
|
||||||
|
"content_length": 1040,
|
||||||
|
"created_at": "2026-05-22T09:22:44.268551+00:00",
|
||||||
|
"updated_at": "2026-05-22T09:23:11.334499+00:00",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx",
|
||||||
|
"track_id": "insert_20260522_092244_2888d301",
|
||||||
|
"metadata": {
|
||||||
|
"processing_start_time": 1779441764,
|
||||||
|
"processing_end_time": 1779441791
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"09fbcae74d3b41e498a47e05b45262cb": {
|
||||||
|
"status": "processed",
|
||||||
|
"chunks_count": 2,
|
||||||
|
"chunks_list": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e",
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"content_summary": "远光软件股份有限公司高新技术企业税收优惠政策汇总\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、企业所得税优惠\n\n1. 高新技术企业减按15%税率征收企业所得税\n\n- 条件:取得高新技术企业证书且在有效期内\n\n- 申请:向主管税务机关备案\n\n2. 技术转让所得优惠\n\n- 符合条件的技术转让所得500万元以下免征...",
|
||||||
|
"content_length": 1772,
|
||||||
|
"created_at": "2026-05-22T09:23:17.399741+00:00",
|
||||||
|
"updated_at": "2026-05-22T09:24:18.933073+00:00",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf",
|
||||||
|
"track_id": "insert_20260522_092317_ca603a9e",
|
||||||
|
"metadata": {
|
||||||
|
"processing_start_time": 1779441797,
|
||||||
|
"processing_end_time": 1779441858
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,11 +3,15 @@
|
|||||||
"chunk_ids": [
|
"chunk_ids": [
|
||||||
"chunk-aa5435156b829944c173fa1d2d7a93d4",
|
"chunk-aa5435156b829944c173fa1d2d7a93d4",
|
||||||
"chunk-18d968b78afe916b419c1b5973421ebe",
|
"chunk-18d968b78afe916b419c1b5973421ebe",
|
||||||
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263"
|
"chunk-dd87aa5bc62cc9587ecb4c26d35a5263",
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1",
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
],
|
],
|
||||||
"count": 3,
|
"count": 8,
|
||||||
"create_time": 1779011991,
|
"update_time": 1779441830,
|
||||||
"update_time": 1779011991,
|
|
||||||
"_id": "远光软件股份有限公司"
|
"_id": "远光软件股份有限公司"
|
||||||
},
|
},
|
||||||
"第一章总则": {
|
"第一章总则": {
|
||||||
@@ -3359,5 +3363,913 @@
|
|||||||
"create_time": 1779379005,
|
"create_time": 1779379005,
|
||||||
"update_time": 1779379005,
|
"update_time": 1779379005,
|
||||||
"_id": "Warning Icon"
|
"_id": "Warning Icon"
|
||||||
|
},
|
||||||
|
"Excel工作簿:远光软件会计科目使用说明.xlsx": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "Excel工作簿:远光软件会计科目使用说明.xlsx"
|
||||||
|
},
|
||||||
|
"会计科目说明": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "会计科目说明"
|
||||||
|
},
|
||||||
|
"科目编码": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "科目编码"
|
||||||
|
},
|
||||||
|
"科目名称": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "科目名称"
|
||||||
|
},
|
||||||
|
"科目类别": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "科目类别"
|
||||||
|
},
|
||||||
|
"使用说明": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "使用说明"
|
||||||
|
},
|
||||||
|
"库存现金": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "库存现金"
|
||||||
|
},
|
||||||
|
"银行存款": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "银行存款"
|
||||||
|
},
|
||||||
|
"应收账款": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "应收账款"
|
||||||
|
},
|
||||||
|
"其他应收款": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "其他应收款"
|
||||||
|
},
|
||||||
|
"原材料": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "原材料"
|
||||||
|
},
|
||||||
|
"固定资产": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "固定资产"
|
||||||
|
},
|
||||||
|
"累计折旧": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "累计折旧"
|
||||||
|
},
|
||||||
|
"应付账款": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "应付账款"
|
||||||
|
},
|
||||||
|
"应交税费": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "应交税费"
|
||||||
|
},
|
||||||
|
"应付职工薪酬": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "应付职工薪酬"
|
||||||
|
},
|
||||||
|
"主营业务收入": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "主营业务收入"
|
||||||
|
},
|
||||||
|
"主营业务成本": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "主营业务成本"
|
||||||
|
},
|
||||||
|
"管理费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "管理费用"
|
||||||
|
},
|
||||||
|
"销售费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "销售费用"
|
||||||
|
},
|
||||||
|
"财务费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "财务费用"
|
||||||
|
},
|
||||||
|
"所得税费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "所得税费用"
|
||||||
|
},
|
||||||
|
"备注": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"create_time": 1779441741,
|
||||||
|
"update_time": 1779441741,
|
||||||
|
"_id": "备注"
|
||||||
|
},
|
||||||
|
"资产类": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "资产类"
|
||||||
|
},
|
||||||
|
"负债类": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "负债类"
|
||||||
|
},
|
||||||
|
"损益类": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d",
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "损益类"
|
||||||
|
},
|
||||||
|
"远光软件会计科目使用说明.xlsx": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "远光软件会计科目使用说明.xlsx"
|
||||||
|
},
|
||||||
|
"2221应交税费": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "2221应交税费"
|
||||||
|
},
|
||||||
|
"2211应付职工薪酬": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "2211应付职工薪酬"
|
||||||
|
},
|
||||||
|
"6001主营业务收入": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "6001主营业务收入"
|
||||||
|
},
|
||||||
|
"6401主营业务成本": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "6401主营业务成本"
|
||||||
|
},
|
||||||
|
"6601管理费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "6601管理费用"
|
||||||
|
},
|
||||||
|
"6602销售费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "6602销售费用"
|
||||||
|
},
|
||||||
|
"6603财务费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "6603财务费用"
|
||||||
|
},
|
||||||
|
"6801所得税费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "6801所得税费用"
|
||||||
|
},
|
||||||
|
"1001库存现金": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "1001库存现金"
|
||||||
|
},
|
||||||
|
"1002银行存款": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "1002银行存款"
|
||||||
|
},
|
||||||
|
"1122应收账款": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "1122应收账款"
|
||||||
|
},
|
||||||
|
"1221其他应收款": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "1221其他应收款"
|
||||||
|
},
|
||||||
|
"财务基础知识手册": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441755,
|
||||||
|
"update_time": 1779441755,
|
||||||
|
"_id": "财务基础知识手册"
|
||||||
|
},
|
||||||
|
"会计要素": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441755,
|
||||||
|
"update_time": 1779441755,
|
||||||
|
"_id": "会计要素"
|
||||||
|
},
|
||||||
|
"资产": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441755,
|
||||||
|
"update_time": 1779441755,
|
||||||
|
"_id": "资产"
|
||||||
|
},
|
||||||
|
"负债": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441755,
|
||||||
|
"update_time": 1779441755,
|
||||||
|
"_id": "负债"
|
||||||
|
},
|
||||||
|
"所有者权益": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441755,
|
||||||
|
"update_time": 1779441755,
|
||||||
|
"_id": "所有者权益"
|
||||||
|
},
|
||||||
|
"收入": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441755,
|
||||||
|
"update_time": 1779441755,
|
||||||
|
"_id": "收入"
|
||||||
|
},
|
||||||
|
"费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441755,
|
||||||
|
"update_time": 1779441755,
|
||||||
|
"_id": "费用"
|
||||||
|
},
|
||||||
|
"利润": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441756,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "利润"
|
||||||
|
},
|
||||||
|
"会计恒等式": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441756,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "会计恒等式"
|
||||||
|
},
|
||||||
|
"增值税": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "增值税"
|
||||||
|
},
|
||||||
|
"企业所得税": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43",
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 2,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "企业所得税"
|
||||||
|
},
|
||||||
|
"个人所得税": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441756,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "个人所得税"
|
||||||
|
},
|
||||||
|
"印花税": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441756,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "印花税"
|
||||||
|
},
|
||||||
|
"三大财务报表": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441756,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "三大财务报表"
|
||||||
|
},
|
||||||
|
"资产负债表": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441756,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "资产负债表"
|
||||||
|
},
|
||||||
|
"利润表": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441756,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "利润表"
|
||||||
|
},
|
||||||
|
"现金流量表": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441756,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "现金流量表"
|
||||||
|
},
|
||||||
|
"会计基础知识": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441756,
|
||||||
|
"update_time": 1779441756,
|
||||||
|
"_id": "会计基础知识"
|
||||||
|
},
|
||||||
|
"税务基础知识": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441757,
|
||||||
|
"update_time": 1779441757,
|
||||||
|
"_id": "税务基础知识"
|
||||||
|
},
|
||||||
|
"财务报表解读": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441757,
|
||||||
|
"update_time": 1779441757,
|
||||||
|
"_id": "财务报表解读"
|
||||||
|
},
|
||||||
|
"财务术语解释手册": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "财务术语解释手册"
|
||||||
|
},
|
||||||
|
"权责发生制": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "权责发生制"
|
||||||
|
},
|
||||||
|
"收付实现制": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "收付实现制"
|
||||||
|
},
|
||||||
|
"固定资产折旧": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "固定资产折旧"
|
||||||
|
},
|
||||||
|
"摊销": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "摊销"
|
||||||
|
},
|
||||||
|
"增值税进项税额": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "增值税进项税额"
|
||||||
|
},
|
||||||
|
"增值税销项税额": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "增值税销项税额"
|
||||||
|
},
|
||||||
|
"预算": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "预算"
|
||||||
|
},
|
||||||
|
"现金流": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "现金流"
|
||||||
|
},
|
||||||
|
"毛利率": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "毛利率"
|
||||||
|
},
|
||||||
|
"净资产收益率": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "净资产收益率"
|
||||||
|
},
|
||||||
|
"成本中心": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "成本中心"
|
||||||
|
},
|
||||||
|
"利润中心": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "利润中心"
|
||||||
|
},
|
||||||
|
"年限平均法": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "年限平均法"
|
||||||
|
},
|
||||||
|
"毛利润": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441774,
|
||||||
|
"update_time": 1779441774,
|
||||||
|
"_id": "毛利润"
|
||||||
|
},
|
||||||
|
"营业收入": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441775,
|
||||||
|
"update_time": 1779441775,
|
||||||
|
"_id": "营业收入"
|
||||||
|
},
|
||||||
|
"营业成本": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441775,
|
||||||
|
"update_time": 1779441775,
|
||||||
|
"_id": "营业成本"
|
||||||
|
},
|
||||||
|
"净利润": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441775,
|
||||||
|
"update_time": 1779441775,
|
||||||
|
"_id": "净利润"
|
||||||
|
},
|
||||||
|
"股东权益": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441775,
|
||||||
|
"update_time": 1779441775,
|
||||||
|
"_id": "股东权益"
|
||||||
|
},
|
||||||
|
"Training Expenses": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441843,
|
||||||
|
"update_time": 1779441843,
|
||||||
|
"_id": "Training Expenses"
|
||||||
|
},
|
||||||
|
"Corporate Income Tax": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441843,
|
||||||
|
"update_time": 1779441843,
|
||||||
|
"_id": "Corporate Income Tax"
|
||||||
|
},
|
||||||
|
"Venture Capital Deduction": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441843,
|
||||||
|
"update_time": 1779441843,
|
||||||
|
"_id": "Venture Capital Deduction"
|
||||||
|
},
|
||||||
|
"Small And Medium High-Tech Enterprises": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441843,
|
||||||
|
"update_time": 1779441843,
|
||||||
|
"_id": "Small And Medium High-Tech Enterprises"
|
||||||
|
},
|
||||||
|
"Taxable Income": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441843,
|
||||||
|
"update_time": 1779441843,
|
||||||
|
"_id": "Taxable Income"
|
||||||
|
},
|
||||||
|
"Preferential Tax Policies": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441843,
|
||||||
|
"update_time": 1779441843,
|
||||||
|
"_id": "Preferential Tax Policies"
|
||||||
|
},
|
||||||
|
"Investment Amount": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441843,
|
||||||
|
"update_time": 1779441843,
|
||||||
|
"_id": "Investment Amount"
|
||||||
|
},
|
||||||
|
"70% Deduction Rate": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441843,
|
||||||
|
"update_time": 1779441843,
|
||||||
|
"_id": "70% Deduction Rate"
|
||||||
|
},
|
||||||
|
"Other Preferential Policies": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "Other Preferential Policies"
|
||||||
|
},
|
||||||
|
"主管税务机关": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "主管税务机关"
|
||||||
|
},
|
||||||
|
"高新技术企业证书": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "高新技术企业证书"
|
||||||
|
},
|
||||||
|
"高新技术企业减按15%税率征收企业所得税": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "高新技术企业减按15%税率征收企业所得税"
|
||||||
|
},
|
||||||
|
"技术转让所得优惠": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "技术转让所得优惠"
|
||||||
|
},
|
||||||
|
"软件产品增值税即征即退": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "软件产品增值税即征即退"
|
||||||
|
},
|
||||||
|
"技术服务免征增值税": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "技术服务免征增值税"
|
||||||
|
},
|
||||||
|
"研发费用加计扣除": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "研发费用加计扣除"
|
||||||
|
},
|
||||||
|
"固定资产加速折旧": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "固定资产加速折旧"
|
||||||
|
},
|
||||||
|
"软件企业职工培训费用": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "软件企业职工培训费用"
|
||||||
|
},
|
||||||
|
"创业投资抵扣": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "创业投资抵扣"
|
||||||
|
},
|
||||||
|
"中小高新技术企业": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441830,
|
||||||
|
"update_time": 1779441830,
|
||||||
|
"_id": "中小高新技术企业"
|
||||||
|
},
|
||||||
|
"13%税率": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441831,
|
||||||
|
"update_time": 1779441831,
|
||||||
|
"_id": "13%税率"
|
||||||
|
},
|
||||||
|
"3%实际税负": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441831,
|
||||||
|
"update_time": 1779441831,
|
||||||
|
"_id": "3%实际税负"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -383,5 +383,153 @@
|
|||||||
"create_time": 1779379018,
|
"create_time": 1779379018,
|
||||||
"update_time": 1779379018,
|
"update_time": 1779379018,
|
||||||
"_id": "a8f8465df08e455ebe133351721d49f8"
|
"_id": "a8f8465df08e455ebe133351721d49f8"
|
||||||
|
},
|
||||||
|
"c7601043d9944ef2bcf4d3f67ed253f7": {
|
||||||
|
"entity_names": [
|
||||||
|
"损益类",
|
||||||
|
"6603财务费用",
|
||||||
|
"固定资产",
|
||||||
|
"银行存款",
|
||||||
|
"6601管理费用",
|
||||||
|
"资产类",
|
||||||
|
"1122应收账款",
|
||||||
|
"会计科目说明",
|
||||||
|
"使用说明",
|
||||||
|
"科目名称",
|
||||||
|
"财务费用",
|
||||||
|
"累计折旧",
|
||||||
|
"库存现金",
|
||||||
|
"6602销售费用",
|
||||||
|
"远光软件会计科目使用说明.xlsx",
|
||||||
|
"主营业务成本",
|
||||||
|
"1001库存现金",
|
||||||
|
"应付账款",
|
||||||
|
"1221其他应收款",
|
||||||
|
"6001主营业务收入",
|
||||||
|
"6801所得税费用",
|
||||||
|
"备注",
|
||||||
|
"科目类别",
|
||||||
|
"所得税费用",
|
||||||
|
"Excel工作簿:远光软件会计科目使用说明.xlsx",
|
||||||
|
"负债类",
|
||||||
|
"2221应交税费",
|
||||||
|
"6401主营业务成本",
|
||||||
|
"应收账款",
|
||||||
|
"科目编码",
|
||||||
|
"应交税费",
|
||||||
|
"其他应收款",
|
||||||
|
"主营业务收入",
|
||||||
|
"原材料",
|
||||||
|
"管理费用",
|
||||||
|
"销售费用",
|
||||||
|
"应付职工薪酬",
|
||||||
|
"2211应付职工薪酬",
|
||||||
|
"1002银行存款",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
"count": 40,
|
||||||
|
"create_time": 1779441745,
|
||||||
|
"update_time": 1779441745,
|
||||||
|
"_id": "c7601043d9944ef2bcf4d3f67ed253f7"
|
||||||
|
},
|
||||||
|
"b0277cd76034437997fbf5219662725a": {
|
||||||
|
"entity_names": [
|
||||||
|
"固定资产",
|
||||||
|
"财务报表解读",
|
||||||
|
"银行存款",
|
||||||
|
"收入",
|
||||||
|
"负债",
|
||||||
|
"现金流量表",
|
||||||
|
"企业所得税",
|
||||||
|
"三大财务报表",
|
||||||
|
"会计恒等式",
|
||||||
|
"库存现金",
|
||||||
|
"所有者权益",
|
||||||
|
"费用",
|
||||||
|
"财务基础知识手册",
|
||||||
|
"应付账款",
|
||||||
|
"利润表",
|
||||||
|
"会计基础知识",
|
||||||
|
"应收账款",
|
||||||
|
"应交税费",
|
||||||
|
"主营业务收入",
|
||||||
|
"资产",
|
||||||
|
"管理费用",
|
||||||
|
"税务基础知识",
|
||||||
|
"应付职工薪酬",
|
||||||
|
"销售费用",
|
||||||
|
"印花税",
|
||||||
|
"资产负债表",
|
||||||
|
"个人所得税",
|
||||||
|
"会计要素",
|
||||||
|
"远光软件股份有限公司",
|
||||||
|
"利润",
|
||||||
|
"增值税"
|
||||||
|
],
|
||||||
|
"count": 31,
|
||||||
|
"create_time": 1779441772,
|
||||||
|
"update_time": 1779441772,
|
||||||
|
"_id": "b0277cd76034437997fbf5219662725a"
|
||||||
|
},
|
||||||
|
"23f56f159a3e4bc3b2338056544120dd": {
|
||||||
|
"entity_names": [
|
||||||
|
"净利润",
|
||||||
|
"财务术语解释手册",
|
||||||
|
"年限平均法",
|
||||||
|
"毛利润",
|
||||||
|
"预算",
|
||||||
|
"权责发生制",
|
||||||
|
"成本中心",
|
||||||
|
"摊销",
|
||||||
|
"营业收入",
|
||||||
|
"增值税进项税额",
|
||||||
|
"收付实现制",
|
||||||
|
"营业成本",
|
||||||
|
"增值税销项税额",
|
||||||
|
"净资产收益率",
|
||||||
|
"利润中心",
|
||||||
|
"固定资产折旧",
|
||||||
|
"股东权益",
|
||||||
|
"现金流",
|
||||||
|
"毛利率",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
"count": 20,
|
||||||
|
"create_time": 1779441791,
|
||||||
|
"update_time": 1779441791,
|
||||||
|
"_id": "23f56f159a3e4bc3b2338056544120dd"
|
||||||
|
},
|
||||||
|
"09fbcae74d3b41e498a47e05b45262cb": {
|
||||||
|
"entity_names": [
|
||||||
|
"Other Preferential Policies",
|
||||||
|
"3%实际税负",
|
||||||
|
"Preferential Tax Policies",
|
||||||
|
"研发费用加计扣除",
|
||||||
|
"Corporate Income Tax",
|
||||||
|
"中小高新技术企业",
|
||||||
|
"企业所得税",
|
||||||
|
"Taxable Income",
|
||||||
|
"主管税务机关",
|
||||||
|
"技术转让所得优惠",
|
||||||
|
"固定资产加速折旧",
|
||||||
|
"高新技术企业减按15%税率征收企业所得税",
|
||||||
|
"技术服务免征增值税",
|
||||||
|
"软件产品增值税即征即退",
|
||||||
|
"13%税率",
|
||||||
|
"软件企业职工培训费用",
|
||||||
|
"创业投资抵扣",
|
||||||
|
"Small And Medium High-Tech Enterprises",
|
||||||
|
"Training Expenses",
|
||||||
|
"高新技术企业证书",
|
||||||
|
"Venture Capital Deduction",
|
||||||
|
"70% Deduction Rate",
|
||||||
|
"Investment Amount",
|
||||||
|
"远光软件股份有限公司",
|
||||||
|
"增值税"
|
||||||
|
],
|
||||||
|
"count": 25,
|
||||||
|
"create_time": 1779441858,
|
||||||
|
"update_time": 1779441858,
|
||||||
|
"_id": "09fbcae74d3b41e498a47e05b45262cb"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,5 +274,205 @@
|
|||||||
"create_time": 1779379018,
|
"create_time": 1779379018,
|
||||||
"update_time": 1779379018,
|
"update_time": 1779379018,
|
||||||
"_id": "a8f8465df08e455ebe133351721d49f8"
|
"_id": "a8f8465df08e455ebe133351721d49f8"
|
||||||
|
},
|
||||||
|
"c7601043d9944ef2bcf4d3f67ed253f7": {
|
||||||
|
"relation_pairs": [
|
||||||
|
[
|
||||||
|
"2221应交税费",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"会计科目说明",
|
||||||
|
"备注"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"会计科目说明",
|
||||||
|
"科目类别"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"会计科目说明",
|
||||||
|
"科目名称"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Excel工作簿:远光软件会计科目使用说明.xlsx",
|
||||||
|
"会计科目说明"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"库存现金",
|
||||||
|
"资产类"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"6001主营业务收入",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"1002银行存款",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"1221其他应收款",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"资产类",
|
||||||
|
"银行存款"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"6401主营业务成本",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Excel工作簿:远光软件会计科目使用说明.xlsx",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"会计科目说明",
|
||||||
|
"科目编码"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"1001库存现金",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"会计科目说明",
|
||||||
|
"使用说明"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"远光软件会计科目使用说明.xlsx",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"2211应付职工薪酬",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"1122应收账款",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"count": 18,
|
||||||
|
"create_time": 1779441745,
|
||||||
|
"update_time": 1779441745,
|
||||||
|
"_id": "c7601043d9944ef2bcf4d3f67ed253f7"
|
||||||
|
},
|
||||||
|
"b0277cd76034437997fbf5219662725a": {
|
||||||
|
"relation_pairs": [
|
||||||
|
[
|
||||||
|
"会计要素",
|
||||||
|
"资产"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"财务基础知识手册",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"财务基础知识手册",
|
||||||
|
"财务报表解读"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"税务基础知识",
|
||||||
|
"财务基础知识手册"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"会计基础知识",
|
||||||
|
"财务基础知识手册"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"count": 5,
|
||||||
|
"create_time": 1779441772,
|
||||||
|
"update_time": 1779441772,
|
||||||
|
"_id": "b0277cd76034437997fbf5219662725a"
|
||||||
|
},
|
||||||
|
"23f56f159a3e4bc3b2338056544120dd": {
|
||||||
|
"relation_pairs": [
|
||||||
|
[
|
||||||
|
"毛利率",
|
||||||
|
"营业成本"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"摊销",
|
||||||
|
"财务术语解释手册"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"年限平均法",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"增值税进项税额",
|
||||||
|
"财务术语解释手册"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"固定资产折旧",
|
||||||
|
"财务术语解释手册"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"净利润",
|
||||||
|
"净资产收益率"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"收付实现制",
|
||||||
|
"财务术语解释手册"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"权责发生制",
|
||||||
|
"财务术语解释手册"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"毛利润",
|
||||||
|
"毛利率"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"毛利率",
|
||||||
|
"营业收入"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"财务术语解释手册",
|
||||||
|
"远光软件股份有限公司"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"净资产收益率",
|
||||||
|
"股东权益"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"固定资产折旧",
|
||||||
|
"年限平均法"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"count": 13,
|
||||||
|
"create_time": 1779441791,
|
||||||
|
"update_time": 1779441791,
|
||||||
|
"_id": "23f56f159a3e4bc3b2338056544120dd"
|
||||||
|
},
|
||||||
|
"09fbcae74d3b41e498a47e05b45262cb": {
|
||||||
|
"relation_pairs": [
|
||||||
|
[
|
||||||
|
"Corporate Income Tax",
|
||||||
|
"Training Expenses"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Small And Medium High-Tech Enterprises",
|
||||||
|
"Venture Capital Deduction"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Taxable Income",
|
||||||
|
"Venture Capital Deduction"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"Preferential Tax Policies",
|
||||||
|
"Venture Capital Deduction"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"70% Deduction Rate",
|
||||||
|
"Venture Capital Deduction"
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"3%实际税负",
|
||||||
|
"软件产品增值税即征即退"
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"count": 6,
|
||||||
|
"create_time": 1779441858,
|
||||||
|
"update_time": 1779441858,
|
||||||
|
"_id": "09fbcae74d3b41e498a47e05b45262cb"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -583,5 +583,383 @@
|
|||||||
"create_time": 1779379017,
|
"create_time": 1779379017,
|
||||||
"update_time": 1779379017,
|
"update_time": 1779379017,
|
||||||
"_id": "Receipt-Free Reimbursement<SEP>Submit Reimbursement"
|
"_id": "Receipt-Free Reimbursement<SEP>Submit Reimbursement"
|
||||||
|
},
|
||||||
|
"Excel工作簿:远光软件会计科目使用说明.xlsx<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "Excel工作簿:远光软件会计科目使用说明.xlsx<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"会计科目说明<SEP>科目编码": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "会计科目说明<SEP>科目编码"
|
||||||
|
},
|
||||||
|
"库存现金<SEP>资产类": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "库存现金<SEP>资产类"
|
||||||
|
},
|
||||||
|
"会计科目说明<SEP>科目名称": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "会计科目说明<SEP>科目名称"
|
||||||
|
},
|
||||||
|
"资产类<SEP>银行存款": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "资产类<SEP>银行存款"
|
||||||
|
},
|
||||||
|
"远光软件会计科目使用说明.xlsx<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441742,
|
||||||
|
"update_time": 1779441742,
|
||||||
|
"_id": "远光软件会计科目使用说明.xlsx<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"2221应交税费<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441743,
|
||||||
|
"update_time": 1779441743,
|
||||||
|
"_id": "2221应交税费<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"会计科目说明<SEP>科目类别": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441743,
|
||||||
|
"update_time": 1779441743,
|
||||||
|
"_id": "会计科目说明<SEP>科目类别"
|
||||||
|
},
|
||||||
|
"会计科目说明<SEP>使用说明": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441743,
|
||||||
|
"update_time": 1779441743,
|
||||||
|
"_id": "会计科目说明<SEP>使用说明"
|
||||||
|
},
|
||||||
|
"2211应付职工薪酬<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441743,
|
||||||
|
"update_time": 1779441743,
|
||||||
|
"_id": "2211应付职工薪酬<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"会计科目说明<SEP>备注": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441743,
|
||||||
|
"update_time": 1779441743,
|
||||||
|
"_id": "会计科目说明<SEP>备注"
|
||||||
|
},
|
||||||
|
"6001主营业务收入<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441743,
|
||||||
|
"update_time": 1779441743,
|
||||||
|
"_id": "6001主营业务收入<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"Excel工作簿:远光软件会计科目使用说明.xlsx<SEP>会计科目说明": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441744,
|
||||||
|
"update_time": 1779441744,
|
||||||
|
"_id": "Excel工作簿:远光软件会计科目使用说明.xlsx<SEP>会计科目说明"
|
||||||
|
},
|
||||||
|
"6401主营业务成本<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441744,
|
||||||
|
"update_time": 1779441744,
|
||||||
|
"_id": "6401主营业务成本<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"1001库存现金<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441744,
|
||||||
|
"update_time": 1779441744,
|
||||||
|
"_id": "1001库存现金<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"1002银行存款<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441744,
|
||||||
|
"update_time": 1779441744,
|
||||||
|
"_id": "1002银行存款<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"1122应收账款<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441745,
|
||||||
|
"update_time": 1779441745,
|
||||||
|
"_id": "1122应收账款<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"1221其他应收款<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441745,
|
||||||
|
"update_time": 1779441745,
|
||||||
|
"_id": "1221其他应收款<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"财务基础知识手册<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441770,
|
||||||
|
"update_time": 1779441770,
|
||||||
|
"_id": "财务基础知识手册<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"会计要素<SEP>资产": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441770,
|
||||||
|
"update_time": 1779441770,
|
||||||
|
"_id": "会计要素<SEP>资产"
|
||||||
|
},
|
||||||
|
"会计基础知识<SEP>财务基础知识手册": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441771,
|
||||||
|
"update_time": 1779441771,
|
||||||
|
"_id": "会计基础知识<SEP>财务基础知识手册"
|
||||||
|
},
|
||||||
|
"税务基础知识<SEP>财务基础知识手册": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441771,
|
||||||
|
"update_time": 1779441771,
|
||||||
|
"_id": "税务基础知识<SEP>财务基础知识手册"
|
||||||
|
},
|
||||||
|
"财务基础知识手册<SEP>财务报表解读": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441771,
|
||||||
|
"update_time": 1779441771,
|
||||||
|
"_id": "财务基础知识手册<SEP>财务报表解读"
|
||||||
|
},
|
||||||
|
"财务术语解释手册<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441775,
|
||||||
|
"update_time": 1779441775,
|
||||||
|
"_id": "财务术语解释手册<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"年限平均法<SEP>远光软件股份有限公司": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441776,
|
||||||
|
"update_time": 1779441776,
|
||||||
|
"_id": "年限平均法<SEP>远光软件股份有限公司"
|
||||||
|
},
|
||||||
|
"权责发生制<SEP>财务术语解释手册": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441776,
|
||||||
|
"update_time": 1779441776,
|
||||||
|
"_id": "权责发生制<SEP>财务术语解释手册"
|
||||||
|
},
|
||||||
|
"毛利润<SEP>毛利率": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441776,
|
||||||
|
"update_time": 1779441776,
|
||||||
|
"_id": "毛利润<SEP>毛利率"
|
||||||
|
},
|
||||||
|
"收付实现制<SEP>财务术语解释手册": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441776,
|
||||||
|
"update_time": 1779441776,
|
||||||
|
"_id": "收付实现制<SEP>财务术语解释手册"
|
||||||
|
},
|
||||||
|
"毛利率<SEP>营业收入": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441776,
|
||||||
|
"update_time": 1779441776,
|
||||||
|
"_id": "毛利率<SEP>营业收入"
|
||||||
|
},
|
||||||
|
"净利润<SEP>净资产收益率": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441776,
|
||||||
|
"update_time": 1779441776,
|
||||||
|
"_id": "净利润<SEP>净资产收益率"
|
||||||
|
},
|
||||||
|
"固定资产折旧<SEP>财务术语解释手册": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441776,
|
||||||
|
"update_time": 1779441776,
|
||||||
|
"_id": "固定资产折旧<SEP>财务术语解释手册"
|
||||||
|
},
|
||||||
|
"毛利率<SEP>营业成本": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441776,
|
||||||
|
"update_time": 1779441776,
|
||||||
|
"_id": "毛利率<SEP>营业成本"
|
||||||
|
},
|
||||||
|
"净资产收益率<SEP>股东权益": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441776,
|
||||||
|
"update_time": 1779441776,
|
||||||
|
"_id": "净资产收益率<SEP>股东权益"
|
||||||
|
},
|
||||||
|
"摊销<SEP>财务术语解释手册": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441790,
|
||||||
|
"update_time": 1779441790,
|
||||||
|
"_id": "摊销<SEP>财务术语解释手册"
|
||||||
|
},
|
||||||
|
"固定资产折旧<SEP>年限平均法": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441790,
|
||||||
|
"update_time": 1779441790,
|
||||||
|
"_id": "固定资产折旧<SEP>年限平均法"
|
||||||
|
},
|
||||||
|
"增值税进项税额<SEP>财务术语解释手册": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441791,
|
||||||
|
"update_time": 1779441791,
|
||||||
|
"_id": "增值税进项税额<SEP>财务术语解释手册"
|
||||||
|
},
|
||||||
|
"Corporate Income Tax<SEP>Training Expenses": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441857,
|
||||||
|
"update_time": 1779441857,
|
||||||
|
"_id": "Corporate Income Tax<SEP>Training Expenses"
|
||||||
|
},
|
||||||
|
"Taxable Income<SEP>Venture Capital Deduction": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441857,
|
||||||
|
"update_time": 1779441857,
|
||||||
|
"_id": "Taxable Income<SEP>Venture Capital Deduction"
|
||||||
|
},
|
||||||
|
"3%实际税负<SEP>软件产品增值税即征即退": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441857,
|
||||||
|
"update_time": 1779441857,
|
||||||
|
"_id": "3%实际税负<SEP>软件产品增值税即征即退"
|
||||||
|
},
|
||||||
|
"Small And Medium High-Tech Enterprises<SEP>Venture Capital Deduction": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441858,
|
||||||
|
"update_time": 1779441858,
|
||||||
|
"_id": "Small And Medium High-Tech Enterprises<SEP>Venture Capital Deduction"
|
||||||
|
},
|
||||||
|
"Preferential Tax Policies<SEP>Venture Capital Deduction": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441858,
|
||||||
|
"update_time": 1779441858,
|
||||||
|
"_id": "Preferential Tax Policies<SEP>Venture Capital Deduction"
|
||||||
|
},
|
||||||
|
"70% Deduction Rate<SEP>Venture Capital Deduction": {
|
||||||
|
"chunk_ids": [
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
|
],
|
||||||
|
"count": 1,
|
||||||
|
"create_time": 1779441858,
|
||||||
|
"update_time": 1779441858,
|
||||||
|
"_id": "70% Deduction Rate<SEP>Venture Capital Deduction"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -174,5 +174,71 @@
|
|||||||
"create_time": 1779378923,
|
"create_time": 1779378923,
|
||||||
"update_time": 1779378923,
|
"update_time": 1779378923,
|
||||||
"_id": "chunk-2224d777c0b72d0b2dab622c79096c2c"
|
"_id": "chunk-2224d777c0b72d0b2dab622c79096c2c"
|
||||||
|
},
|
||||||
|
"chunk-31ff57cf79d009c378478f065eda9d4d": {
|
||||||
|
"tokens": 1200,
|
||||||
|
"content": "# Excel 工作簿:远光软件会计科目使用说明.xlsx\n\n## 工作表 1:会计科目说明\n\n| 远光软件股份有限公司常用会计科目使用说明 | 列2 | 列3 | 列4 | 列5 |\n| --- | --- | --- | --- | --- |\n| 科目编码 | 科目名称 | 科目类别 | 使用说明 | 备注 |\n| 1001 | 库存现金 | 资产类 | 核算公司库存现金 | 日清月结 |\n| 1002 | 银行存款 | 资产类 | 核算存入银行的各项存款 | 按开户行明细 |\n| 1122 | 应收账款 | 资产类 | 核算因销售商品/提供劳务应收款项 | 按客户明细 |\n| 1221 | 其他应收款 | 资产类 | 核算应收暂付款项 | 含备用金、押金 |\n| 1403 | 原材料 | 资产类 | 核算库存的各种材料 | |\n| 1601 | 固定资产 | 资产类 | 核算固定资产原值 | 按类别明细 |\n| 1602 | 累计折旧 | 资产类 | 核算固定资产累计折旧 | 贷方余额 |\n| 2202 | 应付账款 | 负债类 | 核算因购买商品/接受劳务应付款项 | 按供应商明细 |\n| 2221 | 应交税费 | 负债类 | 核算应缴纳的各种税费 | 按税种明细 |\n| 2211 | 应付职工薪酬 | 负债类 | 核算应付给职工的薪酬 | 含社保公积金 |\n| 6001 | 主营业务收入 | 损益类 | 核算主要经营业务产生的收入 | 按业务类型明细 |\n| 6401 | 主营业务成本 | 损益类 | 核算主要经营业务发生的成本 | |\n| 6601 | 管理费用 | 损益类 | 核算为管理生产经营发生的费用 | 按费用类型明细 |\n| 6602 | 销售费用 | 损益类 | 核算为销售产品发生的费用 | 按费用类型明细 |\n| 6603 | 财务费用 | 损益类 | 核算筹资等财务活动费用 | 含利息、手续费 |\n| 6801 | 所得税费用 | 损益类 | 核算企业所得税费用 | 含递延所得税 |\n\n### 行级检索线索\n\n- 会计科目说明 第 2 行:远光软件股份有限公司常用会计科目使用说明=科目编码;列2=科目名称;列3=科目类别;列4=使用说明;列5=备注\n\n- 会计科目说明 第 3 行:远光软件股份有限公司常用会计科目使用说明=1001;列2=库存现金;列3=资产类;列4=核算公司库存现金;列5=日清月结\n\n- 会计科目说明 第 4 行:远光软件股份有限公司常用会计科目使用说明=1002;列2=银行存款;列3=资产类;列4=核算存入银行的各项存款;列5=按开户行明细\n\n- 会计科目说明 第 5 行:远光软件股份有限公司常用会计科目使用说明=1122;列2=应收账款;列3=资产类;列4=核算因销售商品/提供劳务应收款项;列5=按客户明细\n\n- 会计科目说明 第 6 行:远光软件股份有限公司常用会计科目使用说明=1221;列2=其他应收款;列3=资产类;列4=核算应收暂付款项;列5=含备用金、押金\n\n- 会计科目说明 第 7 行:远光软件股份有限公司常用会计科目使用说明=1403;列2=原材料;列3=资产类;列4=核算库存的各种材料\n\n- 会计科目说明 第 8 行:远光软件股份有限公司常用会计科目使用说明=1601;列2=固定资产;列3=资产类;列4=核算固定资产原值;列5=按类别明细\n\n- 会计科目说明 第 9 行:远光软件股份有限公司常用会计科目使用说明=1602;列2=累计折旧;列3=资产类;列4=核算固定资产累计折旧;列5=贷方余额\n\n- 会计科目说明 第 10 行:远光软件股份有限公司常用会计科目使用说明=2202;列2=应付账款;列3=负债类;列4=核算因购买商品/接受劳务应付款项;列5=按供应商明细\n\n- 会计科目说明 第 11 行:远光软件股份有限公司常用会计科目使用说明=2221;列2=应交税费;列3=负债类;列4=核算应缴纳的各种税费;列5=按税种明细",
|
||||||
|
"chunk_order_index": 0,
|
||||||
|
"full_doc_id": "c7601043d9944ef2bcf4d3f67ed253f7",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx",
|
||||||
|
"llm_cache_list": [],
|
||||||
|
"create_time": 1779441661,
|
||||||
|
"update_time": 1779441661,
|
||||||
|
"_id": "chunk-31ff57cf79d009c378478f065eda9d4d"
|
||||||
|
},
|
||||||
|
"chunk-e726f44fb0287c5192cf61b350f18abb": {
|
||||||
|
"tokens": 952,
|
||||||
|
"content": "付账款;列3=负债类;列4=核算因购买商品/接受劳务应付款项;列5=按供应商明细\n\n- 会计科目说明 第 11 行:远光软件股份有限公司常用会计科目使用说明=2221;列2=应交税费;列3=负债类;列4=核算应缴纳的各种税费;列5=按税种明细\n\n- 会计科目说明 第 12 行:远光软件股份有限公司常用会计科目使用说明=2211;列2=应付职工薪酬;列3=负债类;列4=核算应付给职工的薪酬;列5=含社保公积金\n\n- 会计科目说明 第 13 行:远光软件股份有限公司常用会计科目使用说明=6001;列2=主营业务收入;列3=损益类;列4=核算主要经营业务产生的收入;列5=按业务类型明细\n\n- 会计科目说明 第 14 行:远光软件股份有限公司常用会计科目使用说明=6401;列2=主营业务成本;列3=损益类;列4=核算主要经营业务发生的成本\n\n- 会计科目说明 第 15 行:远光软件股份有限公司常用会计科目使用说明=6601;列2=管理费用;列3=损益类;列4=核算为管理生产经营发生的费用;列5=按费用类型明细\n\n- 会计科目说明 第 16 行:远光软件股份有限公司常用会计科目使用说明=6602;列2=销售费用;列3=损益类;列4=核算为销售产品发生的费用;列5=按费用类型明细\n\n- 会计科目说明 第 17 行:远光软件股份有限公司常用会计科目使用说明=6603;列2=财务费用;列3=损益类;列4=核算筹资等财务活动费用;列5=含利息、手续费\n\n- 会计科目说明 第 18 行:远光软件股份有限公司常用会计科目使用说明=6801;列2=所得税费用;列3=损益类;列4=核算企业所得税费用;列5=含递延所得税\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 正文:# Excel 工作簿:远光软件会计科目使用说明.xlsx\n- 正文:会计科目说明 第 2 行:远光软件股份有限公司常用会计科目使用说明=科目编码\n- 正文:列2=科目名称\n- 正文:列3=科目类别\n- 正文:列4=使用说明\n- 正文:列5=备注\n- 正文:会计科目说明 第 3 行:远光软件股份有限公司常用会计科目使用说明=1001\n- 正文:列2=库存现金\n- 正文:列3=资产类\n- 正文:列4=核算公司库存现金\n- 正文:列5=日清月结\n- 正文:会计科目说明 第 4 行:远光软件股份有限公司常用会计科目使用说明=1002\n- 正文:列2=银行存款\n- 正文:列4=核算存入银行的各项存款\n- 正文:列5=按开户行明细\n- 正文:会计科目说明 第 5 行:远光软件股份有限公司常用会计科目使用说明=1122\n- 正文:列2=应收账款\n- 正文:列4=核算因销售商品/提供劳务应收款项\n- 正文:列5=按客户明细\n- 正文:会计科目说明 第 6 行:远光软件股份有限公司常用会计科目使用说明=1221\n- 正文:列2=其他应收款",
|
||||||
|
"chunk_order_index": 1,
|
||||||
|
"full_doc_id": "c7601043d9944ef2bcf4d3f67ed253f7",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件会计科目使用说明.xlsx",
|
||||||
|
"llm_cache_list": [],
|
||||||
|
"create_time": 1779441661,
|
||||||
|
"update_time": 1779441661,
|
||||||
|
"_id": "chunk-e726f44fb0287c5192cf61b350f18abb"
|
||||||
|
},
|
||||||
|
"chunk-78edb0c8ccc8238159196ecaeeb08d43": {
|
||||||
|
"tokens": 839,
|
||||||
|
"content": "远光软件股份有限公司\n财务基础知识手册\n第一部分 会计基础知识\n一、会计要素\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。\n会计恒等式:资产 = 负债 + 所有者权益\n二、常用会计科目\n科目类别\n科目名称\n说明\n资产类\n库存现金\n公司持有的现金\n资产类\n银行存款\n存放在银行的资金\n资产类\n应收账款\n因销售商品或提供劳务应收的款项\n资产类\n固定资产\n使用年限超过一年的有形资产\n负债类\n应付账款\n因购买商品或接受劳务应付的款项\n负债类\n应交税费\n应缴纳的各种税费\n负债类\n应付职工薪酬\n应付给职工的工资、福利等\n损益类\n主营业务收入\n主要经营业务产生的收入\n损益类\n管理费用\n为管理生产经营发生的费用\n损益类\n销售费用\n为销售产品发生的费用\n第二部分 税务基础知识\n三、主要税种介绍\n(一)增值税:公司为一般纳税人,软件服务适用6%税率,软件产品销售适用13%税率。\n(二)企业所得税:税率为25%,高新技术企业享受15%优惠税率。\n(三)个人所得税:按累进税率3%-45%,由公司代扣代缴。\n(四)印花税:对经济活动中的应税凭证征收。\n第三部分 财务报表解读\n四、三大财务报表\n(一)资产负债表:反映企业在某一特定日期的财务状况。\n(二)利润表:反映企业在一定期间的经营成果。\n(三)现金流量表:反映企业在一定期间现金和现金等价物的流入和流出。\n\n# 章节导航\n\n以下内容由入库阶段从制度原文中提取,供检索时优先理解制度层级、条目和标准所在章节。\n\n- 一、会计要素\n- 二、常用会计科目\n- (四)印花税:对经济活动中的应税凭证征收。\n\n# 重点章节摘录\n\n## 一、会计要素\n\n会计要素包括:资产、负债、所有者权益、收入、费用和利润。;会计恒等式:资产 = 负债 + 所有者权益\n\n## 二、常用会计科目\n\n科目类别;科目名称;说明\n\n## (四)印花税:对经济活动中的应税凭证征收。\n\n第三部分 财务报表解读\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 一、会计要素:会计要素包括:资产、负债、所有者权益、收入、费用和利润\n- 一、会计要素:会计恒等式:资产 = 负债 + 所有者权益\n- 二、常用会计科目:因销售商品或提供劳务应收的款项\n- 二、常用会计科目:因购买商品或接受劳务应付的款项\n- 二、常用会计科目:应缴纳的各种税费\n- 二、常用会计科目:应付职工薪酬\n- (四)印花税:对经济活动中的应税凭证征收。:第三部分 财务报表解读",
|
||||||
|
"chunk_order_index": 0,
|
||||||
|
"full_doc_id": "b0277cd76034437997fbf5219662725a",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务基础知识手册.docx",
|
||||||
|
"llm_cache_list": [],
|
||||||
|
"create_time": 1779441751,
|
||||||
|
"update_time": 1779441751,
|
||||||
|
"_id": "chunk-78edb0c8ccc8238159196ecaeeb08d43"
|
||||||
|
},
|
||||||
|
"chunk-2ee7e2a66cb544bdfe1b09e133863ad1": {
|
||||||
|
"tokens": 760,
|
||||||
|
"content": "远光软件股份有限公司\n财务术语解释手册\n权责发生制\n以权利和责任的发生来决定收入和费用归属期的会计基础。即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用。\n收付实现制\n以现金收到或付出为标准来记录收入的实现和费用的发生。即凡是当期收到和支付的现金,都作为当期的收入和费用。\n固定资产折旧\n固定资产在使用过程中因磨损而逐渐转移的价值。公司采用年限平均法计提折旧。\n摊销\n将无形资产或长期待摊费用按照规定期限分期计入当期损益的过程。\n增值税进项税额\n企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣。\n增值税销项税额\n企业销售货物、提供应税劳务或应税服务收取的增值税额。\n预算\n企业对未来一定时期内经营活动的数量化计划,包括收入预算、支出预算、资本预算等。\n现金流\n企业在一定期间内现金和现金等价物流入和流出的数量。\n毛利率\n毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力。计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n净资产收益率(ROE)\n净利润占股东权益的百分比,反映股东投入资金的获利能力。计算公式:ROE = 净利润 / 股东权益 × 100%\n成本中心\n企业内部只发生成本费用而不产生收入的组织单位,用于成本核算和控制。\n利润中心\n企业内部既发生成本费用又产生收入的组织单位,用于考核盈利能力。\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 正文:以权利和责任的发生来决定收入和费用归属期的会计基础\n- 正文:即凡是当期已经实现的收入和已经发生或应当负担的费用,不论款项是否收付,都应当作为当期的收入和费用\n- 正文:以现金收到或付出为标准来记录收入的实现和费用的发生\n- 正文:即凡是当期收到和支付的现金,都作为当期的收入和费用\n- 正文:企业购进货物、接受应税劳务或应税服务支付的增值税额,可以从销项税额中抵扣\n- 正文:企业销售货物、提供应税劳务或应税服务收取的增值税额\n- 正文:毛利润占营业收入的百分比,反映企业产品或服务的初始盈利能力\n- 正文:计算公式:毛利率 = (营业收入 - 营业成本)/ 营业收入 × 100%\n- 正文:净利润占股东权益的百分比,反映股东投入资金的获利能力\n- 正文:计算公式:ROE = 净利润 / 股东权益 × 100%",
|
||||||
|
"chunk_order_index": 0,
|
||||||
|
"full_doc_id": "23f56f159a3e4bc3b2338056544120dd",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件财务术语解释手册.docx",
|
||||||
|
"llm_cache_list": [],
|
||||||
|
"create_time": 1779441764,
|
||||||
|
"update_time": 1779441764,
|
||||||
|
"_id": "chunk-2ee7e2a66cb544bdfe1b09e133863ad1"
|
||||||
|
},
|
||||||
|
"chunk-2c8384b328272063de4dac306a52d21e": {
|
||||||
|
"tokens": 1150,
|
||||||
|
"content": "远光软件股份有限公司高新技术企业税收优惠政策汇总\n\n 远光软件股份有限公司\n\n 2024年度\n\n一、企业所得税优惠\n\n1. 高新技术企业减按15%税率征收企业所得税\n\n- 条件:取得高新技术企业证书且在有效期内\n\n- 申请:向主管税务机关备案\n\n2. 技术转让所得优惠\n\n- 符合条件的技术转让所得500万元以下免征企业所得税\n\n- 超过500万元的部分减半征收\n\n二、增值税优惠\n\n1. 软件产品增值税即征即退\n\n- 销售自行开发生产的软件产品,按13%征收后\n\n- 实际税负超过3%的部分即征即退\n\n2. 技术服务免征增值税\n\n- 符合条件的技术转让、技术开发和相关的技术咨询、技术服务免征增值税\n\n三、研发费用加计扣除\n\n1. 一般企业研发费用按100%加计扣除\n\n- 形成无形资产的按200%摊销\n\n2. 适用范围:\n\n- 人员人工费用\n\n- 直接投入费用\n\n- 折旧费用\n\n- 无形资产摊销\n\n- 新产品设计费等\n\n 远光软件股份有限公司 - 第 <bound method FPDF.page_no of <__main__.ChinesePDF object at 0x000001F09798B750>>/2 页\n- 其他相关费用\n\n四、其他优惠政策\n\n1. 固定资产加速折旧:允许缩短折旧年限或加速折旧\n\n2. 软件企业职工培训费用:全额在企业所得税前扣除\n\n3. 创业投资抵扣:投资未上市中小高新技术企业的按投资额70%抵扣应纳税所得额\n\n 远光软件股份有限公司 - 第 <bound method FPDF.page_no of <__main__.ChinesePDF object at 0x000001F09798B750>>/2 页\n\n# 章节导航\n\n以下内容由入库阶段从制度原文中提取,供检索时优先理解制度层级、条目和标准所在章节。\n\n- 一、企业所得税优惠\n- 二、增值税优惠\n- 三、研发费用加计扣除\n- 四、其他优惠政策\n\n# 重点章节摘录\n\n## 一、企业所得税优惠\n\n1. 高新技术企业减按15%税率征收企业所得税;- 条件:取得高新技术企业证书且在有效期内;- 申请:向主管税务机关备案\n\n## 二、增值税优惠\n\n1. 软件产品增值税即征即退;- 销售自行开发生产的软件产品,按13%征收后;- 实际税负超过3%的部分即征即退\n\n## 三、研发费用加计扣除\n\n1. 一般企业研发费用按100%加计扣除;- 形成无形资产的按200%摊销;2. 适用范围:\n\n## 四、其他优惠政策\n\n1. 固定资产加速折旧:允许缩短折旧年限或加速折旧;2. 软件企业职工培训费用:全额在企业所得税前扣除;3. 创业投资抵扣:投资未上市中小高新技术企业的按投资额70%抵扣应纳税所得额\n\n# 问答线索补充\n\n以下内容由入库阶段根据章节标题、条款、列表、键值对与相邻正文提炼,供问答检索时优先命中更短、更直接的制度依据。\n\n- 一、企业所得税优惠:1. 高新技术企业减按15%税率征收企业所得税\n- 一、企业所得税优惠:条件:取得高新技术企业证书且在有效期内\n- 一、企业所得税优惠:申请:向主管税务机关备案\n- 一、企业所得税优惠:2. 技术转让所得优惠\n- 二、增值税优惠:1. 软件产品增值税即征即退\n- 二、增值税优惠:销售自行开发生产的软件产品,按13%征收后\n- 二、增值税优惠:实际税负超过3%的部分即征即退\n- 二、增值税优惠:2. 技术服务免征增值税\n- 三、研发费用加计扣除:1. 一般企业研发费用按100%加计扣除\n- 三、研发费用加计扣除:形成无形资产的按200%摊销\n- 三、研发费用加计扣除:2. 适用范围:\n- 三、研发费用加计扣除:人员人工费用\n- 四、其他优惠政策:1. 固定资产加速折旧:允许缩短折旧年限或加速折旧\n- 四、其他优惠政策:2. 软件企业职工培训费用:全额在企业所得税前扣除\n- 四、其他优惠政策:3. 创业投资抵扣:投资未上市中小高新技术企业的按投资额70%抵扣应纳税所得额",
|
||||||
|
"chunk_order_index": 0,
|
||||||
|
"full_doc_id": "09fbcae74d3b41e498a47e05b45262cb",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf",
|
||||||
|
"llm_cache_list": [],
|
||||||
|
"create_time": 1779441797,
|
||||||
|
"update_time": 1779441797,
|
||||||
|
"_id": "chunk-2c8384b328272063de4dac306a52d21e"
|
||||||
|
},
|
||||||
|
"chunk-93d2389cdb74257e90201dccbc3f6539": {
|
||||||
|
"tokens": 50,
|
||||||
|
"content": "培训费用:全额在企业所得税前扣除\n- 四、其他优惠政策:3. 创业投资抵扣:投资未上市中小高新技术企业的按投资额70%抵扣应纳税所得额",
|
||||||
|
"chunk_order_index": 1,
|
||||||
|
"full_doc_id": "09fbcae74d3b41e498a47e05b45262cb",
|
||||||
|
"file_path": "/app/server/storage/knowledge/财务知识库/远光软件高新技术企业税收优惠政策汇总.pdf",
|
||||||
|
"llm_cache_list": [],
|
||||||
|
"create_time": 1779441797,
|
||||||
|
"update_time": 1779441797,
|
||||||
|
"_id": "chunk-93d2389cdb74257e90201dccbc3f6539"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +181,49 @@ def test_save_or_submit_persists_claim_only_after_save_draft_action() -> None:
|
|||||||
assert _count_claims(db) == before_count + 1
|
assert _count_claims(db) == before_count + 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_draft_persists_user_changed_expense_category() -> None:
|
||||||
|
user_id = "save-draft-category@example.com"
|
||||||
|
message = "业务发生时间:2026-03-04,打车去客户现场,交通费32元,请帮我看看怎么报"
|
||||||
|
|
||||||
|
with build_session() as db:
|
||||||
|
employee = Employee(
|
||||||
|
employee_no="E5102",
|
||||||
|
name="分类员工",
|
||||||
|
email=user_id,
|
||||||
|
)
|
||||||
|
db.add(employee)
|
||||||
|
db.commit()
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query=message,
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = ExpenseClaimService(db).save_or_submit_from_ontology(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id=user_id,
|
||||||
|
message=message,
|
||||||
|
ontology=ontology,
|
||||||
|
context_json={
|
||||||
|
"name": "分类员工",
|
||||||
|
"user_input_text": message,
|
||||||
|
"review_action": "save_draft",
|
||||||
|
"review_form_values": {
|
||||||
|
"expense_type": "办公用品费",
|
||||||
|
"amount": "32元",
|
||||||
|
"occurred_date": "2026-03-04",
|
||||||
|
"reason": "右侧核对后改为办公用品费",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
claim = db.get(ExpenseClaim, result["claim_id"])
|
||||||
|
assert claim is not None
|
||||||
|
assert claim.expense_type == "office"
|
||||||
|
assert claim.items[0].item_type == "office"
|
||||||
|
|
||||||
|
|
||||||
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
|
def test_unsaved_conversation_expires_after_retention_but_saved_conversation_stays() -> None:
|
||||||
with build_session() as db:
|
with build_session() as db:
|
||||||
service = AgentConversationService(db)
|
service = AgentConversationService(db)
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
|
||||||
from app.services import knowledge_rag as knowledge_rag_module
|
from app.services import knowledge_rag as knowledge_rag_module
|
||||||
|
from app.services.knowledge_ingest_log import (
|
||||||
|
build_document_graph_summary,
|
||||||
|
build_ingest_document_summary,
|
||||||
|
build_ingest_status_summary,
|
||||||
|
)
|
||||||
from app.services.knowledge_rag import KnowledgeRagService
|
from app.services.knowledge_rag import KnowledgeRagService
|
||||||
|
|
||||||
|
|
||||||
@@ -86,7 +94,10 @@ def test_build_hits_demotes_chapter_navigation_for_specific_rule_queries() -> No
|
|||||||
{
|
{
|
||||||
"chunk_id": "body-1",
|
"chunk_id": "body-1",
|
||||||
"file_path": "/tmp/doc-1__费用制度.md",
|
"file_path": "/tmp/doc-1__费用制度.md",
|
||||||
"content": "附表3:支出归口管理部门与归口业务范围\n组织人事部:探亲差旅、条件艰苦及安全风险较高区域补助等支出。",
|
"content": (
|
||||||
|
"附表3:支出归口管理部门与归口业务范围\n"
|
||||||
|
"组织人事部:探亲差旅、条件艰苦及安全风险较高区域补助等支出。"
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
entities=[],
|
entities=[],
|
||||||
@@ -100,9 +111,11 @@ def test_resolve_default_qdrant_url_prefers_container_host(monkeypatch) -> None:
|
|||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
knowledge_rag_module.socket,
|
knowledge_rag_module.socket,
|
||||||
"getaddrinfo",
|
"getaddrinfo",
|
||||||
lambda hostname, port: [("family", "type", "proto", "canonname", ("172.21.0.2", 0))]
|
lambda hostname, port: (
|
||||||
if hostname == "qdrant"
|
[("family", "type", "proto", "canonname", ("172.21.0.2", 0))]
|
||||||
else [],
|
if hostname == "qdrant"
|
||||||
|
else []
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://qdrant:6333"
|
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://qdrant:6333"
|
||||||
@@ -117,6 +130,45 @@ def test_resolve_default_qdrant_url_falls_back_to_loopback(monkeypatch) -> None:
|
|||||||
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://127.0.0.1:6333"
|
assert knowledge_rag_module._resolve_default_qdrant_url() == "http://127.0.0.1:6333"
|
||||||
|
|
||||||
|
|
||||||
|
def test_runtime_cache_is_isolated_by_thread(monkeypatch) -> None:
|
||||||
|
knowledge_rag_module.shutdown_knowledge_rag_runtime()
|
||||||
|
created_runtimes = []
|
||||||
|
|
||||||
|
class FakeRuntime:
|
||||||
|
def __init__(self, **_kwargs):
|
||||||
|
self.finalized = False
|
||||||
|
created_runtimes.append(self)
|
||||||
|
|
||||||
|
def finalize(self):
|
||||||
|
self.finalized = True
|
||||||
|
|
||||||
|
monkeypatch.setattr(knowledge_rag_module, "_LightRagRuntime", FakeRuntime)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
KnowledgeRagService,
|
||||||
|
"_build_runtime_signature",
|
||||||
|
lambda self: (("same-config",), {}),
|
||||||
|
)
|
||||||
|
|
||||||
|
service = KnowledgeRagService()
|
||||||
|
main_runtime = service._get_runtime()
|
||||||
|
assert service._get_runtime() is main_runtime
|
||||||
|
|
||||||
|
worker_runtimes = []
|
||||||
|
|
||||||
|
def load_worker_runtime() -> None:
|
||||||
|
worker_runtimes.append(KnowledgeRagService()._get_runtime())
|
||||||
|
|
||||||
|
thread = threading.Thread(target=load_worker_runtime)
|
||||||
|
thread.start()
|
||||||
|
thread.join(timeout=5)
|
||||||
|
|
||||||
|
assert len(created_runtimes) == 2
|
||||||
|
assert worker_runtimes[0] is not main_runtime
|
||||||
|
|
||||||
|
knowledge_rag_module.shutdown_knowledge_rag_runtime()
|
||||||
|
assert all(runtime.finalized for runtime in created_runtimes)
|
||||||
|
|
||||||
|
|
||||||
def test_is_query_ready_status_rejects_failed_status_even_with_chunks() -> None:
|
def test_is_query_ready_status_rejects_failed_status_even_with_chunks() -> None:
|
||||||
assert (
|
assert (
|
||||||
KnowledgeRagService.is_query_ready_status(
|
KnowledgeRagService.is_query_ready_status(
|
||||||
@@ -141,3 +193,89 @@ def test_is_query_ready_status_rejects_processing_status_even_with_chunks() -> N
|
|||||||
)
|
)
|
||||||
is False
|
is False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_document_graph_summary_reads_lightrag_storage(tmp_path) -> None:
|
||||||
|
workspace = tmp_path / "knowledge" / ".lightrag" / "test_workspace"
|
||||||
|
workspace.mkdir(parents=True)
|
||||||
|
(workspace / "kv_store_full_entities.json").write_text(
|
||||||
|
json.dumps({"doc-1": {"entity_names": ["远光软件", "支出管理", "远光软件"]}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(workspace / "kv_store_full_relations.json").write_text(
|
||||||
|
json.dumps({"doc-1": {"relation_pairs": [["远光软件", "支出管理"]]}}),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
(workspace / "kv_store_text_chunks.json").write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"chunk-2": {
|
||||||
|
"_id": "chunk-2",
|
||||||
|
"full_doc_id": "doc-1",
|
||||||
|
"chunk_order_index": 1,
|
||||||
|
"tokens": 45,
|
||||||
|
"content": "第二条 支出审批需要结合预算、归口部门和授权标准执行。",
|
||||||
|
},
|
||||||
|
"chunk-1": {
|
||||||
|
"_id": "chunk-1",
|
||||||
|
"full_doc_id": "doc-1",
|
||||||
|
"chunk_order_index": 0,
|
||||||
|
"tokens": 31,
|
||||||
|
"content": "第一条 本办法适用于公司支出管理。",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = build_document_graph_summary(
|
||||||
|
tmp_path,
|
||||||
|
workspace="test_workspace",
|
||||||
|
document_id="doc-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["entity_count"] == 2
|
||||||
|
assert summary["entities"] == ["远光软件", "支出管理"]
|
||||||
|
assert summary["relation_count"] == 1
|
||||||
|
assert summary["relations"] == [{"source": "远光软件", "target": "支出管理", "type": "关联"}]
|
||||||
|
assert [item["id"] for item in summary["chunks"]] == ["chunk-1", "chunk-2"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ingest_document_summary_extracts_sections() -> None:
|
||||||
|
summary = build_ingest_document_summary(
|
||||||
|
document_id="doc-1",
|
||||||
|
entry={
|
||||||
|
"original_name": "公司支出管理办法.pdf",
|
||||||
|
"folder": "制度文件",
|
||||||
|
"extension": "pdf",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
},
|
||||||
|
raw_text="第一章 总则\n本办法用于规范公司支出。",
|
||||||
|
indexed_text="# 第一章 总则\n本办法用于规范公司支出。\n第二条 审批\n审批需按授权执行。",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["name"] == "公司支出管理办法.pdf"
|
||||||
|
assert summary["section_count"] == 2
|
||||||
|
assert summary["sections"][0]["title"] == "第一章 总则"
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_ingest_status_summary_keeps_chunk_status() -> None:
|
||||||
|
summary = build_ingest_status_summary(
|
||||||
|
status_payload={
|
||||||
|
"status": "processed",
|
||||||
|
"query_ready": True,
|
||||||
|
"chunks_count": 2,
|
||||||
|
"chunks_list": ["chunk-1", "chunk-2"],
|
||||||
|
},
|
||||||
|
graph_summary={
|
||||||
|
"entity_count": 1,
|
||||||
|
"relation_count": 0,
|
||||||
|
"entities": ["预算"],
|
||||||
|
"relations": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["lightrag_status"] == "processed"
|
||||||
|
assert summary["query_ready"] is True
|
||||||
|
assert summary["chunk_count"] == 2
|
||||||
|
assert summary["chunk_ids"] == ["chunk-1", "chunk-2"]
|
||||||
|
|||||||
@@ -389,10 +389,10 @@ def test_semantic_ontology_service_prefers_expense_for_customer_entertainment_na
|
|||||||
assert result.clarification_required is True
|
assert result.clarification_required is True
|
||||||
assert "customer_name" in result.missing_slots
|
assert "customer_name" in result.missing_slots
|
||||||
assert "participants" in result.missing_slots
|
assert "participants" in result.missing_slots
|
||||||
assert any(
|
assert any(
|
||||||
item.type == "expense_type" and item.normalized_value == "entertainment"
|
item.type == "expense_type" and item.normalized_value == "meal"
|
||||||
for item in result.entities
|
for item in result.entities
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_uses_client_local_date_for_relative_time() -> None:
|
def test_semantic_ontology_service_uses_client_local_date_for_relative_time() -> None:
|
||||||
@@ -556,6 +556,39 @@ def test_semantic_ontology_service_maps_taxi_ticket_reimbursement_to_transport_d
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"query,expected_type",
|
||||||
|
[
|
||||||
|
("报销飞机票和行程单", "travel"),
|
||||||
|
("报销酒店发票和房费", "hotel"),
|
||||||
|
("报销滴滴打车票", "transport"),
|
||||||
|
("报销工作餐餐费", "meal"),
|
||||||
|
("报销会议场地费", "meeting"),
|
||||||
|
("报销客户接待餐", "meal"),
|
||||||
|
("报销打印纸和硒鼓", "office"),
|
||||||
|
("报销培训课程费", "training"),
|
||||||
|
("报销手机话费和流量费", "communication"),
|
||||||
|
("报销员工体检费", "welfare"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_semantic_ontology_service_covers_common_expense_scene_keywords(
|
||||||
|
query: str,
|
||||||
|
expected_type: str,
|
||||||
|
) -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
result = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(query=query, user_id="pytest")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.scenario == "expense"
|
||||||
|
assert result.intent == "draft"
|
||||||
|
assert any(
|
||||||
|
item.type == "expense_type" and item.normalized_value == expected_type
|
||||||
|
for item in result.entities
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
def test_semantic_ontology_service_uses_model_parse_when_available(monkeypatch) -> None:
|
||||||
session_factory = build_session_factory()
|
session_factory = build_session_factory()
|
||||||
with session_factory() as db:
|
with session_factory() as db:
|
||||||
|
|||||||
@@ -540,7 +540,11 @@ def test_user_agent_asks_for_type_when_trip_context_is_ambiguous() -> None:
|
|||||||
"交通费",
|
"交通费",
|
||||||
"住宿费",
|
"住宿费",
|
||||||
"业务招待费",
|
"业务招待费",
|
||||||
"办公费",
|
"会务费",
|
||||||
|
"办公用品费",
|
||||||
|
"培训费",
|
||||||
|
"通讯费",
|
||||||
|
"福利费",
|
||||||
"其他费用",
|
"其他费用",
|
||||||
]
|
]
|
||||||
assert response.suggested_actions[0].payload["original_message"] == message
|
assert response.suggested_actions[0].payload["original_message"] == message
|
||||||
@@ -729,6 +733,9 @@ def test_user_agent_keeps_taxi_ticket_for_customer_dropoff_as_transport_expense(
|
|||||||
assert "业务招待费" not in response.review_payload.intent_summary
|
assert "业务招待费" not in response.review_payload.intent_summary
|
||||||
assert "客户名称" not in response.review_payload.missing_slots
|
assert "客户名称" not in response.review_payload.missing_slots
|
||||||
assert "参与人员" not in response.review_payload.missing_slots
|
assert "参与人员" not in response.review_payload.missing_slots
|
||||||
|
edit_field_keys = {item.key for item in response.review_payload.edit_fields}
|
||||||
|
assert "merchant_name" not in edit_field_keys
|
||||||
|
assert "participants" not in edit_field_keys
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_context() -> None:
|
def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_context() -> None:
|
||||||
@@ -1000,6 +1007,9 @@ def test_user_agent_transport_flow_infers_reason_and_does_not_require_location_o
|
|||||||
|
|
||||||
assert response.review_payload is not None
|
assert response.review_payload is not None
|
||||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||||
|
document_card = response.review_payload.document_cards[0]
|
||||||
|
assert document_card.scene_label == "出租车/网约车票据"
|
||||||
|
assert document_card.suggested_expense_type == "transport"
|
||||||
assert slot_map["reason"].value == "交通出行"
|
assert slot_map["reason"].value == "交通出行"
|
||||||
assert slot_map["reason"].status == "inferred"
|
assert slot_map["reason"].status == "inferred"
|
||||||
assert "酒店/商户" not in response.review_payload.missing_slots
|
assert "酒店/商户" not in response.review_payload.missing_slots
|
||||||
@@ -1189,8 +1199,15 @@ def test_user_agent_document_service_normalizes_ocr_fields_and_scene() -> None:
|
|||||||
assert fields["列车出发时间"] == "2026-03-04"
|
assert fields["列车出发时间"] == "2026-03-04"
|
||||||
assert "商户/酒店" not in fields
|
assert "商户/酒店" not in fields
|
||||||
assert document_service.extract_amount_text_from_value("滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678") == "13.40元"
|
assert document_service.extract_amount_text_from_value("滴滴出行 支付金额 1 元,实付 13.4 元,订单号 12345678") == "13.40元"
|
||||||
|
taxi_classified = document_service.classify_document({"filename": "行程单_的士票.jpg", "summary": "的士 车费 48 元"})
|
||||||
|
assert taxi_classified["document_type"] == "taxi_receipt"
|
||||||
|
assert taxi_classified["expense_type"] == "transport"
|
||||||
|
assert taxi_classified["scene_label"] == "出租车/网约车票据"
|
||||||
|
ship_classified = document_service.classify_document({"filename": "轮船票.jpg", "summary": "轮船 船票 金额 180 元"})
|
||||||
|
assert ship_classified["document_type"] == "ship_ticket"
|
||||||
|
assert ship_classified["scene_label"] == "轮船票"
|
||||||
assert classified["document_type"] == "meal_receipt"
|
assert classified["document_type"] == "meal_receipt"
|
||||||
assert classified["expense_type"] == "entertainment"
|
assert classified["expense_type"] == "meal"
|
||||||
assert document_service.infer_expense_type_from_documents(
|
assert document_service.infer_expense_type_from_documents(
|
||||||
[{"filename": "客户餐饮发票.jpg", "summary": "餐饮发票 客户招待 金额 320 元"}],
|
[{"filename": "客户餐饮发票.jpg", "summary": "餐饮发票 客户招待 金额 320 元"}],
|
||||||
expense_type_code="entertainment",
|
expense_type_code="entertainment",
|
||||||
@@ -1262,11 +1279,13 @@ def test_user_agent_builds_review_payload_for_multi_document_expense_flow() -> N
|
|||||||
assert response.review_payload is not None
|
assert response.review_payload is not None
|
||||||
assert len(response.review_payload.document_cards) == 2
|
assert len(response.review_payload.document_cards) == 2
|
||||||
assert len(response.review_payload.claim_groups) == 2
|
assert len(response.review_payload.claim_groups) == 2
|
||||||
assert response.review_payload.missing_slots == ["参与人员"]
|
assert response.review_payload.missing_slots == ["参与人员", "酒店的报销票据待上传(必须)"]
|
||||||
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
|
assert [item.action_type for item in response.review_payload.confirmation_actions] == [
|
||||||
"save_draft",
|
"save_draft",
|
||||||
]
|
]
|
||||||
assert any(item.scene_label == "业务招待费" for item in response.review_payload.document_cards)
|
assert any(item.scene_label == "餐饮发票" for item in response.review_payload.document_cards)
|
||||||
|
assert all(item.scene_label != "业务招待费" for item in response.review_payload.document_cards)
|
||||||
|
assert any(item.scene_label == "业务招待费" for item in response.review_payload.claim_groups)
|
||||||
assert f"时间:{yesterday}" in response.review_payload.intent_summary
|
assert f"时间:{yesterday}" in response.review_payload.intent_summary
|
||||||
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
slot_map = {item.key: item for item in response.review_payload.slot_cards}
|
||||||
assert slot_map["time_range"].value == yesterday
|
assert slot_map["time_range"].value == yesterday
|
||||||
@@ -1899,7 +1918,58 @@ def test_user_agent_review_payload_prechecks_taxi_amount_against_rule_standard()
|
|||||||
assert "单笔交通金额" in combined
|
assert "单笔交通金额" in combined
|
||||||
assert "报销场景提交与附件标准" in combined
|
assert "报销场景提交与附件标准" in combined
|
||||||
assert amount_brief.level == "high"
|
assert amount_brief.level == "high"
|
||||||
assert any(item.title == "附件金额测算结果" for item in response.review_payload.risk_briefs)
|
measurement = next(item for item in response.review_payload.risk_briefs if item.title == "附件金额测算异常")
|
||||||
|
assert measurement.level == "warning"
|
||||||
|
assert "超出标准" in measurement.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_user_agent_review_payload_does_not_mark_compliant_taxi_amount_as_low_risk() -> None:
|
||||||
|
session_factory = build_session_factory()
|
||||||
|
with session_factory() as db:
|
||||||
|
query = "我上传一张的士票59.10元,帮我生成交通费报销草稿"
|
||||||
|
context = {
|
||||||
|
"name": "张三",
|
||||||
|
"attachment_names": ["的士1.jpg"],
|
||||||
|
"attachment_count": 1,
|
||||||
|
"ocr_documents": [
|
||||||
|
{
|
||||||
|
"filename": "的士1.jpg",
|
||||||
|
"document_type": "taxi_receipt",
|
||||||
|
"summary": "出租车/网约车票据 支付金额 59.10 元",
|
||||||
|
"text": "的士 车费 59.10 元",
|
||||||
|
"avg_score": 0.95,
|
||||||
|
"document_fields": [
|
||||||
|
{"key": "amount", "label": "支付金额", "value": "59.10"},
|
||||||
|
],
|
||||||
|
"warnings": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
ontology = SemanticOntologyService(db).parse(
|
||||||
|
OntologyParseRequest(
|
||||||
|
query=query,
|
||||||
|
user_id="pytest-taxi-pass@example.com",
|
||||||
|
context_json=context,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
response = UserAgentService(db).respond(
|
||||||
|
UserAgentRequest(
|
||||||
|
run_id=ontology.run_id,
|
||||||
|
user_id="pytest-taxi-pass@example.com",
|
||||||
|
message=query,
|
||||||
|
ontology=ontology,
|
||||||
|
context_json=context,
|
||||||
|
tool_payload={"draft_only": True},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.review_payload is not None
|
||||||
|
risk_titles = [item.title for item in response.review_payload.risk_briefs]
|
||||||
|
risk_details = "\n".join(item.detail for item in response.review_payload.risk_briefs)
|
||||||
|
assert "附件金额测算结果" not in risk_titles
|
||||||
|
assert "附件金额测算异常" not in risk_titles
|
||||||
|
assert "测算通过" not in risk_details
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_review_payload_uses_finance_spreadsheet_hotel_amount_standard() -> None:
|
def test_user_agent_review_payload_uses_finance_spreadsheet_hotel_amount_standard() -> None:
|
||||||
@@ -2067,8 +2137,9 @@ def test_user_agent_review_payload_uses_finance_spreadsheet_meal_allowance_stand
|
|||||||
assert "直辖市/特区" in combined
|
assert "直辖市/特区" in combined
|
||||||
assert "公司差旅费报销规则" in combined
|
assert "公司差旅费报销规则" in combined
|
||||||
assert meal_brief.level == "high"
|
assert meal_brief.level == "high"
|
||||||
measurement = next(item for item in response.review_payload.risk_briefs if item.title == "附件金额测算结果")
|
measurement = next(item for item in response.review_payload.risk_briefs if item.title == "附件金额测算异常")
|
||||||
assert "伙食补助标准 65.00" in measurement.detail
|
assert "伙食补助标准 65.00" in measurement.detail
|
||||||
|
assert "超出标准" in measurement.detail
|
||||||
|
|
||||||
|
|
||||||
def test_user_agent_filters_deprecated_review_risk_briefs() -> None:
|
def test_user_agent_filters_deprecated_review_risk_briefs() -> None:
|
||||||
|
|||||||
227
web/src/assets/styles/views/settings-view-form.css
Normal file
227
web/src/assets/styles/views/settings-view-form.css
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
/* 设置页表单/卡片/开关 — 供 SettingsView 与子面板各自 scoped 引入 */
|
||||||
|
|
||||||
|
.settings-card {
|
||||||
|
padding: 0;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02), 0 4px 16px rgba(0, 0, 0, 0.03);
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.01), 0 10px 24px rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px 24px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e2e8f0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card > *:not(.card-head) {
|
||||||
|
padding-left: 24px;
|
||||||
|
padding-right: 24px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card > *:not(.card-head):last-child {
|
||||||
|
margin-bottom: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head h4 {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-head p {
|
||||||
|
margin: 4px 0 0 0;
|
||||||
|
padding: 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-grid {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-wide {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field-full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field span {
|
||||||
|
color: #475569;
|
||||||
|
font-size: 12.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field em {
|
||||||
|
margin-right: 4px;
|
||||||
|
color: #ef4444;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input,
|
||||||
|
.field select {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: #ffffff;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.45;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input::placeholder {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field input:focus,
|
||||||
|
.field select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #10b981;
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08);
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row {
|
||||||
|
width: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px 20px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #ffffff;
|
||||||
|
text-align: left;
|
||||||
|
transition: all 0.25s ease;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.01);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-row:hover {
|
||||||
|
border-color: rgba(16, 185, 129, 0.25);
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, rgba(16, 185, 129, 0.01) 100%);
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.03);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-copy strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-copy small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Switch Toggle Buttons */
|
||||||
|
.switch-btn {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 48px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 3px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #cbd5e1;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: background-color 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn i {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.15);
|
||||||
|
transition: transform 0.25s cubic-bezier(0.25, 1, 0.5, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn.active {
|
||||||
|
background-color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn.active i {
|
||||||
|
transform: translateX(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mini Switch */
|
||||||
|
.switch-btn.mini {
|
||||||
|
width: 38px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn.mini i {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.switch-btn.mini.active i {
|
||||||
|
transform: translateX(18px);
|
||||||
|
}
|
||||||
228
web/src/assets/styles/views/settings-view-hermes.css
Normal file
228
web/src/assets/styles/views/settings-view-hermes.css
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
/* Container */
|
||||||
|
.hermes-settings-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Master Control Hero Card Active Hover & Color */
|
||||||
|
.hermes-hero-card.active {
|
||||||
|
border-color: rgba(16, 185, 129, 0.25);
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, rgba(16, 185, 129, 0.02) 100%);
|
||||||
|
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.01), 0 8px 24px rgba(16, 185, 129, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hermes-hero-card .model-icon-box {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hermes-hero-card .model-icon-box.active {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
border-color: rgba(16, 185, 129, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pulse Dot */
|
||||||
|
.status-pulse-dot {
|
||||||
|
position: absolute;
|
||||||
|
top: -2px;
|
||||||
|
right: -2px;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background-color: #cbd5e1;
|
||||||
|
border: 2px solid #ffffff;
|
||||||
|
border-radius: 50%;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-pulse-dot.active {
|
||||||
|
background-color: #10b981;
|
||||||
|
animation: pulse-ring 1.8s cubic-bezier(0.455, 0.03, 0.515, 0.955) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes pulse-ring {
|
||||||
|
0% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4);
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
box-shadow: 0 0 0 6px rgba(16, 185, 129, 0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
box-shadow: 0 0 0 0 rgba(16, 185, 129, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge {
|
||||||
|
padding: 6px 12px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge.active {
|
||||||
|
background: rgba(16, 185, 129, 0.1);
|
||||||
|
color: #10b981;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Section */
|
||||||
|
.hermes-tasks-section.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tasks Grid */
|
||||||
|
.hermes-task-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task Card */
|
||||||
|
.hermes-task-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 16px;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.01), 0 2px 8px rgba(0, 0, 0, 0.02);
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hermes-task-card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
box-shadow: 0 4px 6px rgba(15, 23, 42, 0.01), 0 10px 20px rgba(15, 23, 42, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-icon-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta-info strong {
|
||||||
|
display: block;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-meta-info small {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task card colors (SaaS Gray) */
|
||||||
|
.task-icon-box.indigo,
|
||||||
|
.task-icon-box.warning,
|
||||||
|
.task-icon-box.danger,
|
||||||
|
.task-icon-box.info,
|
||||||
|
.task-icon-box.success,
|
||||||
|
.task-icon-box.primary,
|
||||||
|
.task-icon-box.secondary,
|
||||||
|
.task-icon-box.default {
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #475569;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Footer & Time picker */
|
||||||
|
.task-card-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px dashed #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 99px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.frequency-badge.active {
|
||||||
|
background: #f0f9ff;
|
||||||
|
color: #0284c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-picker-wrapper input[type="time"] {
|
||||||
|
padding: 5px 8px;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
color: #334155;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-picker-wrapper input[type="time"]:hover {
|
||||||
|
border-color: #cbd5e1;
|
||||||
|
background: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-picker-wrapper input[type="time"]:focus {
|
||||||
|
border-color: #10b981;
|
||||||
|
background: #ffffff;
|
||||||
|
box-shadow: 0 0 0 3px rgba(16, 185, 129, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.time-picker-placeholder {
|
||||||
|
color: #cbd5e1;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hermes-hero-card.active {
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.hermes-task-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -759,7 +759,8 @@
|
|||||||
.review-side-metric-card.editable:hover,
|
.review-side-metric-card.editable:hover,
|
||||||
.review-side-metric-card.editing {
|
.review-side-metric-card.editing {
|
||||||
border-color: rgba(16, 185, 129, 0.34);
|
border-color: rgba(16, 185, 129, 0.34);
|
||||||
background: rgba(248, 252, 250, 0.92);
|
background: #ffffff;
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.08);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,10 +769,25 @@
|
|||||||
height: 32px;
|
height: 32px;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
background: rgba(240, 253, 244, 0.95);
|
background: #f1f5f9;
|
||||||
color: #059669;
|
border: 1px solid transparent;
|
||||||
|
color: #64748b;
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-side-metric-card.editable:hover .review-side-metric-icon,
|
||||||
|
.review-side-metric-card.editing .review-side-metric-icon {
|
||||||
|
color: #059669;
|
||||||
|
border-color: rgba(16, 185, 129, 0.22);
|
||||||
|
background: #ecfdf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-side-metric-card.invalid .review-side-metric-icon {
|
||||||
|
color: #ef4444;
|
||||||
|
border-color: rgba(239, 68, 68, 0.22);
|
||||||
|
background: #fef2f2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.review-side-metric-copy {
|
.review-side-metric-copy {
|
||||||
|
|||||||
@@ -298,6 +298,16 @@
|
|||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.review-next-step-rich-copy {
|
||||||
|
margin-top: 30px;
|
||||||
|
color: #475569;
|
||||||
|
line-height: 1.64;
|
||||||
|
}
|
||||||
|
|
||||||
|
.review-next-step-rich-copy :deep(.markdown-action-paragraph) {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.review-section-card {
|
.review-section-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
.review-overlay {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.review-preview-modal {
|
.review-preview-modal {
|
||||||
width: min(980px, calc(100vw - 40px));
|
width: min(980px, calc(100vw - 40px));
|
||||||
max-height: min(92vh, calc(100vh - 32px));
|
max-height: min(92vh, calc(100vh - 32px));
|
||||||
|
margin: auto;
|
||||||
|
flex: none;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: auto minmax(0, 1fr);
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -63,48 +71,56 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.welcome-quick-actions-title {
|
.welcome-quick-actions-title {
|
||||||
margin: 0 0 22px;
|
margin: 0 0 16px !important;
|
||||||
color: #64748b;
|
color: #64748b;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
font-weight: 800;
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-quick-action-grid {
|
.welcome-quick-action-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 7px;
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-quick-action-btn {
|
.welcome-quick-action-btn {
|
||||||
min-height: 30px;
|
min-height: 32px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 5px;
|
gap: 6px;
|
||||||
padding: 0 11px;
|
padding: 0 14px;
|
||||||
border: 1px solid rgba(191, 219, 254, 0.92);
|
border: 1px solid #cbd5e1;
|
||||||
border-radius: 999px;
|
border-radius: 12px;
|
||||||
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, rgba(239, 246, 255, 0.94) 100%);
|
background: #ffffff;
|
||||||
color: #1d4ed8;
|
color: #334155;
|
||||||
font-size: var(--wb-fs-chip);
|
font-size: var(--wb-fs-chip);
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
box-shadow: 0 4px 10px rgba(59, 130, 246, 0.07);
|
transition: all 0.2s ease;
|
||||||
transition: transform 0.16s ease, box-shadow 0.16s ease, border-color 0.16s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-quick-action-btn i {
|
.welcome-quick-action-btn i {
|
||||||
font-size: 13px;
|
font-size: 14px;
|
||||||
color: #2563eb;
|
color: #64748b;
|
||||||
|
transition: color 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-quick-action-btn:hover:not(:disabled) {
|
.welcome-quick-action-btn:hover:not(:disabled) {
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: rgba(59, 130, 246, 0.34);
|
border-color: #10b981;
|
||||||
box-shadow: 0 7px 14px rgba(59, 130, 246, 0.12);
|
background: #ecfdf5;
|
||||||
|
color: #059669;
|
||||||
|
box-shadow: 0 4px 12px rgba(16, 185, 129, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-quick-action-btn:hover:not(:disabled) i {
|
||||||
|
color: #059669;
|
||||||
}
|
}
|
||||||
|
|
||||||
.welcome-quick-action-btn:disabled {
|
.welcome-quick-action-btn:disabled {
|
||||||
opacity: 0.48;
|
opacity: 0.5;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -522,8 +522,10 @@
|
|||||||
|
|
||||||
.insight-panel-shell {
|
.insight-panel-shell {
|
||||||
flex: none;
|
flex: none;
|
||||||
|
display: flex;
|
||||||
width: clamp(300px, 28vw, 420px);
|
width: clamp(300px, 28vw, 420px);
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
margin-left: 0;
|
margin-left: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -634,6 +636,30 @@
|
|||||||
box-shadow: 0 10px 22px rgba(226, 232, 240, 0.48);
|
box-shadow: 0 10px 22px rgba(226, 232, 240, 0.48);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-bubble-review-risk-low {
|
||||||
|
border-color: rgba(37, 99, 235, 0.72);
|
||||||
|
background: linear-gradient(180deg, rgba(239, 246, 255, 0.72), rgba(255, 255, 255, 0.96));
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(37, 99, 235, 0.12),
|
||||||
|
0 12px 24px rgba(37, 99, 235, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble-review-risk-medium {
|
||||||
|
border-color: rgba(217, 119, 6, 0.78);
|
||||||
|
background: linear-gradient(180deg, rgba(255, 251, 235, 0.76), rgba(255, 255, 255, 0.96));
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(217, 119, 6, 0.14),
|
||||||
|
0 12px 24px rgba(217, 119, 6, 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-bubble-review-risk-high {
|
||||||
|
border-color: rgba(220, 38, 38, 0.78);
|
||||||
|
background: linear-gradient(180deg, rgba(254, 242, 242, 0.78), rgba(255, 255, 255, 0.96));
|
||||||
|
box-shadow:
|
||||||
|
0 0 0 2px rgba(220, 38, 38, 0.14),
|
||||||
|
0 12px 24px rgba(220, 38, 38, 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
.message-meta {
|
.message-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -814,6 +840,26 @@
|
|||||||
color: #1d4ed8;
|
color: #1d4ed8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.message-answer-markdown :deep(.markdown-action-link-next),
|
||||||
|
.message-answer-markdown :deep(.markdown-action-link-edit) {
|
||||||
|
color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-answer-markdown :deep(.markdown-risk-text-low) {
|
||||||
|
color: #2563eb;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-answer-markdown :deep(.markdown-risk-text-medium) {
|
||||||
|
color: #d97706;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-answer-markdown :deep(.markdown-risk-text-high) {
|
||||||
|
color: #dc2626;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
.message-answer-markdown :deep(.markdown-table-wrap) {
|
.message-answer-markdown :deep(.markdown-table-wrap) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
|
|||||||
@@ -2,7 +2,8 @@
|
|||||||
<aside class="rail" aria-label="主导航">
|
<aside class="rail" aria-label="主导航">
|
||||||
<div class="rail-brand">
|
<div class="rail-brand">
|
||||||
<div class="brand-mark" aria-hidden="true">
|
<div class="brand-mark" aria-hidden="true">
|
||||||
<svg viewBox="0 0 36 36">
|
<img v-if="companyLogo" :src="companyLogo" alt="System Logo" class="custom-logo" />
|
||||||
|
<svg v-else viewBox="0 0 36 36">
|
||||||
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
|
<path d="M19.8 4.5c5.7 1.1 9.9 5.7 10.5 11.6-2.8-.9-5.5-.7-7.9.6-2.8 1.5-4.5 4.3-5.2 8.2-4.4-2.8-6.5-6.5-6.3-11.1.2-4.2 3.5-7.8 8.9-9.3Z" />
|
||||||
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
|
<path d="M9 7.6c-3 3.5-4 7.3-2.9 11.2 1.2 4.2 4.6 7 10.1 8.5-2 1.8-4.6 2.6-7.6 2.3C5.1 26.7 3.5 23.1 3.7 19 4 14.4 5.7 10.6 9 7.6Z" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -57,6 +58,10 @@ const props = defineProps({
|
|||||||
type: String,
|
type: String,
|
||||||
default: ''
|
default: ''
|
||||||
},
|
},
|
||||||
|
companyLogo: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
currentUser: {
|
currentUser: {
|
||||||
type: Object,
|
type: Object,
|
||||||
default: () => ({
|
default: () => ({
|
||||||
@@ -145,6 +150,14 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
color: #07936f;
|
color: #07936f;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-logo {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
}
|
}
|
||||||
|
|
||||||
.brand-mark svg {
|
.brand-mark svg {
|
||||||
@@ -195,10 +208,9 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-btn.active {
|
.nav-btn.active {
|
||||||
background: linear-gradient(90deg, rgba(16, 185, 129, 0.16), rgba(16, 185, 129, 0.08));
|
background: #ecfdf5;
|
||||||
border-color: rgba(16, 185, 129, 0.1);
|
border-color: rgba(16, 185, 129, 0.12);
|
||||||
color: #059669;
|
color: #059669;
|
||||||
box-shadow: inset 3px 0 0 #10b981;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon {
|
.nav-icon {
|
||||||
@@ -224,7 +236,7 @@ const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 750;
|
font-weight: 700;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
727
web/src/components/logs/KnowledgeIngestRunPanel.vue
Normal file
727
web/src/components/logs/KnowledgeIngestRunPanel.vue
Normal file
@@ -0,0 +1,727 @@
|
|||||||
|
<template>
|
||||||
|
<article class="knowledge-ingest-panel panel">
|
||||||
|
<header class="ingest-head">
|
||||||
|
<div>
|
||||||
|
<span class="eyebrow">LightRAG 知识归集</span>
|
||||||
|
<h3>{{ model.folder || '未指定目录' }}</h3>
|
||||||
|
<p>{{ model.phaseLabel }} · {{ model.statusLabel }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="progress-ring" :aria-label="`归集进度 ${model.progress.percent}%`">
|
||||||
|
<strong>{{ model.progress.percent }}%</strong>
|
||||||
|
<span>进度</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="progress-bar" aria-hidden="true">
|
||||||
|
<span :style="{ width: `${model.progress.percent}%` }"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="metric-strip">
|
||||||
|
<div v-for="metric in model.metrics" :key="metric.label" class="metric-tile">
|
||||||
|
<span>{{ metric.label }}</span>
|
||||||
|
<strong>{{ metric.value }}</strong>
|
||||||
|
<small>{{ metric.hint }}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ingest-workspace">
|
||||||
|
<aside class="file-rail">
|
||||||
|
<button
|
||||||
|
v-for="document in model.documents"
|
||||||
|
:key="document.documentId"
|
||||||
|
type="button"
|
||||||
|
class="file-item"
|
||||||
|
:class="{ active: selectedDocumentId === document.documentId }"
|
||||||
|
@click="selectDocument(document.documentId)"
|
||||||
|
>
|
||||||
|
<i :class="documentIcon(document)"></i>
|
||||||
|
<span class="file-copy">
|
||||||
|
<strong>{{ document.name }}</strong>
|
||||||
|
<small>
|
||||||
|
{{ document.phaseLabel }} · {{ document.chunkCount }} chunk
|
||||||
|
</small>
|
||||||
|
</span>
|
||||||
|
<span class="mini-status" :class="document.statusTone">
|
||||||
|
{{ document.statusLabel }}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section v-if="selectedDocument" class="file-detail">
|
||||||
|
<div class="detail-topline">
|
||||||
|
<div>
|
||||||
|
<h4>{{ selectedDocument.name }}</h4>
|
||||||
|
<p>
|
||||||
|
{{ selectedDocument.folder || '根目录' }}
|
||||||
|
<span v-if="selectedDocument.extension"> · {{ selectedDocument.extension }}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span class="status-chip" :class="selectedDocument.statusTone">
|
||||||
|
{{ selectedDocument.statusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="detail-stats">
|
||||||
|
<div>
|
||||||
|
<span>原文字符</span>
|
||||||
|
<strong>{{ formatKnowledgeMetric(selectedDocument.textChars) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>索引字符</span>
|
||||||
|
<strong>{{ formatKnowledgeMetric(selectedDocument.indexedTextChars) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>Chunk</span>
|
||||||
|
<strong>{{ formatKnowledgeMetric(selectedDocument.chunkCount) }}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>实体 / 关系</span>
|
||||||
|
<strong>
|
||||||
|
{{ formatKnowledgeMetric(selectedDocument.entityCount) }}
|
||||||
|
/
|
||||||
|
{{ formatKnowledgeMetric(selectedDocument.relationCount) }}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="selectedDocument.error" class="error-note">
|
||||||
|
{{ selectedDocument.error }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="detail-section-grid">
|
||||||
|
<section class="detail-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h5>Chunk 信息</h5>
|
||||||
|
<span>{{ selectedDocument.chunks.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedDocument.chunks.length" class="chunk-list">
|
||||||
|
<div v-for="chunk in selectedDocument.chunks" :key="chunk.id" class="chunk-row">
|
||||||
|
<span class="chunk-index">#{{ chunk.order + 1 }}</span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ compactId(chunk.id) }}</strong>
|
||||||
|
<p>{{ chunk.summary || '暂无摘要' }}</p>
|
||||||
|
</div>
|
||||||
|
<small>{{ chunk.tokens }} tokens</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="compact-empty">暂无 chunk 明细</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h5>章节提取</h5>
|
||||||
|
<span>{{ selectedDocument.sectionCount }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedDocument.sections.length" class="section-list">
|
||||||
|
<div
|
||||||
|
v-for="section in selectedDocument.sections"
|
||||||
|
:key="section.title"
|
||||||
|
class="section-row"
|
||||||
|
>
|
||||||
|
<strong>{{ section.title }}</strong>
|
||||||
|
<p>{{ section.excerpt || '暂无章节摘要' }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="compact-empty">暂无章节信息</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="detail-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h5>处理事件</h5>
|
||||||
|
<span>{{ selectedDocument.events.length }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="selectedDocument.events.length" class="event-list">
|
||||||
|
<div
|
||||||
|
v-for="event in selectedDocument.events"
|
||||||
|
:key="`${event.at}-${event.message}`"
|
||||||
|
class="event-row"
|
||||||
|
:class="event.level"
|
||||||
|
>
|
||||||
|
<span></span>
|
||||||
|
<div>
|
||||||
|
<strong>{{ formatEventTime(event.at) }}</strong>
|
||||||
|
<p>{{ event.message }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="compact-empty">暂无处理事件</div>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="graph-section">
|
||||||
|
<div class="section-head">
|
||||||
|
<h4>图谱形成</h4>
|
||||||
|
<span>
|
||||||
|
{{ formatKnowledgeMetric(model.graph.entityCount) }} 实体 ·
|
||||||
|
{{ formatKnowledgeMetric(model.graph.relationCount) }} 关系
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="graph-grid">
|
||||||
|
<div class="graph-pane">
|
||||||
|
<h5>实体</h5>
|
||||||
|
<div v-if="model.graph.entities.length" class="entity-cloud">
|
||||||
|
<span v-for="entity in model.graph.entities" :key="entity">{{ entity }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="compact-empty">暂无实体</div>
|
||||||
|
</div>
|
||||||
|
<div class="graph-pane">
|
||||||
|
<h5>关系</h5>
|
||||||
|
<div v-if="model.graph.relations.length" class="relation-list">
|
||||||
|
<div
|
||||||
|
v-for="relation in model.graph.relations"
|
||||||
|
:key="`${relation.source}-${relation.target}-${relation.type}`"
|
||||||
|
class="relation-row"
|
||||||
|
>
|
||||||
|
<strong>{{ relation.source }}</strong>
|
||||||
|
<i class="mdi mdi-arrow-right-thin"></i>
|
||||||
|
<strong>{{ relation.target }}</strong>
|
||||||
|
<span>{{ relation.type }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="compact-empty">暂无关系</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildKnowledgeIngestLogModel,
|
||||||
|
formatKnowledgeMetric
|
||||||
|
} from '../../utils/knowledgeIngestLogModel.js'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
run: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedDocumentId = ref('')
|
||||||
|
const model = computed(() => buildKnowledgeIngestLogModel(props.run))
|
||||||
|
const selectedDocument = computed(
|
||||||
|
() => model.value.documents.find((item) => item.documentId === selectedDocumentId.value) || null
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => model.value.selectedDocumentId,
|
||||||
|
(nextDocumentId) => {
|
||||||
|
if (!nextDocumentId) {
|
||||||
|
selectedDocumentId.value = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!selectedDocumentId.value || !model.value.documents.some((item) => item.documentId === selectedDocumentId.value)) {
|
||||||
|
selectedDocumentId.value = nextDocumentId
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
function selectDocument(documentId) {
|
||||||
|
selectedDocumentId.value = documentId
|
||||||
|
}
|
||||||
|
|
||||||
|
function documentIcon(document) {
|
||||||
|
const extension = String(document?.extension || '').toLowerCase()
|
||||||
|
if (extension === 'pdf') return 'mdi mdi-file-pdf-box'
|
||||||
|
if (['doc', 'docx'].includes(extension)) return 'mdi mdi-file-word-box'
|
||||||
|
if (['xls', 'xlsx', 'csv'].includes(extension)) return 'mdi mdi-file-excel-box'
|
||||||
|
if (['ppt', 'pptx'].includes(extension)) return 'mdi mdi-file-powerpoint-box'
|
||||||
|
return 'mdi mdi-file-document-outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function compactId(value) {
|
||||||
|
const text = String(value || '').trim()
|
||||||
|
if (text.length <= 18) return text || 'chunk'
|
||||||
|
return `${text.slice(0, 8)}...${text.slice(-6)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEventTime(value) {
|
||||||
|
if (!value) return '刚刚'
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return String(value)
|
||||||
|
return date.toLocaleTimeString('zh-CN', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.knowledge-ingest-panel {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingest-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.eyebrow {
|
||||||
|
color: #0f766e;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingest-head h3 {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingest-head p {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring {
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #d7e0ea;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring strong,
|
||||||
|
.progress-ring span {
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring strong {
|
||||||
|
margin-top: -12px;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring span {
|
||||||
|
margin-top: 26px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 7px;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #e5eaf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar span {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: inherit;
|
||||||
|
background: linear-gradient(90deg, #0f766e, #2563eb);
|
||||||
|
transition: width 0.24s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-strip {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tile {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 11px 12px;
|
||||||
|
border: 1px solid #e5edf5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tile span,
|
||||||
|
.metric-tile small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-tile strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ingest-workspace {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(230px, 0.85fr) minmax(0, 2fr);
|
||||||
|
gap: 14px;
|
||||||
|
min-height: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-rail {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 58px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid #e5edf5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item.active {
|
||||||
|
border-color: rgba(15, 118, 110, 0.38);
|
||||||
|
background: #f0fdfa;
|
||||||
|
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item > i {
|
||||||
|
color: #334155;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-copy {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-copy strong,
|
||||||
|
.file-copy small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-copy strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-copy small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-status,
|
||||||
|
.status-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 24px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-status.success,
|
||||||
|
.status-chip.success {
|
||||||
|
background: #dcfce7;
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-status.warning,
|
||||||
|
.status-chip.warning {
|
||||||
|
background: #fef3c7;
|
||||||
|
color: #92400e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-status.danger,
|
||||||
|
.status-chip.danger {
|
||||||
|
background: #fee2e2;
|
||||||
|
color: #991b1b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-status.muted,
|
||||||
|
.status-chip.muted {
|
||||||
|
background: #eef2f7;
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-detail {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-topline {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 14px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
border-bottom: 1px solid #e5edf5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-topline h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-topline p {
|
||||||
|
margin: 5px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stats div {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stats span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-stats strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-note {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #fecaca;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff1f2;
|
||||||
|
color: #991b1b;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section-grid,
|
||||||
|
.graph-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-section,
|
||||||
|
.graph-section,
|
||||||
|
.graph-pane {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head h4,
|
||||||
|
.section-head h5,
|
||||||
|
.graph-pane h5 {
|
||||||
|
margin: 0;
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-head span {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-list,
|
||||||
|
.section-list,
|
||||||
|
.event-list,
|
||||||
|
.relation-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-row,
|
||||||
|
.section-row,
|
||||||
|
.event-row,
|
||||||
|
.relation-row {
|
||||||
|
min-width: 0;
|
||||||
|
border: 1px solid #e5edf5;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-index {
|
||||||
|
color: #2563eb;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 850;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-row strong,
|
||||||
|
.section-row strong,
|
||||||
|
.event-row strong,
|
||||||
|
.relation-row strong {
|
||||||
|
color: #0f172a;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-row p,
|
||||||
|
.section-row p,
|
||||||
|
.event-row p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chunk-row small {
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-row {
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-row > span {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
margin-top: 5px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.event-row.error > span {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-cloud {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entity-cloud span {
|
||||||
|
max-width: 100%;
|
||||||
|
padding: 5px 9px;
|
||||||
|
border: 1px solid #bfdbfe;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #1d4ed8;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row strong {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row i {
|
||||||
|
color: #0f766e;
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row span {
|
||||||
|
padding: 3px 7px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #f1f5f9;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.compact-empty {
|
||||||
|
min-height: 42px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px dashed #cbd5e1;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #64748b;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 980px) {
|
||||||
|
.metric-strip,
|
||||||
|
.detail-stats,
|
||||||
|
.detail-section-grid,
|
||||||
|
.graph-grid,
|
||||||
|
.ingest-workspace {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-rail {
|
||||||
|
max-height: 260px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.ingest-head,
|
||||||
|
.detail-topline {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-ring {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-item {
|
||||||
|
grid-template-columns: auto minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-status {
|
||||||
|
grid-column: 2;
|
||||||
|
justify-self: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.relation-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -3,7 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
|
|||||||
|
|
||||||
import { icons } from '../data/icons.js'
|
import { icons } from '../data/icons.js'
|
||||||
|
|
||||||
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'logs', 'employees', 'settings']
|
export const appViews = ['overview', 'workbench', 'requests', 'approval', 'archive', 'policies', 'audit', 'employees', 'logs', 'settings']
|
||||||
|
|
||||||
export const navItems = [
|
export const navItems = [
|
||||||
{
|
{
|
||||||
@@ -62,14 +62,6 @@ export const navItems = [
|
|||||||
title: '任务规则中心',
|
title: '任务规则中心',
|
||||||
desc: '集中管理规则文件、外部 MCP 服务与定时任务调度。'
|
desc: '集中管理规则文件、外部 MCP 服务与定时任务调度。'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'logs',
|
|
||||||
label: '日志管理',
|
|
||||||
navHint: '查看 Hermes 调用与系统运行日志',
|
|
||||||
icon: icons.logs,
|
|
||||||
title: '日志管理',
|
|
||||||
desc: '集中查看 Hermes 归纳任务进度、调用明细与系统运行日志。'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 'employees',
|
id: 'employees',
|
||||||
label: '员工管理',
|
label: '员工管理',
|
||||||
@@ -78,6 +70,14 @@ export const navItems = [
|
|||||||
title: '员工与组织管理',
|
title: '员工与组织管理',
|
||||||
desc: '维护员工账号、组织结构与角色权限。'
|
desc: '维护员工账号、组织结构与角色权限。'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'logs',
|
||||||
|
label: '日志管理',
|
||||||
|
navHint: '查看 Hermes 调用与系统运行日志',
|
||||||
|
icon: icons.logs,
|
||||||
|
title: '日志管理',
|
||||||
|
desc: '集中查看 Hermes 归纳任务进度、调用明细与系统运行日志。'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'settings',
|
id: 'settings',
|
||||||
label: '系统设置',
|
label: '系统设置',
|
||||||
@@ -101,13 +101,34 @@ const viewRouteNames = {
|
|||||||
settings: 'app-settings'
|
settings: 'app-settings'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const routeNameViews = Object.fromEntries(
|
||||||
|
Object.entries(viewRouteNames).map(([view, routeName]) => [routeName, view])
|
||||||
|
)
|
||||||
|
|
||||||
|
routeNameViews['app-request-detail'] = 'requests'
|
||||||
|
routeNameViews['app-log-detail'] = 'logs'
|
||||||
|
|
||||||
|
export function resolveAppViewFromRoute(route) {
|
||||||
|
const routeName = String(route?.name || '').trim()
|
||||||
|
if (routeNameViews[routeName]) {
|
||||||
|
return routeNameViews[routeName]
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaView = String(route?.meta?.appView || '').trim()
|
||||||
|
return appViews.includes(metaView) ? metaView : 'overview'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTargetRouteName(view) {
|
||||||
|
return viewRouteNames[view] || viewRouteNames.overview
|
||||||
|
}
|
||||||
|
|
||||||
export function useNavigation() {
|
export function useNavigation() {
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
const activeView = computed({
|
const activeView = computed({
|
||||||
get() {
|
get() {
|
||||||
return route.meta.appView || 'overview'
|
return resolveAppViewFromRoute(route)
|
||||||
},
|
},
|
||||||
set(view) {
|
set(view) {
|
||||||
setView(view)
|
setView(view)
|
||||||
@@ -119,13 +140,13 @@ export function useNavigation() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
function setView(view) {
|
function setView(view) {
|
||||||
const targetName = viewRouteNames[view] || viewRouteNames.overview
|
const targetName = resolveTargetRouteName(view)
|
||||||
|
|
||||||
if (route.name === targetName) {
|
if (route.name === targetName) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
router.push({ name: targetName })
|
router.push({ name: targetName, params: {}, query: {}, hash: '' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return { activeView, currentView, setView, navItems }
|
return { activeView, currentView, setView, navItems }
|
||||||
|
|||||||
@@ -13,12 +13,12 @@ const EXPENSE_TYPE_LABELS = {
|
|||||||
ride_ticket: '乘车',
|
ride_ticket: '乘车',
|
||||||
travel_allowance: '出差补贴',
|
travel_allowance: '出差补贴',
|
||||||
entertainment: '业务招待费',
|
entertainment: '业务招待费',
|
||||||
office: '办公费',
|
office: '办公用品费',
|
||||||
meeting: '会务费',
|
meeting: '会务费',
|
||||||
training: '培训费',
|
training: '培训费',
|
||||||
hotel: '住宿费',
|
hotel: '住宿费',
|
||||||
transport: '交通费',
|
transport: '交通费',
|
||||||
meal: '餐费',
|
meal: '业务招待费',
|
||||||
other: '其他费用'
|
other: '其他费用'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
486
web/src/composables/useSettings.js
Normal file
486
web/src/composables/useSettings.js
Normal file
@@ -0,0 +1,486 @@
|
|||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useSystemState } from './useSystemState.js'
|
||||||
|
import { fetchSettings, saveSettings } from '../services/settings.js'
|
||||||
|
import { useToast } from './useToast.js'
|
||||||
|
import {
|
||||||
|
isHermesEmployeeSettingsReady
|
||||||
|
} from '../utils/hermesEmployeeSettingsModel.js'
|
||||||
|
import {
|
||||||
|
LOG_LEVELS,
|
||||||
|
PROVIDER_OPTIONS,
|
||||||
|
SECTION_DEFINITIONS,
|
||||||
|
SESSION_RETENTION_OPTIONS,
|
||||||
|
buildDefaultState,
|
||||||
|
buildLlmPayload,
|
||||||
|
buildRenderPayload,
|
||||||
|
computeSectionStatus,
|
||||||
|
isModelConfigReady,
|
||||||
|
isRenderSecretMask,
|
||||||
|
maskConfiguredModelSecrets,
|
||||||
|
maskConfiguredRenderSecret,
|
||||||
|
mergeState,
|
||||||
|
normalizeValue,
|
||||||
|
persistSettings,
|
||||||
|
readStoredSettings
|
||||||
|
} from '../utils/settingsModelHelper.js'
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
|
||||||
|
|
||||||
|
const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value)
|
||||||
|
const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings()))
|
||||||
|
const activeSection = ref('profile')
|
||||||
|
const sessionRetentionPickerOpen = ref(false)
|
||||||
|
const sessionRetentionPickerRef = ref(null)
|
||||||
|
const logoInputRef = ref(null)
|
||||||
|
|
||||||
|
const sections = SECTION_DEFINITIONS
|
||||||
|
const logLevels = LOG_LEVELS
|
||||||
|
const providerOptions = PROVIDER_OPTIONS
|
||||||
|
const sessionRetentionOptions = SESSION_RETENTION_OPTIONS
|
||||||
|
|
||||||
|
const sectionStatus = computed(() => computeSectionStatus(pageState.value))
|
||||||
|
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
|
||||||
|
const activeSectionConfig = computed(
|
||||||
|
() => sections.find((section) => section.id === activeSection.value) || sections[0]
|
||||||
|
)
|
||||||
|
|
||||||
|
function updateBrandPreviewFromState(state) {
|
||||||
|
updateCompanyProfilePreview({
|
||||||
|
name: normalizeValue(state.companyForm.displayName),
|
||||||
|
code: normalizeValue(state.companyForm.companyCode),
|
||||||
|
adminEmail: normalizeValue(state.adminForm.adminEmail),
|
||||||
|
logo: state.companyForm.logo
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyLoadedSnapshot(snapshot, options = {}) {
|
||||||
|
const {
|
||||||
|
mergeDraft = false,
|
||||||
|
preserveModelApiKeys = false,
|
||||||
|
preserveAdminPasswords = false,
|
||||||
|
preserveRenderSecret = false,
|
||||||
|
preserveMailPassword = false
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const currentState = pageState.value
|
||||||
|
let nextState = mergeState(buildResolvedDefaults(), snapshot)
|
||||||
|
|
||||||
|
if (mergeDraft) {
|
||||||
|
nextState = mergeState(nextState, readStoredSettings())
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preserveModelApiKeys) {
|
||||||
|
nextState.llmForm.mainApiKey = currentState.llmForm.mainApiKey
|
||||||
|
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
|
||||||
|
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
|
||||||
|
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preserveAdminPasswords) {
|
||||||
|
nextState.adminForm.newPassword = currentState.adminForm.newPassword
|
||||||
|
nextState.adminForm.confirmPassword = currentState.adminForm.confirmPassword
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preserveRenderSecret) {
|
||||||
|
nextState.renderForm.jwtSecret = currentState.renderForm.jwtSecret
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preserveMailPassword) {
|
||||||
|
nextState.mailForm.password = currentState.mailForm.password
|
||||||
|
}
|
||||||
|
|
||||||
|
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
|
||||||
|
persistSettings(pageState.value)
|
||||||
|
updateBrandPreviewFromState(pageState.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSettingsSnapshot() {
|
||||||
|
try {
|
||||||
|
const snapshot = await fetchSettings()
|
||||||
|
applyLoadedSnapshot(snapshot, { mergeDraft: true })
|
||||||
|
} catch (error) {
|
||||||
|
persistSettings(pageState.value)
|
||||||
|
updateBrandPreviewFromState(pageState.value)
|
||||||
|
toast(error.message || '无法加载已保存设置,继续使用当前会话草稿。')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSettingsPayload() {
|
||||||
|
return {
|
||||||
|
companyForm: { ...pageState.value.companyForm },
|
||||||
|
adminForm: { ...pageState.value.adminForm },
|
||||||
|
sessionForm: { ...pageState.value.sessionForm },
|
||||||
|
llmForm: buildLlmPayload(pageState.value.llmForm),
|
||||||
|
renderForm: buildRenderPayload(pageState.value.renderForm),
|
||||||
|
logForm: { ...pageState.value.logForm },
|
||||||
|
mailForm: { ...pageState.value.mailForm }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function persistRemoteSettings(successMessage, options = {}) {
|
||||||
|
try {
|
||||||
|
const snapshot = await saveSettings(buildSettingsPayload())
|
||||||
|
applyLoadedSnapshot(snapshot, options)
|
||||||
|
toast(successMessage)
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
toast(error.message || '设置保存失败,请稍后重试。')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function activateSection(sectionId) {
|
||||||
|
sessionRetentionPickerOpen.value = false
|
||||||
|
activeSection.value = sectionId
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleBoolean(formKey, field) {
|
||||||
|
pageState.value[formKey][field] = !pageState.value[formKey][field]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSessionRetentionPicker() {
|
||||||
|
sessionRetentionPickerOpen.value = !sessionRetentionPickerOpen.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeSessionRetentionPicker() {
|
||||||
|
sessionRetentionPickerOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectSessionRetentionDays(value) {
|
||||||
|
pageState.value.sessionForm.conversationRetentionDays = Number(value)
|
||||||
|
closeSessionRetentionPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDocumentPointerDown(event) {
|
||||||
|
if (!sessionRetentionPickerOpen.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = event.target
|
||||||
|
if (sessionRetentionPickerRef.value?.contains(target)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
closeSessionRetentionPicker()
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRenderSecretMask() {
|
||||||
|
if (isRenderSecretMask(pageState.value.renderForm.jwtSecret)) {
|
||||||
|
pageState.value.renderForm.jwtSecret = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveProfileSection() {
|
||||||
|
const companyForm = pageState.value.companyForm
|
||||||
|
|
||||||
|
if (!normalizeValue(companyForm.companyName)) {
|
||||||
|
toast('请输入企业名称。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(companyForm.displayName)) {
|
||||||
|
toast('请输入系统显示名称。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(companyForm.copyright)) {
|
||||||
|
toast('请输入版权信息。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pageState.value.mailForm.senderName = normalizeValue(companyForm.displayName)
|
||||||
|
await persistRemoteSettings('企业信息已保存并应用到当前系统。', {
|
||||||
|
preserveModelApiKeys: true,
|
||||||
|
preserveAdminPasswords: true,
|
||||||
|
preserveRenderSecret: true,
|
||||||
|
preserveMailPassword: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAdminSection() {
|
||||||
|
const adminForm = pageState.value.adminForm
|
||||||
|
|
||||||
|
if (!normalizeValue(adminForm.adminAccount)) {
|
||||||
|
toast('请输入管理员账号。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(adminForm.adminEmail)) {
|
||||||
|
toast('请输入管理员邮箱。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(adminForm.sessionTimeout) < 5) {
|
||||||
|
toast('会话超时时间不能少于 5 分钟。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminForm.newPassword) {
|
||||||
|
if (adminForm.newPassword.length < 5) {
|
||||||
|
toast('管理员密码至少需要 5 位。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adminForm.newPassword !== adminForm.confirmPassword) {
|
||||||
|
toast('两次输入的管理员密码不一致。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistRemoteSettings('管理员安全设置已保存。', {
|
||||||
|
preserveModelApiKeys: true,
|
||||||
|
preserveAdminPasswords: false,
|
||||||
|
preserveRenderSecret: true,
|
||||||
|
preserveMailPassword: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHermesMaster() {
|
||||||
|
pageState.value.hermesForm.masterEnabled = !pageState.value.hermesForm.masterEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHermesFlag(field) {
|
||||||
|
pageState.value.hermesForm[field] = !pageState.value.hermesForm[field]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleHermesTask(taskId) {
|
||||||
|
const schedule = pageState.value.hermesForm.schedules[taskId]
|
||||||
|
if (!schedule) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = !(pageState.value.hermesForm.capabilities[taskId] && schedule.enabled)
|
||||||
|
pageState.value.hermesForm.capabilities[taskId] = enabled
|
||||||
|
schedule.enabled = enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHermesTaskTime({ taskId, time }) {
|
||||||
|
const schedule = pageState.value.hermesForm.schedules[taskId]
|
||||||
|
if (!schedule) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
schedule.time = time
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHermesSection() {
|
||||||
|
if (!isHermesEmployeeSettingsReady(pageState.value.hermesForm)) {
|
||||||
|
toast('请至少开启一项 Hermes 能力,或关闭总控开关。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
persistSettings(pageState.value)
|
||||||
|
toast('数字员工设置已保存。')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSessionSection() {
|
||||||
|
const sessionForm = pageState.value.sessionForm
|
||||||
|
const retentionDays = Number(sessionForm.conversationRetentionDays)
|
||||||
|
|
||||||
|
if (retentionDays < 1 || retentionDays > 10) {
|
||||||
|
toast('会话保留天数必须在 1 到 10 天之间。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistRemoteSettings('会话设置已保存。', {
|
||||||
|
preserveModelApiKeys: true,
|
||||||
|
preserveAdminPasswords: true,
|
||||||
|
preserveRenderSecret: true,
|
||||||
|
preserveMailPassword: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLlmSection() {
|
||||||
|
const llmForm = pageState.value.llmForm
|
||||||
|
const modelConfigs = [
|
||||||
|
['主模型', llmForm.mainProvider, llmForm.mainModel, llmForm.mainEndpoint],
|
||||||
|
['备份模型', llmForm.backupProvider, llmForm.backupModel, llmForm.backupEndpoint],
|
||||||
|
['Embedding 模型', llmForm.embeddingProvider, llmForm.embeddingModel, llmForm.embeddingEndpoint],
|
||||||
|
['Reranker 模型', llmForm.rerankerProvider, llmForm.rerankerModel, llmForm.rerankerEndpoint]
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const [label, provider, model, endpoint] of modelConfigs) {
|
||||||
|
if (!isModelConfigReady(provider, model, endpoint)) {
|
||||||
|
toast(`请完整填写${label}的供应商、模型名称和接口地址。`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistRemoteSettings('模型配置已保存。', {
|
||||||
|
preserveModelApiKeys: true,
|
||||||
|
preserveAdminPasswords: true,
|
||||||
|
preserveRenderSecret: true,
|
||||||
|
preserveMailPassword: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveRenderingSection() {
|
||||||
|
const renderForm = pageState.value.renderForm
|
||||||
|
|
||||||
|
if (renderForm.enabled && !normalizeValue(renderForm.publicUrl)) {
|
||||||
|
toast('启用 ONLYOFFICE 时请输入服务地址。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (renderForm.enabled && !normalizeValue(renderForm.jwtSecret) && !renderForm.jwtSecretConfigured) {
|
||||||
|
toast('启用 ONLYOFFICE 时请输入 JWT 密钥。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistRemoteSettings('文件渲染配置已保存。', {
|
||||||
|
preserveModelApiKeys: true,
|
||||||
|
preserveAdminPasswords: true,
|
||||||
|
preserveRenderSecret: false,
|
||||||
|
preserveMailPassword: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLogsSection() {
|
||||||
|
const logForm = pageState.value.logForm
|
||||||
|
|
||||||
|
if (!normalizeValue(logForm.level) || Number(logForm.retentionDays) <= 0) {
|
||||||
|
toast('请填写有效的日志级别和留存天数。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(logForm.logPath)) {
|
||||||
|
toast('请输入日志路径。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistRemoteSettings('日志策略已保存。', {
|
||||||
|
preserveModelApiKeys: true,
|
||||||
|
preserveAdminPasswords: true,
|
||||||
|
preserveRenderSecret: true,
|
||||||
|
preserveMailPassword: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMailSection() {
|
||||||
|
const mailForm = pageState.value.mailForm
|
||||||
|
|
||||||
|
if (!normalizeValue(mailForm.smtpHost) || Number(mailForm.port) <= 0) {
|
||||||
|
toast('请填写有效的 SMTP Host 和端口。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalizeValue(mailForm.senderAddress) || !normalizeValue(mailForm.username)) {
|
||||||
|
toast('请填写发件人邮箱和 SMTP 登录账号。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await persistRemoteSettings('邮箱配置已保存。', {
|
||||||
|
preserveModelApiKeys: true,
|
||||||
|
preserveAdminPasswords: true,
|
||||||
|
preserveRenderSecret: true,
|
||||||
|
preserveMailPassword: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveActiveSection() {
|
||||||
|
if (activeSection.value === 'profile') {
|
||||||
|
await saveProfileSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'admin') {
|
||||||
|
await saveAdminSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'session') {
|
||||||
|
await saveSessionSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'hermes') {
|
||||||
|
saveHermesSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'llm') {
|
||||||
|
await saveLlmSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'logs') {
|
||||||
|
await saveLogsSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeSection.value === 'rendering') {
|
||||||
|
await saveRenderingSection()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveMailSection()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
||||||
|
}
|
||||||
|
loadSettingsSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (typeof document !== 'undefined') {
|
||||||
|
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function triggerLogoUpload() {
|
||||||
|
logoInputRef.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogoUpload(event) {
|
||||||
|
const file = event.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
toast('请上传图片文件。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > 2 * 1024 * 1024) {
|
||||||
|
toast('图片大小不能超过 2MB。')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (e) => {
|
||||||
|
pageState.value.companyForm.logo = e.target.result
|
||||||
|
}
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeSection,
|
||||||
|
activeSectionConfig,
|
||||||
|
activateSection,
|
||||||
|
clearRenderSecretMask,
|
||||||
|
completedSectionCount,
|
||||||
|
logLevels,
|
||||||
|
logoInputRef,
|
||||||
|
pageState,
|
||||||
|
providerOptions,
|
||||||
|
sessionRetentionOptions,
|
||||||
|
sessionRetentionPickerOpen,
|
||||||
|
sessionRetentionPickerRef,
|
||||||
|
saveActiveSection,
|
||||||
|
sectionStatus,
|
||||||
|
sections,
|
||||||
|
selectSessionRetentionDays,
|
||||||
|
toggleSessionRetentionPicker,
|
||||||
|
closeSessionRetentionPicker,
|
||||||
|
toggleBoolean,
|
||||||
|
toggleHermesFlag,
|
||||||
|
toggleHermesMaster,
|
||||||
|
toggleHermesTask,
|
||||||
|
updateHermesTaskTime,
|
||||||
|
triggerLogoUpload,
|
||||||
|
handleLogoUpload
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -383,7 +383,8 @@ const { toast } = useToast()
|
|||||||
const companyProfile = computed(() => ({
|
const companyProfile = computed(() => ({
|
||||||
name: bootstrapState.value.company?.name || '',
|
name: bootstrapState.value.company?.name || '',
|
||||||
code: bootstrapState.value.company?.code || '',
|
code: bootstrapState.value.company?.code || '',
|
||||||
adminEmail: bootstrapState.value.company?.admin_email || ''
|
adminEmail: bootstrapState.value.company?.admin_email || '',
|
||||||
|
logo: bootstrapState.value.company?.logo || ''
|
||||||
}))
|
}))
|
||||||
|
|
||||||
function updateCompanyProfilePreview(payload = {}) {
|
function updateCompanyProfilePreview(payload = {}) {
|
||||||
@@ -395,7 +396,8 @@ function updateCompanyProfilePreview(payload = {}) {
|
|||||||
...currentCompany,
|
...currentCompany,
|
||||||
...(payload.name !== undefined ? { name: payload.name } : {}),
|
...(payload.name !== undefined ? { name: payload.name } : {}),
|
||||||
...(payload.code !== undefined ? { code: payload.code } : {}),
|
...(payload.code !== undefined ? { code: payload.code } : {}),
|
||||||
...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {})
|
...(payload.adminEmail !== undefined ? { admin_email: payload.adminEmail } : {}),
|
||||||
|
...(payload.logo !== undefined ? { logo: payload.logo } : {})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ export const documents = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'DOC-2026-0503',
|
id: 'DOC-2026-0503',
|
||||||
type: '办公费报销',
|
type: '办公用品费报销',
|
||||||
typeTag: 'office',
|
typeTag: 'office',
|
||||||
applicant: '赵敏',
|
applicant: '赵敏',
|
||||||
dept: '研发 · 平台组',
|
dept: '研发 · 平台组',
|
||||||
@@ -59,7 +59,7 @@ export const documents = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
export const docTypes = ['全部类型', '个人报销单', '业务招待费', '办公费报销', '会务费报销']
|
export const docTypes = ['全部类型', '个人报销单', '业务招待费', '办公用品费报销', '会务费报销']
|
||||||
export const docStatuses = ['全部状态', '草稿', '审批中', '已完成', '待补件']
|
export const docStatuses = ['全部状态', '草稿', '审批中', '已完成', '待补件']
|
||||||
export const docMonths = ['2026-05', '2026-04', '2026-03', '2026-02']
|
export const docMonths = ['2026-05', '2026-04', '2026-03', '2026-02']
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,12 @@ const EXPENSE_SCENE_SELECTION_OPTIONS = [
|
|||||||
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' },
|
{ key: 'travel', label: '差旅费', description: '出差行程、住宿、跨城交通等费用', icon: 'mdi mdi-bag-suitcase-outline' },
|
||||||
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' },
|
{ key: 'transport', label: '交通费', description: '市内交通、打车、停车、通行等费用', icon: 'mdi mdi-car-outline' },
|
||||||
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' },
|
{ key: 'hotel', label: '住宿费', description: '单独住宿或酒店发票报销', icon: 'mdi mdi-bed-outline' },
|
||||||
{ key: 'entertainment', label: '业务招待费', description: '客户接待、餐饮招待等费用', icon: 'mdi mdi-food-fork-drink' },
|
{ key: 'meal', label: '业务招待费', description: '客户接待、工作餐、加班餐、餐饮票据等费用', icon: 'mdi mdi-food-fork-drink' },
|
||||||
{ key: 'office', label: '办公费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
|
{ key: 'meeting', label: '会务费', description: '会议、论坛、会场、参会等费用', icon: 'mdi mdi-account-tie-voice-outline' },
|
||||||
|
{ key: 'office', label: '办公用品费', description: '办公用品、低值易耗品等费用', icon: 'mdi mdi-briefcase-outline' },
|
||||||
|
{ key: 'training', label: '培训费', description: '培训课程、讲师费、教材认证等费用', icon: 'mdi mdi-school-outline' },
|
||||||
|
{ key: 'communication', label: '通讯费', description: '话费、流量、宽带、网络等费用', icon: 'mdi mdi-cellphone-message' },
|
||||||
|
{ key: 'welfare', label: '福利费', description: '团建、体检、慰问、节日福利等费用', icon: 'mdi mdi-gift-outline' },
|
||||||
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
|
{ key: 'other', label: '其他费用', description: '暂不属于以上类型的费用', icon: 'mdi mdi-dots-horizontal-circle-outline' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
150
web/src/utils/hermesEmployeeSettingsModel.js
Normal file
150
web/src/utils/hermesEmployeeSettingsModel.js
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
/** 数字员工设置:面向管理员的简明任务列表(频率固定,仅可调执行时间) */
|
||||||
|
export const HERMES_SIMPLE_TASKS = [
|
||||||
|
{
|
||||||
|
id: 'knowledgeAggregation',
|
||||||
|
label: '知识库同步',
|
||||||
|
hint: '同步制度文档与知识索引',
|
||||||
|
frequency: 'daily',
|
||||||
|
frequencyLabel: '每天'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ruleReviewDigest',
|
||||||
|
label: '规则待审提醒',
|
||||||
|
hint: '汇总待审规则并推送管理员',
|
||||||
|
frequency: 'daily',
|
||||||
|
frequencyLabel: '每天'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'riskSummary',
|
||||||
|
label: '风险每日巡检',
|
||||||
|
hint: '扫描报销、付款等风险信号',
|
||||||
|
frequency: 'daily',
|
||||||
|
frequencyLabel: '每天'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'archiveDigest',
|
||||||
|
label: '归档周报',
|
||||||
|
hint: '汇总已归档报销单',
|
||||||
|
frequency: 'weekly',
|
||||||
|
frequencyLabel: '每周一',
|
||||||
|
weekday: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'dailyStats',
|
||||||
|
label: '日报统计',
|
||||||
|
hint: '生成昨日报销与审批数据',
|
||||||
|
frequency: 'daily',
|
||||||
|
frequencyLabel: '每天'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monthlyStats',
|
||||||
|
label: '月报统计',
|
||||||
|
hint: '每月 1 号生成上月汇总',
|
||||||
|
frequency: 'monthly',
|
||||||
|
frequencyLabel: '每月 1 日',
|
||||||
|
monthDay: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'yearlyStats',
|
||||||
|
label: '年报统计',
|
||||||
|
hint: '每年 1 月 1 号生成上年汇总',
|
||||||
|
frequency: 'yearly',
|
||||||
|
frequencyLabel: '每年 1 月 1 日',
|
||||||
|
month: 1,
|
||||||
|
monthDay: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
function buildDefaultSchedules() {
|
||||||
|
const defaults = {
|
||||||
|
knowledgeAggregation: { enabled: true, frequency: 'daily', time: '00:00', weekday: 1, monthDay: 1, month: 1 },
|
||||||
|
ruleReviewDigest: { enabled: true, frequency: 'daily', time: '18:00', weekday: 5, monthDay: 1, month: 1 },
|
||||||
|
riskSummary: { enabled: true, frequency: 'daily', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
|
||||||
|
archiveDigest: { enabled: false, frequency: 'weekly', time: '10:30', weekday: 1, monthDay: 1, month: 1 },
|
||||||
|
dailyStats: { enabled: true, frequency: 'daily', time: '08:30', weekday: 1, monthDay: 1, month: 1 },
|
||||||
|
monthlyStats: { enabled: true, frequency: 'monthly', time: '09:00', weekday: 1, monthDay: 1, month: 1 },
|
||||||
|
yearlyStats: { enabled: false, frequency: 'yearly', time: '10:00', weekday: 1, monthDay: 1, month: 1 }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of HERMES_SIMPLE_TASKS) {
|
||||||
|
const schedule = defaults[task.id]
|
||||||
|
if (!schedule) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
schedule.frequency = task.frequency
|
||||||
|
if (task.weekday != null) {
|
||||||
|
schedule.weekday = task.weekday
|
||||||
|
}
|
||||||
|
if (task.monthDay != null) {
|
||||||
|
schedule.monthDay = task.monthDay
|
||||||
|
}
|
||||||
|
if (task.month != null) {
|
||||||
|
schedule.month = task.month
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaults
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultHermesEmployeeForm() {
|
||||||
|
return {
|
||||||
|
masterEnabled: true,
|
||||||
|
notifyOnFailure: true,
|
||||||
|
capabilities: {
|
||||||
|
knowledgeAggregation: true,
|
||||||
|
ruleReviewDigest: true,
|
||||||
|
riskSummary: true,
|
||||||
|
archiveDigest: false,
|
||||||
|
dailyStats: true,
|
||||||
|
monthlyStats: true,
|
||||||
|
yearlyStats: false
|
||||||
|
},
|
||||||
|
schedules: buildDefaultSchedules()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeHermesEmployeeForm(override = {}) {
|
||||||
|
const defaults = buildDefaultHermesEmployeeForm()
|
||||||
|
const schedules = { ...defaults.schedules }
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(override?.schedules || {})) {
|
||||||
|
schedules[key] = { ...defaults.schedules[key], ...value }
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const task of HERMES_SIMPLE_TASKS) {
|
||||||
|
if (schedules[task.id]) {
|
||||||
|
schedules[task.id].frequency = task.frequency
|
||||||
|
if (task.weekday != null) {
|
||||||
|
schedules[task.id].weekday = task.weekday
|
||||||
|
}
|
||||||
|
if (task.monthDay != null) {
|
||||||
|
schedules[task.id].monthDay = task.monthDay
|
||||||
|
}
|
||||||
|
if (task.month != null) {
|
||||||
|
schedules[task.id].month = task.month
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...defaults,
|
||||||
|
...override,
|
||||||
|
capabilities: {
|
||||||
|
...defaults.capabilities,
|
||||||
|
...(override?.capabilities || {})
|
||||||
|
},
|
||||||
|
schedules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHermesTaskEnabled(form, taskId) {
|
||||||
|
return Boolean(form?.masterEnabled && form?.capabilities?.[taskId] && form?.schedules?.[taskId]?.enabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function countEnabledHermesTasks(form) {
|
||||||
|
return HERMES_SIMPLE_TASKS.filter((task) => isHermesTaskEnabled(form, task.id)).length
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isHermesEmployeeSettingsReady(form) {
|
||||||
|
return Boolean(!form?.masterEnabled || countEnabledHermesTasks(form) > 0)
|
||||||
|
}
|
||||||
315
web/src/utils/knowledgeIngestLogModel.js
Normal file
315
web/src/utils/knowledgeIngestLogModel.js
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
const KNOWLEDGE_INGEST_JOB_TYPES = new Set(['knowledge_index_sync', 'llm_wiki_sync'])
|
||||||
|
|
||||||
|
const STATUS_META = {
|
||||||
|
queued: { label: '等待处理', tone: 'muted' },
|
||||||
|
running: { label: '处理中', tone: 'warning' },
|
||||||
|
succeeded: { label: '已完成', tone: 'success' },
|
||||||
|
failed: { label: '失败', tone: 'danger' },
|
||||||
|
skipped: { label: '已跳过', tone: 'muted' }
|
||||||
|
}
|
||||||
|
|
||||||
|
const PHASE_LABELS = {
|
||||||
|
queued: '进入队列',
|
||||||
|
indexing: '解析与索引',
|
||||||
|
indexed: '索引完成',
|
||||||
|
failed: '处理失败',
|
||||||
|
completed: '任务完成'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isKnowledgeIngestRun(run) {
|
||||||
|
const routeJson = asObject(run?.route_json)
|
||||||
|
return KNOWLEDGE_INGEST_JOB_TYPES.has(String(routeJson.job_type || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildKnowledgeIngestLogModel(run) {
|
||||||
|
const routeJson = asObject(run?.route_json)
|
||||||
|
const ingest = asObject(routeJson.knowledge_ingest)
|
||||||
|
const toolDocuments = extractToolDocuments(run)
|
||||||
|
const sourceDocuments = normalizeSourceDocuments(
|
||||||
|
ingest.documents,
|
||||||
|
toolDocuments,
|
||||||
|
routeJson.requested_document_ids
|
||||||
|
)
|
||||||
|
const documents = sourceDocuments.map(normalizeDocument)
|
||||||
|
const graph = normalizeGraph(ingest.graph, documents)
|
||||||
|
const progress = normalizeProgress(routeJson.progress, documents)
|
||||||
|
const currentDocumentId = String(ingest.current_document_id || '').trim()
|
||||||
|
|
||||||
|
return {
|
||||||
|
available: isKnowledgeIngestRun(run),
|
||||||
|
folder: String(routeJson.folder || '').trim(),
|
||||||
|
phase: String(ingest.phase || routeJson.phase || '').trim(),
|
||||||
|
phaseLabel: PHASE_LABELS[ingest.phase] || PHASE_LABELS[routeJson.phase] || '运行中',
|
||||||
|
status: String(ingest.status || run?.status || '').trim(),
|
||||||
|
statusLabel: resolveStatusMeta(ingest.status || run?.status).label,
|
||||||
|
statusTone: resolveStatusMeta(ingest.status || run?.status).tone,
|
||||||
|
progress,
|
||||||
|
currentDocumentId,
|
||||||
|
documents,
|
||||||
|
selectedDocumentId: resolveDefaultDocumentId(documents, currentDocumentId),
|
||||||
|
graph,
|
||||||
|
metrics: [
|
||||||
|
{
|
||||||
|
label: '文件',
|
||||||
|
value: `${progress.completedDocuments}/${progress.totalDocuments}`,
|
||||||
|
hint: `失败 ${progress.failedDocuments}`
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Chunk',
|
||||||
|
value: formatNumber(graph.chunkCount),
|
||||||
|
hint: '已解析块'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '实体',
|
||||||
|
value: formatNumber(graph.entityCount),
|
||||||
|
hint: '图谱节点'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: '关系',
|
||||||
|
value: formatNumber(graph.relationCount),
|
||||||
|
hint: '图谱边'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatKnowledgeMetric(value) {
|
||||||
|
return formatNumber(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSourceDocuments(ingestDocuments, toolDocuments, requestedDocumentIds) {
|
||||||
|
if (Array.isArray(ingestDocuments) && ingestDocuments.length) {
|
||||||
|
return ingestDocuments
|
||||||
|
}
|
||||||
|
if (Array.isArray(toolDocuments) && toolDocuments.length) {
|
||||||
|
return toolDocuments
|
||||||
|
}
|
||||||
|
if (Array.isArray(requestedDocumentIds)) {
|
||||||
|
return requestedDocumentIds
|
||||||
|
.map((documentId) => String(documentId || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((documentId) => ({ document_id: documentId, name: documentId, status: 'queued' }))
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractToolDocuments(run) {
|
||||||
|
const toolCalls = Array.isArray(run?.tool_calls) ? run.tool_calls : []
|
||||||
|
for (const toolCall of [...toolCalls].reverse()) {
|
||||||
|
const responseJson = asObject(toolCall?.response_json)
|
||||||
|
if (Array.isArray(responseJson.documents) && responseJson.documents.length) {
|
||||||
|
return responseJson.documents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDocument(rawDocument) {
|
||||||
|
const document = asObject(rawDocument)
|
||||||
|
const documentId = String(document.document_id || document.id || '').trim()
|
||||||
|
const status = String(document.status || 'queued').trim()
|
||||||
|
const phase = String(document.phase || status).trim()
|
||||||
|
const chunks = normalizeChunks(document.chunks)
|
||||||
|
const sections = normalizeSections(document.sections)
|
||||||
|
const entities = normalizeEntities(document.entities)
|
||||||
|
const relations = normalizeRelations(document.relations)
|
||||||
|
return {
|
||||||
|
documentId,
|
||||||
|
name: String(document.name || document.original_name || documentId || '未命名文件').trim(),
|
||||||
|
folder: String(document.folder || '').trim(),
|
||||||
|
extension: String(document.extension || '').trim(),
|
||||||
|
mimeType: String(document.mime_type || '').trim(),
|
||||||
|
status,
|
||||||
|
statusLabel: resolveStatusMeta(status).label,
|
||||||
|
statusTone: resolveStatusMeta(status).tone,
|
||||||
|
phase,
|
||||||
|
phaseLabel: PHASE_LABELS[phase] || PHASE_LABELS[status] || phase || '未开始',
|
||||||
|
startedAt: String(document.started_at || '').trim(),
|
||||||
|
finishedAt: String(document.finished_at || '').trim(),
|
||||||
|
error: String(document.error || '').trim(),
|
||||||
|
textChars: toNumber(document.text_chars),
|
||||||
|
indexedTextChars: toNumber(document.indexed_text_chars),
|
||||||
|
sectionCount: toNumber(document.section_count || sections.length),
|
||||||
|
chunkCount: toNumber(document.chunk_count || chunks.length),
|
||||||
|
chunkIds: normalizeTextList(document.chunk_ids),
|
||||||
|
chunks,
|
||||||
|
entityCount: toNumber(document.entity_count || entities.length),
|
||||||
|
relationCount: toNumber(document.relation_count || relations.length),
|
||||||
|
entities,
|
||||||
|
relations,
|
||||||
|
events: normalizeEvents(document.events)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProgress(rawProgress, documents) {
|
||||||
|
const progress = asObject(rawProgress)
|
||||||
|
const totalDocuments = toNumber(progress.total_documents || documents.length)
|
||||||
|
const completedDocuments = toNumber(
|
||||||
|
progress.completed_documents || documents.filter((item) => item.status === 'succeeded').length
|
||||||
|
)
|
||||||
|
const failedDocuments = toNumber(
|
||||||
|
progress.failed_documents || documents.filter((item) => item.status === 'failed').length
|
||||||
|
)
|
||||||
|
const skippedDocuments = toNumber(progress.skipped_documents)
|
||||||
|
const percent = clampPercent(
|
||||||
|
progress.percent ?? calculatePercent(totalDocuments, completedDocuments + failedDocuments)
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
totalDocuments,
|
||||||
|
completedDocuments,
|
||||||
|
failedDocuments,
|
||||||
|
skippedDocuments,
|
||||||
|
percent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGraph(rawGraph, documents) {
|
||||||
|
const graph = asObject(rawGraph)
|
||||||
|
const fallbackEntities = dedupeTextList(documents.flatMap((item) => item.entities))
|
||||||
|
const fallbackRelations = dedupeRelations(documents.flatMap((item) => item.relations))
|
||||||
|
return {
|
||||||
|
chunkCount: toNumber(
|
||||||
|
graph.chunk_count || documents.reduce((total, item) => total + item.chunkCount, 0)
|
||||||
|
),
|
||||||
|
entityCount: toNumber(
|
||||||
|
graph.entity_count || documents.reduce((total, item) => total + item.entityCount, 0)
|
||||||
|
),
|
||||||
|
relationCount: toNumber(
|
||||||
|
graph.relation_count || documents.reduce((total, item) => total + item.relationCount, 0)
|
||||||
|
),
|
||||||
|
entities: normalizeTextList(graph.entities).length
|
||||||
|
? normalizeTextList(graph.entities)
|
||||||
|
: fallbackEntities,
|
||||||
|
relations: normalizeRelations(graph.relations).length
|
||||||
|
? normalizeRelations(graph.relations)
|
||||||
|
: fallbackRelations
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeChunks(rawChunks) {
|
||||||
|
if (!Array.isArray(rawChunks)) return []
|
||||||
|
return rawChunks
|
||||||
|
.map((chunk, index) => {
|
||||||
|
const item = asObject(chunk)
|
||||||
|
return {
|
||||||
|
id: String(item.id || item._id || `chunk-${index + 1}`).trim(),
|
||||||
|
order: toNumber(item.order ?? item.chunk_order_index ?? index),
|
||||||
|
tokens: toNumber(item.tokens),
|
||||||
|
summary: String(item.summary || item.content || '').trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((left, right) => left.order - right.order)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSections(rawSections) {
|
||||||
|
if (!Array.isArray(rawSections)) return []
|
||||||
|
return rawSections.map((section, index) => {
|
||||||
|
const item = asObject(section)
|
||||||
|
return {
|
||||||
|
title: String(item.title || `章节 ${index + 1}`).trim(),
|
||||||
|
excerpt: String(item.excerpt || '').trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEvents(rawEvents) {
|
||||||
|
if (!Array.isArray(rawEvents)) return []
|
||||||
|
return rawEvents.map((event) => {
|
||||||
|
const item = asObject(event)
|
||||||
|
return {
|
||||||
|
at: String(item.at || '').trim(),
|
||||||
|
level: String(item.level || 'info').trim(),
|
||||||
|
message: String(item.message || '').trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntities(rawEntities) {
|
||||||
|
return normalizeTextList(rawEntities)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRelations(rawRelations) {
|
||||||
|
if (!Array.isArray(rawRelations)) return []
|
||||||
|
return rawRelations
|
||||||
|
.map((relation) => {
|
||||||
|
const item = asObject(relation)
|
||||||
|
return {
|
||||||
|
source: String(item.source || item.from || '').trim(),
|
||||||
|
target: String(item.target || item.to || '').trim(),
|
||||||
|
type: String(item.type || '关联').trim()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((item) => item.source && item.target)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveDefaultDocumentId(documents, currentDocumentId) {
|
||||||
|
if (currentDocumentId && documents.some((item) => item.documentId === currentDocumentId)) {
|
||||||
|
return currentDocumentId
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
documents.find((item) => item.status === 'running')?.documentId ||
|
||||||
|
documents.find((item) => item.status === 'failed')?.documentId ||
|
||||||
|
documents[0]?.documentId ||
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStatusMeta(status) {
|
||||||
|
return STATUS_META[String(status || '').trim()] || STATUS_META.queued
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(value) {
|
||||||
|
return value && typeof value === 'object' && !Array.isArray(value) ? value : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTextList(value) {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return dedupeTextList(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeTextList(items) {
|
||||||
|
const result = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const item of items) {
|
||||||
|
const text = String(item || '').trim()
|
||||||
|
if (!text || seen.has(text)) continue
|
||||||
|
seen.add(text)
|
||||||
|
result.push(text)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupeRelations(items) {
|
||||||
|
const result = []
|
||||||
|
const seen = new Set()
|
||||||
|
for (const item of items) {
|
||||||
|
const source = String(item?.source || '').trim()
|
||||||
|
const target = String(item?.target || '').trim()
|
||||||
|
const type = String(item?.type || '关联').trim()
|
||||||
|
const key = `${source}::${target}::${type}`
|
||||||
|
if (!source || !target || seen.has(key)) continue
|
||||||
|
seen.add(key)
|
||||||
|
result.push({ source, target, type })
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculatePercent(total, done) {
|
||||||
|
if (!total) return 0
|
||||||
|
return Math.round((done / total) * 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampPercent(value) {
|
||||||
|
const numericValue = toNumber(value)
|
||||||
|
return Math.max(0, Math.min(100, numericValue))
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatNumber(value) {
|
||||||
|
const numericValue = toNumber(value)
|
||||||
|
return Number.isFinite(numericValue) ? numericValue.toLocaleString('zh-CN') : '0'
|
||||||
|
}
|
||||||
|
|
||||||
|
function toNumber(value) {
|
||||||
|
const numericValue = Number(value)
|
||||||
|
return Number.isFinite(numericValue) ? numericValue : 0
|
||||||
|
}
|
||||||
@@ -12,8 +12,32 @@ const defaultParagraphOpen = markdown.renderer.rules.paragraph_open
|
|||||||
const defaultLinkOpen = markdown.renderer.rules.link_open
|
const defaultLinkOpen = markdown.renderer.rules.link_open
|
||||||
const defaultBlockquoteOpen = markdown.renderer.rules.blockquote_open
|
const defaultBlockquoteOpen = markdown.renderer.rules.blockquote_open
|
||||||
|
|
||||||
|
const RISK_TEXT_CLASS_BY_LABEL = {
|
||||||
|
低风险: 'markdown-risk-text-low',
|
||||||
|
中风险: 'markdown-risk-text-medium',
|
||||||
|
高风险: 'markdown-risk-text-high'
|
||||||
|
}
|
||||||
|
|
||||||
const ACTION_LINK_CLASS_BY_HREF = {
|
const ACTION_LINK_CLASS_BY_HREF = {
|
||||||
'#confirm-attachment-association': 'markdown-action-link-confirm'
|
'#confirm-attachment-association': 'markdown-action-link-confirm',
|
||||||
|
'#review-next-step': 'markdown-action-link-next',
|
||||||
|
'#review-quick-edit': 'markdown-action-link-edit',
|
||||||
|
'#review-risk-panel': 'markdown-action-link-risk'
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRiskText(text) {
|
||||||
|
return escapeHtml(text).replace(/低风险|中风险|高风险/g, (label) => {
|
||||||
|
const className = RISK_TEXT_CLASS_BY_LABEL[label]
|
||||||
|
return className ? `<span class="${className}">${label}</span>` : label
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveActionLinkClass(href) {
|
function resolveActionLinkClass(href) {
|
||||||
@@ -70,6 +94,8 @@ markdown.renderer.rules.link_open = (tokens, idx, options, env, self) => {
|
|||||||
: self.renderToken(tokens, idx, options)
|
: self.renderToken(tokens, idx, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
markdown.renderer.rules.text = (tokens, idx) => renderRiskText(tokens[idx]?.content)
|
||||||
|
|
||||||
markdown.renderer.rules.blockquote_open = (tokens, idx, options, env, self) => {
|
markdown.renderer.rules.blockquote_open = (tokens, idx, options, env, self) => {
|
||||||
if (blockquoteHasAttachmentHeading(tokens, idx)) {
|
if (blockquoteHasAttachmentHeading(tokens, idx)) {
|
||||||
tokens[idx].attrJoin('class', 'markdown-attachment-card')
|
tokens[idx].attrJoin('class', 'markdown-attachment-card')
|
||||||
|
|||||||
@@ -22,17 +22,17 @@ const DEFAULT_EXPENSE_TYPE_LABELS = {
|
|||||||
travel: '差旅费',
|
travel: '差旅费',
|
||||||
hotel: '住宿费',
|
hotel: '住宿费',
|
||||||
transport: '交通费',
|
transport: '交通费',
|
||||||
meal: '餐费',
|
meal: '业务招待费',
|
||||||
meeting: '会务费',
|
meeting: '会务费',
|
||||||
entertainment: '业务招待费',
|
entertainment: '业务招待费',
|
||||||
office: '办公费',
|
office: '办公用品费',
|
||||||
training: '培训费',
|
training: '培训费',
|
||||||
communication: '通讯费',
|
communication: '通讯费',
|
||||||
welfare: '福利费',
|
welfare: '福利费',
|
||||||
other: '其他费用'
|
other: '其他费用'
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TRANSPORT_KEYWORD_PATTERN = /交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费/
|
export const TRANSPORT_KEYWORD_PATTERN = /交通|市内交通|出行|打车|网约车|出租车|滴滴|车费|乘车|用车|叫车|约车|的士|车票|车资|地铁|公交|停车|过路费|通行费|高速费|油费/
|
||||||
|
|
||||||
const FLOW_INTENT_KEYWORDS = {
|
const FLOW_INTENT_KEYWORDS = {
|
||||||
draft: ['报销', '报账', '草稿', '生成', '提交', '申请', '请走报销'],
|
draft: ['报销', '报账', '草稿', '生成', '提交', '申请', '请走报销'],
|
||||||
@@ -104,21 +104,36 @@ export function inferLocalFlowCandidates(rawText) {
|
|||||||
|
|
||||||
let event = ''
|
let event = ''
|
||||||
let expenseType = ''
|
let expenseType = ''
|
||||||
if (/客户.*吃饭|请客户.*吃饭|招待|宴请|请客/.test(compact)) {
|
if (TRANSPORT_KEYWORD_PATTERN.test(compact)) {
|
||||||
event = '请客户吃饭'
|
|
||||||
expenseType = '业务招待费'
|
|
||||||
} else if (/出差|差旅|机票|高铁|火车|行程/.test(compact)) {
|
|
||||||
event = '出差行程'
|
|
||||||
expenseType = '差旅费'
|
|
||||||
} else if (TRANSPORT_KEYWORD_PATTERN.test(compact)) {
|
|
||||||
event = '交通出行'
|
event = '交通出行'
|
||||||
expenseType = '交通费'
|
expenseType = '交通费'
|
||||||
} else if (/住宿|酒店|宾馆/.test(compact)) {
|
} else if (/客户.*吃饭|请客户.*吃饭|客户用餐|客户接待|商务接待|招待|宴请|请客/.test(compact)) {
|
||||||
|
event = '业务招待'
|
||||||
|
expenseType = '业务招待费'
|
||||||
|
} else if (/出差|差旅|机票|飞机票|航班|高铁票|高铁|火车票|火车|动车|行程单|铁路客票/.test(compact)) {
|
||||||
|
event = '出差行程'
|
||||||
|
expenseType = '差旅费'
|
||||||
|
} else if (/住宿|住宿费|酒店|酒店发票|宾馆|民宿|房费|客房/.test(compact)) {
|
||||||
event = '住宿报销'
|
event = '住宿报销'
|
||||||
expenseType = '住宿费'
|
expenseType = '住宿费'
|
||||||
} else if (/餐费|用餐|午餐|晚餐|早餐|餐饮/.test(compact)) {
|
} else if (/餐费|工作餐|用餐|午餐|晚餐|早餐|餐饮|伙食|茶歇/.test(compact)) {
|
||||||
event = '餐饮用餐'
|
event = '业务招待'
|
||||||
expenseType = '餐费'
|
expenseType = '业务招待费'
|
||||||
|
} else if (/会务|会议费|会议|参会|会场|场地费|论坛|展会/.test(compact)) {
|
||||||
|
event = '会务活动'
|
||||||
|
expenseType = '会务费'
|
||||||
|
} else if (/办公用品|办公耗材|办公设备|文具|打印纸|硒鼓|墨盒|键盘|鼠标|白板/.test(compact)) {
|
||||||
|
event = '办公采购'
|
||||||
|
expenseType = '办公用品费'
|
||||||
|
} else if (/培训|讲师费|课程费|教材|认证费|考试费/.test(compact)) {
|
||||||
|
event = '培训学习'
|
||||||
|
expenseType = '培训费'
|
||||||
|
} else if (/通讯费|话费|电话费|手机费|流量费|宽带费|网络费/.test(compact)) {
|
||||||
|
event = '通讯使用'
|
||||||
|
expenseType = '通讯费'
|
||||||
|
} else if (/福利费|团建|慰问|节日福利|体检费|员工关怀/.test(compact)) {
|
||||||
|
event = '员工福利'
|
||||||
|
expenseType = '福利费'
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -232,7 +247,13 @@ export function buildLocalExtractionProgressMessages(rawText, options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
|
const attachmentHint = Number(options.attachmentCount || 0) > 0 ? '附件完整性' : '票据附件'
|
||||||
messages.push(`正在判断待补项:客户名称、参与人员、${attachmentHint}`)
|
const pendingSlots = ['发生时间', '金额', attachmentHint]
|
||||||
|
if (candidates.expenseType === '业务招待费') {
|
||||||
|
pendingSlots.splice(2, 0, '客户名称', '参与人员')
|
||||||
|
} else if (candidates.expenseType === '住宿费') {
|
||||||
|
pendingSlots.splice(2, 0, '酒店/商户')
|
||||||
|
}
|
||||||
|
messages.push(`正在判断待补项:${pendingSlots.join('、')}`)
|
||||||
|
|
||||||
return messages
|
return messages
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,13 +54,13 @@ const REQUEST_TYPE_META = {
|
|||||||
secondaryStatusLabel: '票据状态'
|
secondaryStatusLabel: '票据状态'
|
||||||
},
|
},
|
||||||
meal: {
|
meal: {
|
||||||
label: '餐费',
|
label: '业务招待费',
|
||||||
detailVariant: 'general',
|
detailVariant: 'general',
|
||||||
tone: 'meeting',
|
tone: 'meeting',
|
||||||
secondaryStatusLabel: '票据状态'
|
secondaryStatusLabel: '票据状态'
|
||||||
},
|
},
|
||||||
office: {
|
office: {
|
||||||
label: '办公费',
|
label: '办公用品费',
|
||||||
detailVariant: 'general',
|
detailVariant: 'general',
|
||||||
tone: 'office',
|
tone: 'office',
|
||||||
secondaryStatusLabel: '票据状态'
|
secondaryStatusLabel: '票据状态'
|
||||||
|
|||||||
461
web/src/utils/settingsModelHelper.js
Normal file
461
web/src/utils/settingsModelHelper.js
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
import {
|
||||||
|
buildDefaultHermesEmployeeForm,
|
||||||
|
isHermesEmployeeSettingsReady,
|
||||||
|
mergeHermesEmployeeForm
|
||||||
|
} from './hermesEmployeeSettingsModel.js'
|
||||||
|
|
||||||
|
export const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft'
|
||||||
|
export const CURRENT_YEAR = new Date().getFullYear()
|
||||||
|
export const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
|
||||||
|
export const MODEL_SECRET_MASK = '********'
|
||||||
|
export const RENDER_SECRET_MASK = '********'
|
||||||
|
|
||||||
|
export const SECTION_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
id: 'profile',
|
||||||
|
label: '企业信息',
|
||||||
|
title: '系统基本信息',
|
||||||
|
desc: '公司名称、品牌与版权',
|
||||||
|
longDesc: '统一维护企业名称、系统显示名称和版权信息,保存后会同步更新当前系统品牌展示。',
|
||||||
|
actionLabel: '保存企业信息'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'admin',
|
||||||
|
label: '管理员安全',
|
||||||
|
title: '管理员账号与安全策略',
|
||||||
|
desc: '账号、密码与登录安全',
|
||||||
|
longDesc: '集中管理管理员账号、邮箱和登录安全策略。密码仅在当前输入时可见,不会写入浏览器草稿。',
|
||||||
|
actionLabel: '保存安全设置'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'session',
|
||||||
|
label: '会话设置',
|
||||||
|
title: '会话留存设置',
|
||||||
|
desc: '会话保留天数',
|
||||||
|
longDesc: '统一配置智能体会话的保留天数。超过保留期的历史会话会在后端清理,避免上下文和管理成本无限增长。',
|
||||||
|
actionLabel: '保存会话设置'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'hermes',
|
||||||
|
label: '数字员工设置',
|
||||||
|
title: '数字员工设置',
|
||||||
|
desc: 'Hermes 自动任务',
|
||||||
|
longDesc: '选择需要自动执行的任务,并设置每天的执行时间。无需了解 Cron 或复杂调度规则。',
|
||||||
|
actionLabel: '保存数字员工设置'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'llm',
|
||||||
|
label: '大语言模型',
|
||||||
|
title: '模型接入配置',
|
||||||
|
desc: '主模型、备份模型与检索模型',
|
||||||
|
longDesc: '集中维护主模型、备份模型、Embedding 模型和 Reranker 模型的接入参数,供 AI 助手和检索链路调用。',
|
||||||
|
actionLabel: '保存模型配置'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rendering',
|
||||||
|
label: '文件渲染',
|
||||||
|
title: '文件渲染',
|
||||||
|
desc: '文档预览服务与访问密钥',
|
||||||
|
longDesc: '维护文件渲染开关、文档服务对外地址和 JWT 密钥,后端回调地址继续由部署配置管理。',
|
||||||
|
actionLabel: '保存文件渲染配置'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'logs',
|
||||||
|
label: '日志策略',
|
||||||
|
title: '日志与审计策略',
|
||||||
|
desc: '日志级别、留存与脱敏',
|
||||||
|
longDesc: '定义系统日志级别、留存周期和审计策略,保证问题排查和合规审计可追溯。',
|
||||||
|
actionLabel: '保存日志策略'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mail',
|
||||||
|
label: '邮箱设置',
|
||||||
|
title: '邮箱通知配置',
|
||||||
|
desc: 'SMTP 与通知投递策略',
|
||||||
|
longDesc: '维护系统邮件发送配置和通知投递策略,审批、告警和摘要邮件都会依赖这里的设置。',
|
||||||
|
actionLabel: '保存邮箱配置'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
export const LOG_LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR']
|
||||||
|
|
||||||
|
export const PROVIDER_OPTIONS = [
|
||||||
|
'MiniMax',
|
||||||
|
'GLM',
|
||||||
|
'Kimi',
|
||||||
|
'Ali',
|
||||||
|
'Codex',
|
||||||
|
'Claude',
|
||||||
|
'Gemini',
|
||||||
|
CUSTOM_OPENAI_PROVIDER
|
||||||
|
]
|
||||||
|
|
||||||
|
export const PROVIDER_ENDPOINTS = {
|
||||||
|
MiniMax: 'https://api.minimaxi.com/v1',
|
||||||
|
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
|
||||||
|
Kimi: 'https://api.moonshot.ai/v1',
|
||||||
|
Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||||
|
Codex: 'https://api.openai.com/v1',
|
||||||
|
Claude: 'https://api.anthropic.com/v1/',
|
||||||
|
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
||||||
|
[CUSTOM_OPENAI_PROVIDER]: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RERANKER_PROVIDER_ENDPOINTS = {
|
||||||
|
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
|
||||||
|
[CUSTOM_OPENAI_PROVIDER]: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEGACY_PROVIDER_MAP = {
|
||||||
|
'OpenAI Compatible': 'Codex',
|
||||||
|
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
|
||||||
|
Ollama: CUSTOM_OPENAI_PROVIDER,
|
||||||
|
'自定义网关': CUSTOM_OPENAI_PROVIDER
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODEL_TEST_CONFIGS = {
|
||||||
|
main: {
|
||||||
|
label: '主模型',
|
||||||
|
providerKey: 'mainProvider',
|
||||||
|
modelKey: 'mainModel',
|
||||||
|
endpointKey: 'mainEndpoint',
|
||||||
|
apiKeyKey: 'mainApiKey',
|
||||||
|
capability: 'chat'
|
||||||
|
},
|
||||||
|
backup: {
|
||||||
|
label: '备份模型',
|
||||||
|
providerKey: 'backupProvider',
|
||||||
|
modelKey: 'backupModel',
|
||||||
|
endpointKey: 'backupEndpoint',
|
||||||
|
apiKeyKey: 'backupApiKey',
|
||||||
|
capability: 'chat'
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
label: 'Embedding 模型',
|
||||||
|
providerKey: 'embeddingProvider',
|
||||||
|
modelKey: 'embeddingModel',
|
||||||
|
endpointKey: 'embeddingEndpoint',
|
||||||
|
apiKeyKey: 'embeddingApiKey',
|
||||||
|
capability: 'embedding'
|
||||||
|
},
|
||||||
|
reranker: {
|
||||||
|
label: 'Reranker 模型',
|
||||||
|
providerKey: 'rerankerProvider',
|
||||||
|
modelKey: 'rerankerModel',
|
||||||
|
endpointKey: 'rerankerEndpoint',
|
||||||
|
apiKeyKey: 'rerankerApiKey',
|
||||||
|
capability: 'reranker'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS)
|
||||||
|
export const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
|
||||||
|
value: index + 1,
|
||||||
|
label: `${index + 1} 天`
|
||||||
|
}))
|
||||||
|
|
||||||
|
export function normalizeValue(value) {
|
||||||
|
return String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProviderValue(value, fallback = 'Codex') {
|
||||||
|
const normalized = normalizeValue(value)
|
||||||
|
|
||||||
|
if (PROVIDER_OPTIONS.includes(normalized)) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LEGACY_PROVIDER_MAP[normalized]) {
|
||||||
|
return LEGACY_PROVIDER_MAP[normalized]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getProviderEndpoint(provider) {
|
||||||
|
return PROVIDER_ENDPOINTS[provider] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRerankerEndpoint(provider) {
|
||||||
|
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDefaultState(companyProfile, currentUser) {
|
||||||
|
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
|
||||||
|
const companyCode = normalizeValue(companyProfile?.code) || 'XF-001'
|
||||||
|
const adminEmail =
|
||||||
|
normalizeValue(companyProfile?.adminEmail) ||
|
||||||
|
normalizeValue(currentUser?.email) ||
|
||||||
|
'admin@example.com'
|
||||||
|
const adminAccount = normalizeValue(currentUser?.username) || 'superadmin'
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyForm: {
|
||||||
|
companyName,
|
||||||
|
displayName: companyName,
|
||||||
|
companyCode,
|
||||||
|
logo: normalizeValue(companyProfile?.logo) || '',
|
||||||
|
recordNumber: '',
|
||||||
|
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
|
||||||
|
},
|
||||||
|
adminForm: {
|
||||||
|
adminAccount,
|
||||||
|
adminEmail,
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: '',
|
||||||
|
sessionTimeout: Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30),
|
||||||
|
noticeEmail: adminEmail,
|
||||||
|
mfaEnabled: true,
|
||||||
|
strongPassword: true,
|
||||||
|
loginAlertEnabled: true,
|
||||||
|
adminPasswordConfigured: false
|
||||||
|
},
|
||||||
|
sessionForm: {
|
||||||
|
conversationRetentionDays: 3
|
||||||
|
},
|
||||||
|
llmForm: {
|
||||||
|
mainProvider: 'Codex',
|
||||||
|
mainModel: 'codex-mini-latest',
|
||||||
|
mainEndpoint: getProviderEndpoint('Codex'),
|
||||||
|
mainApiKey: '',
|
||||||
|
mainApiKeyConfigured: false,
|
||||||
|
backupProvider: 'GLM',
|
||||||
|
backupModel: 'glm-5.1',
|
||||||
|
backupEndpoint: getProviderEndpoint('GLM'),
|
||||||
|
backupApiKey: '',
|
||||||
|
backupApiKeyConfigured: false,
|
||||||
|
embeddingProvider: 'GLM',
|
||||||
|
embeddingModel: 'Embedding-3',
|
||||||
|
embeddingEndpoint: getProviderEndpoint('GLM'),
|
||||||
|
embeddingApiKey: '',
|
||||||
|
embeddingApiKeyConfigured: false,
|
||||||
|
rerankerProvider: 'Ali',
|
||||||
|
rerankerModel: 'gte-rerank-v2',
|
||||||
|
rerankerEndpoint: getRerankerEndpoint('Ali'),
|
||||||
|
rerankerApiKey: '',
|
||||||
|
rerankerApiKeyConfigured: false
|
||||||
|
},
|
||||||
|
renderForm: {
|
||||||
|
enabled: false,
|
||||||
|
publicUrl: '',
|
||||||
|
jwtSecret: '',
|
||||||
|
jwtSecretConfigured: false
|
||||||
|
},
|
||||||
|
logForm: {
|
||||||
|
level: 'INFO',
|
||||||
|
retentionDays: 180,
|
||||||
|
archiveCycle: 'weekly',
|
||||||
|
logPath: 'server/logs/app.log',
|
||||||
|
alertEmail: adminEmail,
|
||||||
|
operationAudit: true,
|
||||||
|
loginAudit: true,
|
||||||
|
maskSensitive: true
|
||||||
|
},
|
||||||
|
hermesForm: buildDefaultHermesEmployeeForm(),
|
||||||
|
mailForm: {
|
||||||
|
smtpHost: 'smtp.exmail.qq.com',
|
||||||
|
port: 465,
|
||||||
|
encryption: 'SSL/TLS',
|
||||||
|
senderName: companyName,
|
||||||
|
senderAddress: adminEmail,
|
||||||
|
username: adminEmail,
|
||||||
|
password: '',
|
||||||
|
passwordConfigured: false,
|
||||||
|
alertEnabled: true,
|
||||||
|
digestEnabled: false,
|
||||||
|
digestTime: '09:00',
|
||||||
|
defaultReceiver: adminEmail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readStoredSettings() {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = window.sessionStorage.getItem(SETTINGS_STORAGE_KEY)
|
||||||
|
if (!raw) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mergeState(baseState, overrideState) {
|
||||||
|
const mergedLlmForm = { ...baseState.llmForm, ...(overrideState?.llmForm || {}) }
|
||||||
|
|
||||||
|
mergedLlmForm.mainProvider = normalizeProviderValue(mergedLlmForm.mainProvider, baseState.llmForm.mainProvider)
|
||||||
|
mergedLlmForm.backupProvider = normalizeProviderValue(mergedLlmForm.backupProvider, baseState.llmForm.backupProvider)
|
||||||
|
mergedLlmForm.embeddingProvider = normalizeProviderValue(
|
||||||
|
mergedLlmForm.embeddingProvider,
|
||||||
|
baseState.llmForm.embeddingProvider
|
||||||
|
)
|
||||||
|
mergedLlmForm.rerankerProvider = normalizeProviderValue(
|
||||||
|
mergedLlmForm.rerankerProvider,
|
||||||
|
baseState.llmForm.rerankerProvider
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
|
||||||
|
adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) },
|
||||||
|
sessionForm: { ...baseState.sessionForm, ...(overrideState?.sessionForm || {}) },
|
||||||
|
hermesForm: mergeHermesEmployeeForm({
|
||||||
|
...baseState.hermesForm,
|
||||||
|
...(overrideState?.hermesForm || {})
|
||||||
|
}),
|
||||||
|
llmForm: mergedLlmForm,
|
||||||
|
renderForm: { ...baseState.renderForm, ...(overrideState?.renderForm || {}) },
|
||||||
|
logForm: { ...baseState.logForm, ...(overrideState?.logForm || {}) },
|
||||||
|
mailForm: { ...baseState.mailForm, ...(overrideState?.mailForm || {}) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeForStorage(state) {
|
||||||
|
return {
|
||||||
|
companyForm: { ...state.companyForm },
|
||||||
|
adminForm: {
|
||||||
|
...state.adminForm,
|
||||||
|
newPassword: '',
|
||||||
|
confirmPassword: ''
|
||||||
|
},
|
||||||
|
sessionForm: { ...state.sessionForm },
|
||||||
|
hermesForm: mergeHermesEmployeeForm(state.hermesForm),
|
||||||
|
llmForm: {
|
||||||
|
...state.llmForm,
|
||||||
|
mainApiKey: '',
|
||||||
|
backupApiKey: '',
|
||||||
|
embeddingApiKey: '',
|
||||||
|
rerankerApiKey: ''
|
||||||
|
},
|
||||||
|
renderForm: {
|
||||||
|
...state.renderForm,
|
||||||
|
jwtSecret: ''
|
||||||
|
},
|
||||||
|
logForm: { ...state.logForm },
|
||||||
|
mailForm: {
|
||||||
|
...state.mailForm,
|
||||||
|
password: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getModelConfiguredKey(apiKeyKey) {
|
||||||
|
return `${apiKeyKey}Configured`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModelSecretMask(value) {
|
||||||
|
return value === MODEL_SECRET_MASK
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskConfiguredModelSecrets(state) {
|
||||||
|
for (const config of MODEL_API_KEY_CONFIGS) {
|
||||||
|
const configuredKey = getModelConfiguredKey(config.apiKeyKey)
|
||||||
|
|
||||||
|
if (state.llmForm[configuredKey] && !normalizeValue(state.llmForm[config.apiKeyKey])) {
|
||||||
|
state.llmForm[config.apiKeyKey] = MODEL_SECRET_MASK
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLlmPayload(llmForm) {
|
||||||
|
const payload = { ...llmForm }
|
||||||
|
|
||||||
|
for (const config of MODEL_API_KEY_CONFIGS) {
|
||||||
|
if (isModelSecretMask(payload[config.apiKeyKey])) {
|
||||||
|
payload[config.apiKeyKey] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRenderSecretMask(value) {
|
||||||
|
return value === RENDER_SECRET_MASK
|
||||||
|
}
|
||||||
|
|
||||||
|
export function maskConfiguredRenderSecret(state) {
|
||||||
|
if (state.renderForm.jwtSecretConfigured && !normalizeValue(state.renderForm.jwtSecret)) {
|
||||||
|
state.renderForm.jwtSecret = RENDER_SECRET_MASK
|
||||||
|
}
|
||||||
|
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildRenderPayload(renderForm) {
|
||||||
|
const payload = { ...renderForm }
|
||||||
|
|
||||||
|
if (isRenderSecretMask(payload.jwtSecret)) {
|
||||||
|
payload.jwtSecret = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistSettings(state) {
|
||||||
|
if (typeof window === 'undefined') {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
window.sessionStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(sanitizeForStorage(state)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isModelConfigReady(provider, model, endpoint) {
|
||||||
|
return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSectionStatus(state) {
|
||||||
|
return {
|
||||||
|
profile: Boolean(
|
||||||
|
normalizeValue(state.companyForm.companyName) &&
|
||||||
|
normalizeValue(state.companyForm.displayName) &&
|
||||||
|
normalizeValue(state.companyForm.copyright)
|
||||||
|
),
|
||||||
|
admin: Boolean(
|
||||||
|
normalizeValue(state.adminForm.adminAccount) &&
|
||||||
|
normalizeValue(state.adminForm.adminEmail) &&
|
||||||
|
Number(state.adminForm.sessionTimeout) >= 5
|
||||||
|
),
|
||||||
|
session: Boolean(
|
||||||
|
Number(state.sessionForm.conversationRetentionDays) >= 1 &&
|
||||||
|
Number(state.sessionForm.conversationRetentionDays) <= 10
|
||||||
|
),
|
||||||
|
hermes: isHermesEmployeeSettingsReady(state.hermesForm),
|
||||||
|
llm: Boolean(
|
||||||
|
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
|
||||||
|
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
|
||||||
|
isModelConfigReady(
|
||||||
|
state.llmForm.embeddingProvider,
|
||||||
|
state.llmForm.embeddingModel,
|
||||||
|
state.llmForm.embeddingEndpoint
|
||||||
|
) &&
|
||||||
|
isModelConfigReady(
|
||||||
|
state.llmForm.rerankerProvider,
|
||||||
|
state.llmForm.rerankerModel,
|
||||||
|
state.llmForm.rerankerEndpoint
|
||||||
|
)
|
||||||
|
),
|
||||||
|
rendering: Boolean(
|
||||||
|
!state.renderForm.enabled ||
|
||||||
|
(normalizeValue(state.renderForm.publicUrl) &&
|
||||||
|
(normalizeValue(state.renderForm.jwtSecret) || state.renderForm.jwtSecretConfigured))
|
||||||
|
),
|
||||||
|
logs: Boolean(
|
||||||
|
normalizeValue(state.logForm.level) &&
|
||||||
|
Number(state.logForm.retentionDays) > 0 &&
|
||||||
|
normalizeValue(state.logForm.logPath)
|
||||||
|
),
|
||||||
|
mail: Boolean(
|
||||||
|
normalizeValue(state.mailForm.smtpHost) &&
|
||||||
|
Number(state.mailForm.port) > 0 &&
|
||||||
|
normalizeValue(state.mailForm.senderAddress) &&
|
||||||
|
normalizeValue(state.mailForm.username)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
:nav-items="filteredNavItems"
|
:nav-items="filteredNavItems"
|
||||||
:active-view="activeView"
|
:active-view="activeView"
|
||||||
:company-name="companyProfile.name"
|
:company-name="companyProfile.name"
|
||||||
|
:company-logo="companyProfile.logo"
|
||||||
:current-user="currentUser"
|
:current-user="currentUser"
|
||||||
@navigate="handleNavigate"
|
@navigate="handleNavigate"
|
||||||
@open-chat="openSmartEntry"
|
@open-chat="openSmartEntry"
|
||||||
|
|||||||
136
web/src/views/HermesEmployeeSettingsPanel.vue
Normal file
136
web/src/views/HermesEmployeeSettingsPanel.vue
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
<template>
|
||||||
|
<div class="hermes-settings-container">
|
||||||
|
<!-- 主控制卡片 -->
|
||||||
|
<section class="settings-card hermes-hero-card" :class="{ active: hermesForm.masterEnabled }">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box" :class="{ active: hermesForm.masterEnabled }">
|
||||||
|
<i class="mdi mdi-robot"></i>
|
||||||
|
<span class="status-pulse-dot" :class="{ active: hermesForm.masterEnabled }"></span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>数字员工自动任务主控</h4>
|
||||||
|
<p>开启后系统将自动按计划调度后台数字员工执行知识同步、规则待审、风险扫描及数据统计任务。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-head-actions">
|
||||||
|
<span class="status-badge" :class="{ active: hermesForm.masterEnabled }">
|
||||||
|
{{ hermesForm.masterEnabled ? '调度服务运行中' : '调度服务已禁用' }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="switch-btn"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: hermesForm.masterEnabled }"
|
||||||
|
aria-label="切换全局自动任务"
|
||||||
|
@click="$emit('toggle-master')"
|
||||||
|
>
|
||||||
|
<i></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 任务网格控制 -->
|
||||||
|
<section class="settings-card hermes-tasks-section" :class="{ disabled: !hermesForm.masterEnabled }">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box">
|
||||||
|
<i class="mdi mdi-clipboard-list-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>自动任务项管理</h4>
|
||||||
|
<p>配置并调度具体的后台异步任务,在设定的周期与执行时间定时运行。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-head-actions">
|
||||||
|
<span class="section-badge">已启用 {{ activeTasksCount }} / {{ HERMES_SIMPLE_TASKS.length }} 项</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul class="hermes-task-grid">
|
||||||
|
<li
|
||||||
|
v-for="task in HERMES_SIMPLE_TASKS"
|
||||||
|
:key="task.id"
|
||||||
|
class="hermes-task-card"
|
||||||
|
:class="{ active: isTaskOn(task.id), disabled: !hermesForm.masterEnabled }"
|
||||||
|
>
|
||||||
|
<div class="task-card-header">
|
||||||
|
<div class="task-icon-box" :class="getTaskColorClass(task.id)">
|
||||||
|
<i class="mdi" :class="getTaskIcon(task.id)"></i>
|
||||||
|
</div>
|
||||||
|
<div class="task-meta-info">
|
||||||
|
<strong>{{ task.label }}</strong>
|
||||||
|
<small>{{ task.hint }}</small>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="switch-btn mini"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: isTaskOn(task.id) }"
|
||||||
|
:disabled="!hermesForm.masterEnabled"
|
||||||
|
:aria-label="`${isTaskOn(task.id) ? '关闭' : '开启'}${task.label}`"
|
||||||
|
@click="$emit('toggle-task', task.id)"
|
||||||
|
>
|
||||||
|
<i></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="task-card-footer">
|
||||||
|
<div class="frequency-badge" :class="{ active: isTaskOn(task.id) }">
|
||||||
|
<i class="mdi mdi-clock-outline"></i>
|
||||||
|
<span>{{ task.frequencyLabel }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="isTaskOn(task.id)" class="time-picker-wrapper">
|
||||||
|
<input
|
||||||
|
:value="taskTime(task.id)"
|
||||||
|
type="time"
|
||||||
|
:disabled="!hermesForm.masterEnabled"
|
||||||
|
aria-label="设置执行时间"
|
||||||
|
@input="$emit('update-task-time', { taskId: task.id, time: $event.target.value })"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-else class="time-picker-placeholder">
|
||||||
|
<span>未开启</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 额外设置 (任务告警通知) -->
|
||||||
|
<section class="settings-card hermes-extra-settings">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box">
|
||||||
|
<i class="mdi mdi-bell-ring-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>任务告警通知</h4>
|
||||||
|
<p>配置自动任务运行出现故障、限频或执行失败时的管理员实时提醒通知渠道。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="switch-group">
|
||||||
|
<button
|
||||||
|
class="switch-row"
|
||||||
|
type="button"
|
||||||
|
:class="{ active: hermesForm.notifyOnFailure }"
|
||||||
|
aria-label="切换邮件通知"
|
||||||
|
@click="$emit('toggle-flag', 'notifyOnFailure')"
|
||||||
|
>
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>任务失败时发送邮件通知管理员</strong>
|
||||||
|
<small>仅在自动任务执行出现故障或异常时触发告警,保障后台服务的高可用性。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch-btn" :class="{ active: hermesForm.notifyOnFailure }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./scripts/HermesEmployeeSettingsPanel.js"></script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/views/settings-view-form.css"></style>
|
||||||
|
<style scoped src="../assets/styles/views/settings-view.css"></style>
|
||||||
|
<style scoped src="../assets/styles/views/settings-view-hermes.css"></style>
|
||||||
309
web/src/views/LlmSettingsPanel.vue
Normal file
309
web/src/views/LlmSettingsPanel.vue
Normal file
@@ -0,0 +1,309 @@
|
|||||||
|
<template>
|
||||||
|
<div class="model-grid">
|
||||||
|
<!-- 主模型配置 -->
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box purple">
|
||||||
|
<i class="mdi mdi-brain"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>主模型配置</h4>
|
||||||
|
<p>用于 AI 助手和主业务排队调度的默认模型接入。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-head-actions">
|
||||||
|
<button
|
||||||
|
class="test-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isModelTesting('main')"
|
||||||
|
@click="testModelConnection('main')"
|
||||||
|
>
|
||||||
|
<i :class="isModelTesting('main') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
||||||
|
<span>{{ isModelTesting('main') ? '测试中...' : '测试模型' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 供应商</span>
|
||||||
|
<select v-model="llmForm.mainProvider" @change="applyProviderPreset('main')">
|
||||||
|
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 模型名称</span>
|
||||||
|
<input v-model="llmForm.mainModel" type="text" placeholder="请输入主模型名称" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span><em>*</em> 接口地址</span>
|
||||||
|
<input v-model="llmForm.mainEndpoint" type="text" placeholder="请输入模型接口地址" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span>API Key</span>
|
||||||
|
<input
|
||||||
|
v-model="llmForm.mainApiKey"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="clearModelSecretMask('main')"
|
||||||
|
:placeholder="llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
||||||
|
/>
|
||||||
|
<small v-if="llmForm.mainApiKeyConfigured" class="secret-bound-state">
|
||||||
|
<i class="mdi mdi-database-lock"></i>
|
||||||
|
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`">
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
getModelTestState('main').status === 'success'
|
||||||
|
? 'mdi mdi-check-circle'
|
||||||
|
: getModelTestState('main').status === 'testing'
|
||||||
|
? 'mdi mdi-loading mdi-spin'
|
||||||
|
: 'mdi mdi-alert-circle'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
<span>{{ getModelTestState('main').message }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- 备份模型配置 -->
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box orange">
|
||||||
|
<i class="mdi mdi-lifebuoy"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>备份模型配置</h4>
|
||||||
|
<p>主模型不可用或限频时用于兜底切换的备用模型接入。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-head-actions">
|
||||||
|
<button
|
||||||
|
class="test-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isModelTesting('backup')"
|
||||||
|
@click="testModelConnection('backup')"
|
||||||
|
>
|
||||||
|
<i :class="isModelTesting('backup') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
||||||
|
<span>{{ isModelTesting('backup') ? '测试中...' : '测试模型' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 供应商</span>
|
||||||
|
<select v-model="llmForm.backupProvider" @change="applyProviderPreset('backup')">
|
||||||
|
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 模型名称</span>
|
||||||
|
<input v-model="llmForm.backupModel" type="text" placeholder="请输入备份模型名称" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span><em>*</em> 接口地址</span>
|
||||||
|
<input v-model="llmForm.backupEndpoint" type="text" placeholder="请输入模型接口地址" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span>API Key</span>
|
||||||
|
<input
|
||||||
|
v-model="llmForm.backupApiKey"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="clearModelSecretMask('backup')"
|
||||||
|
:placeholder="llmForm.backupApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
||||||
|
/>
|
||||||
|
<small v-if="llmForm.backupApiKeyConfigured" class="secret-bound-state">
|
||||||
|
<i class="mdi mdi-database-lock"></i>
|
||||||
|
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`">
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
getModelTestState('backup').status === 'success'
|
||||||
|
? 'mdi mdi-check-circle'
|
||||||
|
: getModelTestState('backup').status === 'testing'
|
||||||
|
? 'mdi mdi-loading mdi-spin'
|
||||||
|
: 'mdi mdi-alert-circle'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
<span>{{ getModelTestState('backup').message }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Embedding 模型配置 -->
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box cyan">
|
||||||
|
<i class="mdi mdi-vector-combine"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Embedding 模型配置</h4>
|
||||||
|
<p>用于向量检索、知识库召回和语义匹配的嵌入模型设置。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-head-actions">
|
||||||
|
<button
|
||||||
|
class="test-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isModelTesting('embedding')"
|
||||||
|
@click="testModelConnection('embedding')"
|
||||||
|
>
|
||||||
|
<i :class="isModelTesting('embedding') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
||||||
|
<span>{{ isModelTesting('embedding') ? '测试中...' : '测试模型' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 供应商</span>
|
||||||
|
<select v-model="llmForm.embeddingProvider" @change="applyProviderPreset('embedding')">
|
||||||
|
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 模型名称</span>
|
||||||
|
<input v-model="llmForm.embeddingModel" type="text" placeholder="请输入 Embedding 模型名称" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span><em>*</em> 接口地址</span>
|
||||||
|
<input v-model="llmForm.embeddingEndpoint" type="text" placeholder="请输入模型接口地址" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span>API Key</span>
|
||||||
|
<input
|
||||||
|
v-model="llmForm.embeddingApiKey"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="clearModelSecretMask('embedding')"
|
||||||
|
:placeholder="llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
||||||
|
/>
|
||||||
|
<small v-if="llmForm.embeddingApiKeyConfigured" class="secret-bound-state">
|
||||||
|
<i class="mdi mdi-database-lock"></i>
|
||||||
|
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="getModelTestState('embedding').message"
|
||||||
|
class="test-feedback"
|
||||||
|
:class="`is-${getModelTestState('embedding').status}`"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
getModelTestState('embedding').status === 'success'
|
||||||
|
? 'mdi mdi-check-circle'
|
||||||
|
: getModelTestState('embedding').status === 'testing'
|
||||||
|
? 'mdi mdi-loading mdi-spin'
|
||||||
|
: 'mdi mdi-alert-circle'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
<span>{{ getModelTestState('embedding').message }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Reranker 模型配置 -->
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box teal">
|
||||||
|
<i class="mdi mdi-filter-variant"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>Reranker 模型配置</h4>
|
||||||
|
<p>用于检索结果重排和语义精排的 Reranker 模型设置。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-head-actions">
|
||||||
|
<button
|
||||||
|
class="test-button"
|
||||||
|
type="button"
|
||||||
|
:disabled="isModelTesting('reranker')"
|
||||||
|
@click="testModelConnection('reranker')"
|
||||||
|
>
|
||||||
|
<i :class="isModelTesting('reranker') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
||||||
|
<span>{{ isModelTesting('reranker') ? '测试中...' : '测试模型' }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 供应商</span>
|
||||||
|
<select v-model="llmForm.rerankerProvider" @change="applyProviderPreset('reranker')">
|
||||||
|
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 模型名称</span>
|
||||||
|
<input v-model="llmForm.rerankerModel" type="text" placeholder="请输入 Reranker 模型名称" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span><em>*</em> 接口地址</span>
|
||||||
|
<input v-model="llmForm.rerankerEndpoint" type="text" placeholder="请输入模型接口地址" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span>API Key</span>
|
||||||
|
<input
|
||||||
|
v-model="llmForm.rerankerApiKey"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="clearModelSecretMask('reranker')"
|
||||||
|
:placeholder="llmForm.rerankerApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
||||||
|
/>
|
||||||
|
<small v-if="llmForm.rerankerApiKeyConfigured" class="secret-bound-state">
|
||||||
|
<i class="mdi mdi-database-lock"></i>
|
||||||
|
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="getModelTestState('reranker').message"
|
||||||
|
class="test-feedback"
|
||||||
|
:class="`is-${getModelTestState('reranker').status}`"
|
||||||
|
>
|
||||||
|
<i
|
||||||
|
:class="
|
||||||
|
getModelTestState('reranker').status === 'success'
|
||||||
|
? 'mdi mdi-check-circle'
|
||||||
|
: getModelTestState('reranker').status === 'testing'
|
||||||
|
? 'mdi mdi-loading mdi-spin'
|
||||||
|
: 'mdi mdi-alert-circle'
|
||||||
|
"
|
||||||
|
></i>
|
||||||
|
<span>{{ getModelTestState('reranker').message }}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./scripts/LlmSettingsPanel.js"></script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/views/settings-view-form.css"></style>
|
||||||
|
<style scoped src="../assets/styles/views/settings-view.css"></style>
|
||||||
|
|
||||||
@@ -43,6 +43,11 @@
|
|||||||
{{ hermesRunAlert.message }}
|
{{ hermesRunAlert.message }}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<KnowledgeIngestRunPanel
|
||||||
|
v-if="isKnowledgeIngestRunDetail"
|
||||||
|
:run="hermesRun"
|
||||||
|
/>
|
||||||
|
|
||||||
<div class="detail-grid">
|
<div class="detail-grid">
|
||||||
<article class="panel detail-card wide">
|
<article class="panel detail-card wide">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
@@ -63,9 +68,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article class="panel detail-card">
|
<article v-if="!isKnowledgeIngestRunDetail" class="panel detail-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>处理链路</h3>
|
<h3>处理链路</h3>
|
||||||
<p>按工具调用顺序查看执行链。</p>
|
<p>按工具调用顺序查看执行链。</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="(hermesRun.tool_calls || []).length" class="trace-steps">
|
<div v-if="(hermesRun.tool_calls || []).length" class="trace-steps">
|
||||||
@@ -92,7 +97,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<article v-if="selectedToolCall" class="panel detail-card">
|
<article v-if="selectedToolCall && !isKnowledgeIngestRunDetail" class="panel detail-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<h3>当前 ToolCall</h3>
|
<h3>当前 ToolCall</h3>
|
||||||
<p>查看当前工具调用的请求与返回。</p>
|
<p>查看当前工具调用的请求与返回。</p>
|
||||||
@@ -194,6 +199,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import KnowledgeIngestRunPanel from '../components/logs/KnowledgeIngestRunPanel.vue'
|
||||||
import { fetchAgentRunDetail } from '../services/agentAssets.js'
|
import { fetchAgentRunDetail } from '../services/agentAssets.js'
|
||||||
import { fetchSystemLogEntry } from '../services/systemLogs.js'
|
import { fetchSystemLogEntry } from '../services/systemLogs.js'
|
||||||
import {
|
import {
|
||||||
@@ -204,6 +210,7 @@ import {
|
|||||||
resolveAgentRunHeartbeat,
|
resolveAgentRunHeartbeat,
|
||||||
resolveAgentRunStatus
|
resolveAgentRunStatus
|
||||||
} from '../utils/agentRunMonitor.js'
|
} from '../utils/agentRunMonitor.js'
|
||||||
|
import { isKnowledgeIngestRun } from '../utils/knowledgeIngestLogModel.js'
|
||||||
|
|
||||||
const SOURCE_LABELS = {
|
const SOURCE_LABELS = {
|
||||||
schedule: '定时任务',
|
schedule: '定时任务',
|
||||||
@@ -223,6 +230,7 @@ let pollTimer = 0
|
|||||||
|
|
||||||
const isHermes = computed(() => route.params.logKind === 'hermes')
|
const isHermes = computed(() => route.params.logKind === 'hermes')
|
||||||
const isSystem = computed(() => route.params.logKind === 'system')
|
const isSystem = computed(() => route.params.logKind === 'system')
|
||||||
|
const isKnowledgeIngestRunDetail = computed(() => isKnowledgeIngestRun(hermesRun.value))
|
||||||
const selectedToolCall = computed(() =>
|
const selectedToolCall = computed(() =>
|
||||||
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
|
(hermesRun.value?.tool_calls || []).find((item) => item.id === selectedToolCallId.value) || null
|
||||||
)
|
)
|
||||||
|
|||||||
163
web/src/views/MailSettingsPanel.vue
Normal file
163
web/src/views/MailSettingsPanel.vue
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
<template>
|
||||||
|
<div class="mail-panel-container">
|
||||||
|
<!-- Card 1: SMTP Server Configuration -->
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box slate">
|
||||||
|
<i class="mdi mdi-send-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>发信服务器配置</h4>
|
||||||
|
<p>维护系统发信的 SMTP 服务端点、网络端口与身份验证凭证。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> SMTP Host</span>
|
||||||
|
<input v-model="mailForm.smtpHost" type="text" placeholder="例如 smtp.exmail.qq.com" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 端口</span>
|
||||||
|
<input v-model.number="mailForm.port" type="number" min="1" max="65535" placeholder="SSL 默认 465" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span>加密方式</span>
|
||||||
|
<select v-model="mailForm.encryption">
|
||||||
|
<option value="SSL/TLS">SSL/TLS</option>
|
||||||
|
<option value="STARTTLS">STARTTLS</option>
|
||||||
|
<option value="None">无</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 登录账号</span>
|
||||||
|
<input v-model="mailForm.username" type="text" placeholder="请输入 SMTP 登录账号,通常为发信邮箱" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span>SMTP 密码</span>
|
||||||
|
<input
|
||||||
|
v-model="mailForm.password"
|
||||||
|
type="password"
|
||||||
|
autocomplete="new-password"
|
||||||
|
:placeholder="mailForm.passwordConfigured ? '已配置,如需修改请重新输入' : '请输入 SMTP 密码或客户端授权码'"
|
||||||
|
/>
|
||||||
|
<small v-if="mailForm.passwordConfigured" class="secret-bound-state">
|
||||||
|
<i class="mdi mdi-database-lock"></i>
|
||||||
|
<span>密保已加密安全托管</span>
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Card 2: Sender Identity Card -->
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box slate">
|
||||||
|
<i class="mdi mdi-account-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>发信身份配置</h4>
|
||||||
|
<p>设置邮件外显名称及默认的邮件接收账户。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span>发件人名称</span>
|
||||||
|
<input v-model="mailForm.senderName" type="text" placeholder="例如 X-Financial 财务系统" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 发件人邮箱</span>
|
||||||
|
<input v-model="mailForm.senderAddress" type="email" placeholder="例如 admin@company.com" />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span>默认接收邮箱</span>
|
||||||
|
<input v-model="mailForm.defaultReceiver" type="email" placeholder="审批待办等默认分发邮箱" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Card 3: Notification Policy Card -->
|
||||||
|
<section class="settings-card notice-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box slate">
|
||||||
|
<i class="mdi mdi-bell-ring-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>通知策略</h4>
|
||||||
|
<p>控制各类业务消息是否通过邮件通知以及定时摘要发送频率。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="switch-group">
|
||||||
|
<button class="switch-row" type="button" @click="toggleField('alertEnabled')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>启用系统通知</strong>
|
||||||
|
<small>审批申请、异常告警和核心系统事件可通过邮件实时触达用户。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch-btn" :class="{ active: mailForm.alertEnabled }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button class="switch-row" type="button" @click="toggleField('digestEnabled')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>启用日报摘要</strong>
|
||||||
|
<small>每天定时发送系统运行概况与待办任务列表的邮件摘要。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch-btn" :class="{ active: mailForm.digestEnabled }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="digest-time-wrapper">
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field" :class="{ disabled: !mailForm.digestEnabled }">
|
||||||
|
<span>摘要发送时间</span>
|
||||||
|
<input v-model="mailForm.digestTime" type="time" :disabled="!mailForm.digestEnabled" />
|
||||||
|
<small>设定每日发送邮件简报的具体时间点。</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script src="./scripts/MailSettingsPanel.js"></script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/views/settings-view-form.css"></style>
|
||||||
|
<style scoped src="../assets/styles/views/settings-view.css"></style>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.mail-panel-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notice-card .switch-group {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.digest-time-wrapper {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field.disabled input {
|
||||||
|
background: #f1f5f9;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<div class="settings-nav-head">
|
<div class="settings-nav-head">
|
||||||
<span class="nav-kicker">Settings</span>
|
<span class="nav-kicker">Settings</span>
|
||||||
<h2>系统设置</h2>
|
<h2>系统设置</h2>
|
||||||
<p>已完成 {{ completedSectionCount }} / {{ sections.length }} 项配置,敏感字段不会保存在浏览器草稿中。</p>
|
<p>已完成 {{ completedSectionCount }} / {{ sections.length }} 项配置</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="settings-nav-list">
|
<nav class="settings-nav-list">
|
||||||
@@ -47,18 +47,26 @@
|
|||||||
<template v-if="activeSection === 'profile'">
|
<template v-if="activeSection === 'profile'">
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div class="card-title-with-icon">
|
||||||
<h4>系统基本信息</h4>
|
<div class="model-icon-box slate">
|
||||||
<p>统一维护企业名称、系统显示名称和版权信息,保存后会同步更新当前系统品牌名称。</p>
|
<i class="mdi mdi-domain"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>系统基本信息</h4>
|
||||||
|
<p>统一维护企业名称、系统显示名称和版权信息,保存后会同步更新当前系统品牌名称。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-grid profile-grid">
|
<div class="form-grid profile-grid">
|
||||||
<label class="field logo-field">
|
<label class="field logo-field">
|
||||||
<span><em>*</em> 系统图标</span>
|
<span><em>*</em> 系统图标</span>
|
||||||
<div class="logo-tile" aria-hidden="true">
|
<div class="logo-tile" aria-hidden="true" @click="triggerLogoUpload" style="cursor: pointer; overflow: hidden;" title="点击上传图片">
|
||||||
<i class="mdi mdi-domain"></i>
|
<img v-if="pageState.companyForm.logo" :src="pageState.companyForm.logo" style="width:100%;height:100%;object-fit:contain;" />
|
||||||
|
<i v-else class="mdi mdi-domain"></i>
|
||||||
</div>
|
</div>
|
||||||
|
<input type="file" ref="logoInputRef" accept="image/*" style="display: none;" @change="handleLogoUpload" />
|
||||||
|
<small style="color:#64748b; font-size:12px; margin-top:4px;">建议尺寸 64x64,PNG/JPG 格式</small>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="field">
|
<label class="field">
|
||||||
@@ -96,9 +104,14 @@
|
|||||||
<template v-else-if="activeSection === 'admin'">
|
<template v-else-if="activeSection === 'admin'">
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div class="card-title-with-icon">
|
||||||
<h4>管理员账号</h4>
|
<div class="model-icon-box slate">
|
||||||
<p>维护最高权限管理员的登录账户、密码和安全通知邮箱。</p>
|
<i class="mdi mdi-account-cog-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>管理员账号</h4>
|
||||||
|
<p>维护最高权限管理员的登录账户、密码和安全通知邮箱。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -137,9 +150,14 @@
|
|||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div class="card-title-with-icon">
|
||||||
<h4>登录安全策略</h4>
|
<div class="model-icon-box slate">
|
||||||
<p>控制会话超时、登录提醒和管理员高风险操作的基础安全策略。</p>
|
<i class="mdi mdi-shield-lock-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>登录安全策略</h4>
|
||||||
|
<p>控制会话超时、登录提醒和管理员高风险操作的基础安全策略。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -161,7 +179,7 @@
|
|||||||
<strong>开启双因素验证</strong>
|
<strong>开启双因素验证</strong>
|
||||||
<small>要求管理员使用附加验证步骤登录后台。</small>
|
<small>要求管理员使用附加验证步骤登录后台。</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="switch" :class="{ active: pageState.adminForm.mfaEnabled }"><i></i></span>
|
<span class="switch-btn" :class="{ active: pageState.adminForm.mfaEnabled }"><i></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'strongPassword')">
|
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'strongPassword')">
|
||||||
@@ -169,7 +187,7 @@
|
|||||||
<strong>启用强密码策略</strong>
|
<strong>启用强密码策略</strong>
|
||||||
<small>管理员密码修改时需要满足强度要求。</small>
|
<small>管理员密码修改时需要满足强度要求。</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="switch" :class="{ active: pageState.adminForm.strongPassword }"><i></i></span>
|
<span class="switch-btn" :class="{ active: pageState.adminForm.strongPassword }"><i></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'loginAlertEnabled')">
|
<button class="switch-row" type="button" @click="toggleBoolean('adminForm', 'loginAlertEnabled')">
|
||||||
@@ -177,356 +195,154 @@
|
|||||||
<strong>异常登录提醒</strong>
|
<strong>异常登录提醒</strong>
|
||||||
<small>检测到高风险登录时,向安全通知邮箱发送告警。</small>
|
<small>检测到高风险登录时,向安全通知邮箱发送告警。</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="switch" :class="{ active: pageState.adminForm.loginAlertEnabled }"><i></i></span>
|
<span class="switch-btn" :class="{ active: pageState.adminForm.loginAlertEnabled }"><i></i></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="activeSection === 'session'">
|
|
||||||
<section class="settings-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h4>会话保留策略</h4>
|
|
||||||
<p>控制智能体会话在系统中的保留时长,超过保留期的历史会话会自动清理。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid compact-grid">
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 保留会话天数</span>
|
|
||||||
<div
|
|
||||||
ref="sessionRetentionPickerRef"
|
|
||||||
class="session-picker-filter"
|
|
||||||
:class="{ open: sessionRetentionPickerOpen }"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="session-picker-trigger"
|
|
||||||
type="button"
|
|
||||||
:aria-expanded="sessionRetentionPickerOpen"
|
|
||||||
aria-haspopup="dialog"
|
|
||||||
@click="toggleSessionRetentionPicker"
|
|
||||||
>
|
|
||||||
<span class="session-picker-label">{{ pageState.sessionForm.conversationRetentionDays }} 天</span>
|
|
||||||
<i class="mdi mdi-chevron-down"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="sessionRetentionPickerOpen"
|
|
||||||
class="session-picker-popover"
|
|
||||||
role="dialog"
|
|
||||||
aria-label="选择会话保留天数"
|
|
||||||
>
|
|
||||||
<header>
|
|
||||||
<strong>选择会话保留天数</strong>
|
|
||||||
<button type="button" aria-label="关闭会话保留天数选择" @click="closeSessionRetentionPicker">
|
|
||||||
<i class="mdi mdi-close"></i>
|
|
||||||
</button>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div class="session-picker-option-list">
|
|
||||||
<button
|
|
||||||
v-for="option in sessionRetentionOptions"
|
|
||||||
:key="option.value"
|
|
||||||
type="button"
|
|
||||||
class="session-picker-option"
|
|
||||||
:class="{ active: pageState.sessionForm.conversationRetentionDays === option.value }"
|
|
||||||
@click="selectSessionRetentionDays(option.value)"
|
|
||||||
>
|
|
||||||
{{ option.label }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<small>最小 1 天,最大 10 天,按会话最后活跃时间计算。</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="activeSection === 'llm'">
|
|
||||||
<div class="model-grid">
|
|
||||||
<section class="settings-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h4>主模型配置</h4>
|
|
||||||
<p>用于 AI 助手和主业务链路的默认模型接入。</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-head-actions">
|
|
||||||
<button class="test-button" type="button" :disabled="isModelTesting('main')" @click="testModelConnection('main')">
|
|
||||||
<i :class="isModelTesting('main') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
|
||||||
<span>{{ isModelTesting('main') ? '测试中...' : '测试模型' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
<template v-else-if="activeSection === 'session'">
|
||||||
<div class="field field-full">
|
<section class="settings-card">
|
||||||
<small class="secret-bound-state">
|
|
||||||
<i class="mdi mdi-source-branch"></i>
|
|
||||||
<span>保存后会同步写入 Hermes 配置;外部 Hermes agent 也可通过后端共享接口读取这里的主模型配置。</span>
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 供应商</span>
|
|
||||||
<select v-model="pageState.llmForm.mainProvider" @change="applyProviderPreset('main')">
|
|
||||||
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 模型名称</span>
|
|
||||||
<input v-model="pageState.llmForm.mainModel" type="text" placeholder="请输入主模型名称" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span><em>*</em> 接口地址</span>
|
|
||||||
<input v-model="pageState.llmForm.mainEndpoint" type="text" placeholder="请输入模型接口地址" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span>API Key</span>
|
|
||||||
<input
|
|
||||||
v-model="pageState.llmForm.mainApiKey"
|
|
||||||
type="password"
|
|
||||||
autocomplete="off"
|
|
||||||
@focus="clearModelSecretMask('main')"
|
|
||||||
:placeholder="pageState.llmForm.mainApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
|
||||||
/>
|
|
||||||
<small v-if="pageState.llmForm.mainApiKeyConfigured" class="secret-bound-state">
|
|
||||||
<i class="mdi mdi-database-lock"></i>
|
|
||||||
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div v-if="getModelTestState('main').message" class="test-feedback" :class="`is-${getModelTestState('main').status}`">
|
|
||||||
<i :class="getModelTestState('main').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('main').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
|
|
||||||
<span>{{ getModelTestState('main').message }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h4>备份模型配置</h4>
|
|
||||||
<p>主模型不可用时用于兜底切换的备用模型接入。</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-head-actions">
|
|
||||||
<button class="test-button" type="button" :disabled="isModelTesting('backup')" @click="testModelConnection('backup')">
|
|
||||||
<i :class="isModelTesting('backup') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
|
||||||
<span>{{ isModelTesting('backup') ? '测试中...' : '测试模型' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 供应商</span>
|
|
||||||
<select v-model="pageState.llmForm.backupProvider" @change="applyProviderPreset('backup')">
|
|
||||||
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 模型名称</span>
|
|
||||||
<input v-model="pageState.llmForm.backupModel" type="text" placeholder="请输入备份模型名称" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span><em>*</em> 接口地址</span>
|
|
||||||
<input v-model="pageState.llmForm.backupEndpoint" type="text" placeholder="请输入模型接口地址" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span>API Key</span>
|
|
||||||
<input
|
|
||||||
v-model="pageState.llmForm.backupApiKey"
|
|
||||||
type="password"
|
|
||||||
autocomplete="off"
|
|
||||||
@focus="clearModelSecretMask('backup')"
|
|
||||||
:placeholder="pageState.llmForm.backupApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
|
||||||
/>
|
|
||||||
<small v-if="pageState.llmForm.backupApiKeyConfigured" class="secret-bound-state">
|
|
||||||
<i class="mdi mdi-database-lock"></i>
|
|
||||||
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div v-if="getModelTestState('backup').message" class="test-feedback" :class="`is-${getModelTestState('backup').status}`">
|
|
||||||
<i :class="getModelTestState('backup').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('backup').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
|
|
||||||
<span>{{ getModelTestState('backup').message }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h4>Embedding 模型配置</h4>
|
|
||||||
<p>用于向量检索、知识库召回和语义匹配的嵌入模型设置。</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-head-actions">
|
|
||||||
<button class="test-button" type="button" :disabled="isModelTesting('embedding')" @click="testModelConnection('embedding')">
|
|
||||||
<i :class="isModelTesting('embedding') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
|
||||||
<span>{{ isModelTesting('embedding') ? '测试中...' : '测试模型' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 供应商</span>
|
|
||||||
<select v-model="pageState.llmForm.embeddingProvider" @change="applyProviderPreset('embedding')">
|
|
||||||
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 模型名称</span>
|
|
||||||
<input v-model="pageState.llmForm.embeddingModel" type="text" placeholder="请输入 Embedding 模型名称" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span><em>*</em> 接口地址</span>
|
|
||||||
<input v-model="pageState.llmForm.embeddingEndpoint" type="text" placeholder="请输入模型接口地址" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span>API Key</span>
|
|
||||||
<input
|
|
||||||
v-model="pageState.llmForm.embeddingApiKey"
|
|
||||||
type="password"
|
|
||||||
autocomplete="off"
|
|
||||||
@focus="clearModelSecretMask('embedding')"
|
|
||||||
:placeholder="pageState.llmForm.embeddingApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
|
||||||
/>
|
|
||||||
<small v-if="pageState.llmForm.embeddingApiKeyConfigured" class="secret-bound-state">
|
|
||||||
<i class="mdi mdi-database-lock"></i>
|
|
||||||
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="getModelTestState('embedding').message"
|
|
||||||
class="test-feedback"
|
|
||||||
:class="`is-${getModelTestState('embedding').status}`"
|
|
||||||
>
|
|
||||||
<i :class="getModelTestState('embedding').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('embedding').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
|
|
||||||
<span>{{ getModelTestState('embedding').message }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h4>Reranker 模型配置</h4>
|
|
||||||
<p>用于检索结果重排和语义精排的 Reranker 模型设置。</p>
|
|
||||||
</div>
|
|
||||||
<div class="card-head-actions">
|
|
||||||
<button class="test-button" type="button" :disabled="isModelTesting('reranker')" @click="testModelConnection('reranker')">
|
|
||||||
<i :class="isModelTesting('reranker') ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-connection'"></i>
|
|
||||||
<span>{{ isModelTesting('reranker') ? '测试中...' : '测试模型' }}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 供应商</span>
|
|
||||||
<select v-model="pageState.llmForm.rerankerProvider" @change="applyProviderPreset('reranker')">
|
|
||||||
<option v-for="option in providerOptions" :key="option" :value="option">{{ option }}</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 模型名称</span>
|
|
||||||
<input v-model="pageState.llmForm.rerankerModel" type="text" placeholder="请输入 Reranker 模型名称" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span><em>*</em> 接口地址</span>
|
|
||||||
<input v-model="pageState.llmForm.rerankerEndpoint" type="text" placeholder="请输入模型接口地址" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span>API Key</span>
|
|
||||||
<input
|
|
||||||
v-model="pageState.llmForm.rerankerApiKey"
|
|
||||||
type="password"
|
|
||||||
autocomplete="off"
|
|
||||||
@focus="clearModelSecretMask('reranker')"
|
|
||||||
:placeholder="pageState.llmForm.rerankerApiKeyConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
|
||||||
/>
|
|
||||||
<small v-if="pageState.llmForm.rerankerApiKeyConfigured" class="secret-bound-state">
|
|
||||||
<i class="mdi mdi-database-lock"></i>
|
|
||||||
<span>已从数据库加密加载,测试会使用已保存密钥。</span>
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
v-if="getModelTestState('reranker').message"
|
|
||||||
class="test-feedback"
|
|
||||||
:class="`is-${getModelTestState('reranker').status}`"
|
|
||||||
>
|
|
||||||
<i :class="getModelTestState('reranker').status === 'success' ? 'mdi mdi-check-circle' : getModelTestState('reranker').status === 'testing' ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-alert-circle'"></i>
|
|
||||||
<span>{{ getModelTestState('reranker').message }}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="activeSection === 'rendering'">
|
|
||||||
<section class="settings-card rendering-settings-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h4>ONLYOFFICE 服务配置</h4>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="switch-group">
|
|
||||||
<button class="switch-row" type="button" @click="toggleBoolean('renderForm', 'enabled')">
|
|
||||||
<span class="switch-copy">
|
|
||||||
<strong>启用 ONLYOFFICE 文件渲染</strong>
|
|
||||||
<small>启用后,知识库中的 Office 文件将优先走 ONLYOFFICE 在线预览。</small>
|
|
||||||
</span>
|
|
||||||
<span class="switch" :class="{ active: pageState.renderForm.enabled }"><i></i></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<label class="field field-full">
|
|
||||||
<span><em>*</em> ONLYOFFICE 服务地址</span>
|
|
||||||
<input
|
|
||||||
v-model="pageState.renderForm.publicUrl"
|
|
||||||
type="text"
|
|
||||||
placeholder="例如 http://10.10.10.122:8082"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span><em>*</em> JWT 密钥</span>
|
|
||||||
<input
|
|
||||||
v-model="pageState.renderForm.jwtSecret"
|
|
||||||
type="password"
|
|
||||||
autocomplete="off"
|
|
||||||
@focus="clearRenderSecretMask"
|
|
||||||
:placeholder="pageState.renderForm.jwtSecretConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
|
||||||
/>
|
|
||||||
<small v-if="pageState.renderForm.jwtSecretConfigured" class="secret-bound-state">
|
|
||||||
<i class="mdi mdi-database-lock"></i>
|
|
||||||
<span>已从数据库加密加载,预览签名会使用已保存密钥。</span>
|
|
||||||
</small>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template v-else-if="activeSection === 'logs'">
|
|
||||||
<section class="settings-card">
|
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div class="card-title-with-icon">
|
||||||
<h4>日志级别与留存</h4>
|
<div class="model-icon-box slate">
|
||||||
<p>定义系统记录粒度、归档周期和告警接收人,方便后续审计与排障。</p>
|
<i class="mdi mdi-clock-time-three-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>会话保留策略</h4>
|
||||||
|
<p>控制智能体会话在系统中的保留时长,超过保留期的历史会话会自动清理。</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid compact-grid">
|
||||||
|
<label class="field">
|
||||||
|
<span><em>*</em> 保留会话天数</span>
|
||||||
|
<div
|
||||||
|
ref="sessionRetentionPickerRef"
|
||||||
|
class="session-picker-filter"
|
||||||
|
:class="{ open: sessionRetentionPickerOpen }"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="session-picker-trigger"
|
||||||
|
type="button"
|
||||||
|
:aria-expanded="sessionRetentionPickerOpen"
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
@click="toggleSessionRetentionPicker"
|
||||||
|
>
|
||||||
|
<span class="session-picker-label">{{ pageState.sessionForm.conversationRetentionDays }} 天</span>
|
||||||
|
<i class="mdi mdi-chevron-down"></i>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="sessionRetentionPickerOpen"
|
||||||
|
class="session-picker-popover"
|
||||||
|
role="dialog"
|
||||||
|
aria-label="选择会话保留天数"
|
||||||
|
>
|
||||||
|
<header>
|
||||||
|
<strong>选择会话保留天数</strong>
|
||||||
|
<button type="button" aria-label="关闭会话保留天数选择" @click="closeSessionRetentionPicker">
|
||||||
|
<i class="mdi mdi-close"></i>
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="session-picker-option-list">
|
||||||
|
<button
|
||||||
|
v-for="option in sessionRetentionOptions"
|
||||||
|
:key="option.value"
|
||||||
|
type="button"
|
||||||
|
class="session-picker-option"
|
||||||
|
:class="{ active: pageState.sessionForm.conversationRetentionDays === option.value }"
|
||||||
|
@click="selectSessionRetentionDays(option.value)"
|
||||||
|
>
|
||||||
|
{{ option.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small>最小 1 天,最大 10 天,按会话最后活跃时间计算。</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeSection === 'hermes'">
|
||||||
|
<HermesEmployeeSettingsPanel
|
||||||
|
:hermes-form="pageState.hermesForm"
|
||||||
|
@toggle-master="toggleHermesMaster"
|
||||||
|
@toggle-flag="toggleHermesFlag"
|
||||||
|
@toggle-task="toggleHermesTask"
|
||||||
|
@update-task-time="updateHermesTaskTime"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeSection === 'llm'">
|
||||||
|
<LlmSettingsPanel :llm-form="pageState.llmForm" :provider-options="providerOptions" />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeSection === 'rendering'">
|
||||||
|
<section class="settings-card rendering-settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box slate">
|
||||||
|
<i class="mdi mdi-file-document-edit-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>ONLYOFFICE 服务配置</h4>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="switch-group">
|
||||||
|
<button class="switch-row" type="button" @click="toggleBoolean('renderForm', 'enabled')">
|
||||||
|
<span class="switch-copy">
|
||||||
|
<strong>启用 ONLYOFFICE 文件渲染</strong>
|
||||||
|
<small>启用后,知识库中的 Office 文件将优先走 ONLYOFFICE 在线预览。</small>
|
||||||
|
</span>
|
||||||
|
<span class="switch-btn" :class="{ active: pageState.renderForm.enabled }"><i></i></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-grid">
|
||||||
|
<label class="field field-full">
|
||||||
|
<span><em>*</em> ONLYOFFICE 服务地址</span>
|
||||||
|
<input
|
||||||
|
v-model="pageState.renderForm.publicUrl"
|
||||||
|
type="text"
|
||||||
|
placeholder="例如 http://10.10.10.122:8082"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="field field-full">
|
||||||
|
<span><em>*</em> JWT 密钥</span>
|
||||||
|
<input
|
||||||
|
v-model="pageState.renderForm.jwtSecret"
|
||||||
|
type="password"
|
||||||
|
autocomplete="off"
|
||||||
|
@focus="clearRenderSecretMask"
|
||||||
|
:placeholder="pageState.renderForm.jwtSecretConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
||||||
|
/>
|
||||||
|
<small v-if="pageState.renderForm.jwtSecretConfigured" class="secret-bound-state">
|
||||||
|
<i class="mdi mdi-database-lock"></i>
|
||||||
|
<span>已从数据库加密加载,预览签名会使用已保存密钥。</span>
|
||||||
|
</small>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template v-else-if="activeSection === 'logs'">
|
||||||
|
<section class="settings-card">
|
||||||
|
<div class="card-head">
|
||||||
|
<div class="card-title-with-icon">
|
||||||
|
<div class="model-icon-box slate">
|
||||||
|
<i class="mdi mdi-text-box-search-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>日志级别与留存</h4>
|
||||||
|
<p>定义系统记录粒度、归档周期和告警接收人,方便后续审计与排障。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -572,9 +388,14 @@
|
|||||||
|
|
||||||
<section class="settings-card">
|
<section class="settings-card">
|
||||||
<div class="card-head">
|
<div class="card-head">
|
||||||
<div>
|
<div class="card-title-with-icon">
|
||||||
<h4>审计策略</h4>
|
<div class="model-icon-box slate">
|
||||||
<p>决定是否记录关键操作、登录行为以及是否对敏感字段进行脱敏处理。</p>
|
<i class="mdi mdi-eye-check-outline"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h4>审计策略</h4>
|
||||||
|
<p>决定是否记录关键操作、登录行为以及是否对敏感字段进行脱敏处理。</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -584,7 +405,7 @@
|
|||||||
<strong>记录关键操作日志</strong>
|
<strong>记录关键操作日志</strong>
|
||||||
<small>保存配置修改、审批动作和账户管理等重要事件。</small>
|
<small>保存配置修改、审批动作和账户管理等重要事件。</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="switch" :class="{ active: pageState.logForm.operationAudit }"><i></i></span>
|
<span class="switch-btn" :class="{ active: pageState.logForm.operationAudit }"><i></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'loginAudit')">
|
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'loginAudit')">
|
||||||
@@ -592,7 +413,7 @@
|
|||||||
<strong>记录登录审计</strong>
|
<strong>记录登录审计</strong>
|
||||||
<small>追踪登录来源、登录结果和异常登录行为。</small>
|
<small>追踪登录来源、登录结果和异常登录行为。</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="switch" :class="{ active: pageState.logForm.loginAudit }"><i></i></span>
|
<span class="switch-btn" :class="{ active: pageState.logForm.loginAudit }"><i></i></span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'maskSensitive')">
|
<button class="switch-row" type="button" @click="toggleBoolean('logForm', 'maskSensitive')">
|
||||||
@@ -600,106 +421,14 @@
|
|||||||
<strong>敏感字段脱敏</strong>
|
<strong>敏感字段脱敏</strong>
|
||||||
<small>日志写入时自动隐藏密码、密钥与认证令牌。</small>
|
<small>日志写入时自动隐藏密码、密钥与认证令牌。</small>
|
||||||
</span>
|
</span>
|
||||||
<span class="switch" :class="{ active: pageState.logForm.maskSensitive }"><i></i></span>
|
<span class="switch-btn" :class="{ active: pageState.logForm.maskSensitive }"><i></i></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else-if="activeSection === 'mail'">
|
||||||
<section class="settings-card">
|
<MailSettingsPanel :mail-form="pageState.mailForm" />
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h4>SMTP 基础配置</h4>
|
|
||||||
<p>维护系统发信地址、认证账号和加密方式,用于审批提醒与系统通知。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid">
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> SMTP Host</span>
|
|
||||||
<input v-model="pageState.mailForm.smtpHost" type="text" placeholder="请输入 SMTP Host" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 端口</span>
|
|
||||||
<input v-model.number="pageState.mailForm.port" type="number" min="1" max="65535" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span>加密方式</span>
|
|
||||||
<select v-model="pageState.mailForm.encryption">
|
|
||||||
<option value="SSL/TLS">SSL/TLS</option>
|
|
||||||
<option value="STARTTLS">STARTTLS</option>
|
|
||||||
<option value="None">无</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span>发件人名称</span>
|
|
||||||
<input v-model="pageState.mailForm.senderName" type="text" placeholder="请输入发件人名称" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 发件人邮箱</span>
|
|
||||||
<input v-model="pageState.mailForm.senderAddress" type="email" placeholder="请输入发件人邮箱" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span><em>*</em> 登录账号</span>
|
|
||||||
<input v-model="pageState.mailForm.username" type="text" placeholder="请输入 SMTP 登录账号" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field field-full">
|
|
||||||
<span>SMTP 密码</span>
|
|
||||||
<input
|
|
||||||
v-model="pageState.mailForm.password"
|
|
||||||
type="password"
|
|
||||||
autocomplete="off"
|
|
||||||
:placeholder="pageState.mailForm.passwordConfigured ? '已配置,如需修改请重新输入' : '保存后不会保留在草稿中'"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="settings-card">
|
|
||||||
<div class="card-head">
|
|
||||||
<div>
|
|
||||||
<h4>通知策略</h4>
|
|
||||||
<p>控制是否启用邮件通知、日报摘要以及默认接收邮箱。</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="switch-group">
|
|
||||||
<button class="switch-row" type="button" @click="toggleBoolean('mailForm', 'alertEnabled')">
|
|
||||||
<span class="switch-copy">
|
|
||||||
<strong>启用系统通知</strong>
|
|
||||||
<small>审批、异常告警和系统事件可通过邮件触达用户。</small>
|
|
||||||
</span>
|
|
||||||
<span class="switch" :class="{ active: pageState.mailForm.alertEnabled }"><i></i></span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="switch-row" type="button" @click="toggleBoolean('mailForm', 'digestEnabled')">
|
|
||||||
<span class="switch-copy">
|
|
||||||
<strong>启用日报摘要</strong>
|
|
||||||
<small>按固定时间发送系统运行与待办摘要。</small>
|
|
||||||
</span>
|
|
||||||
<span class="switch" :class="{ active: pageState.mailForm.digestEnabled }"><i></i></span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="form-grid compact-grid">
|
|
||||||
<label class="field">
|
|
||||||
<span>摘要发送时间</span>
|
|
||||||
<input v-model="pageState.mailForm.digestTime" type="time" />
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="field">
|
|
||||||
<span>默认接收邮箱</span>
|
|
||||||
<input v-model="pageState.mailForm.defaultReceiver" type="email" placeholder="请输入默认接收邮箱" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -709,4 +438,5 @@
|
|||||||
|
|
||||||
<script src="./scripts/SettingsView.js"></script>
|
<script src="./scripts/SettingsView.js"></script>
|
||||||
|
|
||||||
|
<style scoped src="../assets/styles/views/settings-view-form.css"></style>
|
||||||
<style scoped src="../assets/styles/views/settings-view.css"></style>
|
<style scoped src="../assets/styles/views/settings-view.css"></style>
|
||||||
|
|||||||
59
web/src/views/scripts/HermesEmployeeSettingsPanel.js
Normal file
59
web/src/views/scripts/HermesEmployeeSettingsPanel.js
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { computed } from 'vue'
|
||||||
|
import { HERMES_SIMPLE_TASKS } from '../../utils/hermesEmployeeSettingsModel.js'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'HermesEmployeeSettingsPanel',
|
||||||
|
props: {
|
||||||
|
hermesForm: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
emits: ['toggle-master', 'toggle-flag', 'toggle-task', 'update-task-time'],
|
||||||
|
setup(props) {
|
||||||
|
const TASK_METADATA = {
|
||||||
|
knowledgeAggregation: { icon: 'mdi-sync', color: 'indigo' },
|
||||||
|
ruleReviewDigest: { icon: 'mdi-bell-ring-outline', color: 'warning' },
|
||||||
|
riskSummary: { icon: 'mdi-shield-search', color: 'danger' },
|
||||||
|
archiveDigest: { icon: 'mdi-archive-outline', color: 'info' },
|
||||||
|
dailyStats: { icon: 'mdi-chart-line', color: 'success' },
|
||||||
|
monthlyStats: { icon: 'mdi-chart-bar', color: 'primary' },
|
||||||
|
yearlyStats: { icon: 'mdi-chart-pie', color: 'secondary' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskIcon(taskId) {
|
||||||
|
return TASK_METADATA[taskId]?.icon || 'mdi-cog-outline'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTaskColorClass(taskId) {
|
||||||
|
return TASK_METADATA[taskId]?.color || 'default'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTaskOn(taskId) {
|
||||||
|
return Boolean(
|
||||||
|
props.hermesForm?.masterEnabled &&
|
||||||
|
props.hermesForm?.capabilities?.[taskId] &&
|
||||||
|
props.hermesForm?.schedules?.[taskId]?.enabled
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function taskTime(taskId) {
|
||||||
|
return props.hermesForm?.schedules?.[taskId]?.time || '09:00'
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTasksCount = computed(() => {
|
||||||
|
return HERMES_SIMPLE_TASKS.filter(task => isTaskOn(task.id)).length
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
HERMES_SIMPLE_TASKS,
|
||||||
|
isTaskOn,
|
||||||
|
taskTime,
|
||||||
|
getTaskIcon,
|
||||||
|
getTaskColorClass,
|
||||||
|
activeTasksCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
194
web/src/views/scripts/LlmSettingsPanel.js
Normal file
194
web/src/views/scripts/LlmSettingsPanel.js
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { testModelConnectivity } from '../../services/settings.js'
|
||||||
|
import { useToast } from '../../composables/useToast.js'
|
||||||
|
|
||||||
|
const MODEL_SECRET_MASK = '********'
|
||||||
|
|
||||||
|
const MODEL_TEST_CONFIGS = {
|
||||||
|
main: {
|
||||||
|
label: '主模型',
|
||||||
|
providerKey: 'mainProvider',
|
||||||
|
modelKey: 'mainModel',
|
||||||
|
endpointKey: 'mainEndpoint',
|
||||||
|
apiKeyKey: 'mainApiKey',
|
||||||
|
capability: 'chat'
|
||||||
|
},
|
||||||
|
backup: {
|
||||||
|
label: '备份模型',
|
||||||
|
providerKey: 'backupProvider',
|
||||||
|
modelKey: 'backupModel',
|
||||||
|
endpointKey: 'backupEndpoint',
|
||||||
|
apiKeyKey: 'backupApiKey',
|
||||||
|
capability: 'chat'
|
||||||
|
},
|
||||||
|
embedding: {
|
||||||
|
label: 'Embedding 模型',
|
||||||
|
providerKey: 'embeddingProvider',
|
||||||
|
modelKey: 'embeddingModel',
|
||||||
|
endpointKey: 'embeddingEndpoint',
|
||||||
|
apiKeyKey: 'embeddingApiKey',
|
||||||
|
capability: 'embedding'
|
||||||
|
},
|
||||||
|
reranker: {
|
||||||
|
label: 'Reranker 模型',
|
||||||
|
providerKey: 'rerankerProvider',
|
||||||
|
modelKey: 'rerankerModel',
|
||||||
|
endpointKey: 'rerankerEndpoint',
|
||||||
|
apiKeyKey: 'rerankerApiKey',
|
||||||
|
capability: 'reranker'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
|
||||||
|
|
||||||
|
const PROVIDER_ENDPOINTS = {
|
||||||
|
MiniMax: 'https://api.minimaxi.com/v1',
|
||||||
|
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
|
||||||
|
Kimi: 'https://api.moonshot.ai/v1',
|
||||||
|
Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||||
|
Codex: 'https://api.openai.com/v1',
|
||||||
|
Claude: 'https://api.anthropic.com/v1/',
|
||||||
|
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
||||||
|
[CUSTOM_OPENAI_PROVIDER]: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const RERANKER_PROVIDER_ENDPOINTS = {
|
||||||
|
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
|
||||||
|
[CUSTOM_OPENAI_PROVIDER]: ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEGACY_PROVIDER_MAP = {
|
||||||
|
'OpenAI Compatible': 'Codex',
|
||||||
|
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
|
||||||
|
Ollama: CUSTOM_OPENAI_PROVIDER,
|
||||||
|
'自定义网关': CUSTOM_OPENAI_PROVIDER
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValue(value) {
|
||||||
|
return String(value ?? '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeProviderValue(value, fallback = 'Codex') {
|
||||||
|
const normalized = normalizeValue(value)
|
||||||
|
|
||||||
|
const providerOptions = Object.keys(PROVIDER_ENDPOINTS)
|
||||||
|
if (providerOptions.includes(normalized)) {
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LEGACY_PROVIDER_MAP[normalized]) {
|
||||||
|
return LEGACY_PROVIDER_MAP[normalized]
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
function getProviderEndpoint(provider) {
|
||||||
|
return PROVIDER_ENDPOINTS[provider] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRerankerEndpoint(provider) {
|
||||||
|
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelConfigReady(provider, model, endpoint) {
|
||||||
|
return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint))
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelSecretMask(value) {
|
||||||
|
return value === MODEL_SECRET_MASK
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
name: 'LlmSettingsPanel',
|
||||||
|
props: {
|
||||||
|
llmForm: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
providerOptions: {
|
||||||
|
type: Array,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { toast } = useToast()
|
||||||
|
const modelTestState = ref({
|
||||||
|
main: { status: 'idle', message: '' },
|
||||||
|
backup: { status: 'idle', message: '' },
|
||||||
|
embedding: { status: 'idle', message: '' },
|
||||||
|
reranker: { status: 'idle', message: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyProviderPreset(testKey) {
|
||||||
|
const config = MODEL_TEST_CONFIGS[testKey]
|
||||||
|
const provider = normalizeProviderValue(props.llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER)
|
||||||
|
|
||||||
|
props.llmForm[config.providerKey] = provider
|
||||||
|
props.llmForm[config.endpointKey] =
|
||||||
|
testKey === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelTestState(testKey) {
|
||||||
|
return modelTestState.value[testKey] || { status: 'idle', message: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
function isModelTesting(testKey) {
|
||||||
|
return getModelTestState(testKey).status === 'testing'
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearModelSecretMask(testKey) {
|
||||||
|
const config = MODEL_TEST_CONFIGS[testKey]
|
||||||
|
if (isModelSecretMask(props.llmForm[config.apiKeyKey])) {
|
||||||
|
props.llmForm[config.apiKeyKey] = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testModelConnection(testKey) {
|
||||||
|
const config = MODEL_TEST_CONFIGS[testKey]
|
||||||
|
const provider = props.llmForm[config.providerKey]
|
||||||
|
const model = props.llmForm[config.modelKey]
|
||||||
|
const endpoint = props.llmForm[config.endpointKey]
|
||||||
|
const apiKey = props.llmForm[config.apiKeyKey]
|
||||||
|
|
||||||
|
if (!isModelConfigReady(provider, model, endpoint)) {
|
||||||
|
const message = `请先完整填写${config.label}的供应商、模型名称和接口地址。`
|
||||||
|
modelTestState.value[testKey] = { status: 'error', message }
|
||||||
|
toast(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
modelTestState.value[testKey] = { status: 'testing', message: '正在测试模型连通性...' }
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
provider,
|
||||||
|
model,
|
||||||
|
endpoint,
|
||||||
|
api_key: isModelSecretMask(apiKey) ? '' : apiKey,
|
||||||
|
capability: config.capability,
|
||||||
|
slot: testKey
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await testModelConnectivity(payload)
|
||||||
|
modelTestState.value[testKey] = {
|
||||||
|
status: result.ok ? 'success' : 'error',
|
||||||
|
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
|
||||||
|
}
|
||||||
|
toast(modelTestState.value[testKey].message)
|
||||||
|
} catch (error) {
|
||||||
|
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
|
||||||
|
modelTestState.value[testKey] = { status: 'error', message }
|
||||||
|
toast(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
applyProviderPreset,
|
||||||
|
getModelTestState,
|
||||||
|
isModelTesting,
|
||||||
|
clearModelSecretMask,
|
||||||
|
testModelConnection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
20
web/src/views/scripts/MailSettingsPanel.js
Normal file
20
web/src/views/scripts/MailSettingsPanel.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
export default {
|
||||||
|
name: 'MailSettingsPanel',
|
||||||
|
props: {
|
||||||
|
mailForm: {
|
||||||
|
type: Object,
|
||||||
|
required: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
function toggleField(field) {
|
||||||
|
if (props.mailForm) {
|
||||||
|
props.mailForm[field] = !props.mailForm[field]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
toggleField
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,914 +1,21 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
import HermesEmployeeSettingsPanel from '../HermesEmployeeSettingsPanel.vue'
|
||||||
|
import LlmSettingsPanel from '../LlmSettingsPanel.vue'
|
||||||
import { useSystemState } from '../../composables/useSystemState.js'
|
import MailSettingsPanel from '../MailSettingsPanel.vue'
|
||||||
import { fetchSettings, saveSettings, testModelConnectivity } from '../../services/settings.js'
|
import { useSettings } from '../../composables/useSettings.js'
|
||||||
import { useToast } from '../../composables/useToast.js'
|
|
||||||
|
export default {
|
||||||
const SETTINGS_STORAGE_KEY = 'x-financial-settings-draft'
|
name: 'SettingsView',
|
||||||
const CURRENT_YEAR = new Date().getFullYear()
|
components: {
|
||||||
const CUSTOM_OPENAI_PROVIDER = 'Custom OpenAI Compatible'
|
HermesEmployeeSettingsPanel,
|
||||||
const MODEL_SECRET_MASK = '********'
|
LlmSettingsPanel,
|
||||||
const RENDER_SECRET_MASK = '********'
|
MailSettingsPanel
|
||||||
|
|
||||||
const SECTION_DEFINITIONS = [
|
|
||||||
{
|
|
||||||
id: 'profile',
|
|
||||||
label: '企业信息',
|
|
||||||
title: '系统基本信息',
|
|
||||||
desc: '公司名称、品牌与版权',
|
|
||||||
longDesc: '统一维护企业名称、系统显示名称和版权信息,保存后会同步更新当前系统品牌展示。',
|
|
||||||
actionLabel: '保存企业信息'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'admin',
|
|
||||||
label: '管理员安全',
|
|
||||||
title: '管理员账号与安全策略',
|
|
||||||
desc: '账号、密码与登录安全',
|
|
||||||
longDesc: '集中管理管理员账号、邮箱和登录安全策略。密码仅在当前输入时可见,不会写入浏览器草稿。',
|
|
||||||
actionLabel: '保存安全设置'
|
|
||||||
},
|
},
|
||||||
{
|
setup() {
|
||||||
id: 'session',
|
const settings = useSettings()
|
||||||
label: '会话设置',
|
|
||||||
title: '会话留存设置',
|
|
||||||
desc: '会话保留天数',
|
|
||||||
longDesc: '统一配置智能体会话的保留天数。超过保留期的历史会话会在后端清理,避免上下文和管理成本无限增长。',
|
|
||||||
actionLabel: '保存会话设置'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'llm',
|
|
||||||
label: '大语言模型',
|
|
||||||
title: '模型接入配置',
|
|
||||||
desc: '主模型、备份模型与检索模型',
|
|
||||||
longDesc: '集中维护主模型、备份模型、Embedding 模型和 Reranker 模型的接入参数,供 AI 助手和检索链路调用。',
|
|
||||||
actionLabel: '保存模型配置'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rendering',
|
|
||||||
label: '文件渲染',
|
|
||||||
title: '文件渲染',
|
|
||||||
desc: '文档预览服务与访问密钥',
|
|
||||||
longDesc: '维护文件渲染开关、文档服务对外地址和 JWT 密钥,后端回调地址继续由部署配置管理。',
|
|
||||||
actionLabel: '保存文件渲染配置'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'logs',
|
|
||||||
label: '日志策略',
|
|
||||||
title: '日志与审计策略',
|
|
||||||
desc: '日志级别、留存与脱敏',
|
|
||||||
longDesc: '定义系统日志级别、留存周期和审计策略,保证问题排查和合规审计可追溯。',
|
|
||||||
actionLabel: '保存日志策略'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'mail',
|
|
||||||
label: '邮箱设置',
|
|
||||||
title: '邮箱通知配置',
|
|
||||||
desc: 'SMTP 与通知投递策略',
|
|
||||||
longDesc: '维护系统邮件发送配置和通知投递策略,审批、告警和摘要邮件都会依赖这里的设置。',
|
|
||||||
actionLabel: '保存邮箱配置'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
const LOG_LEVELS = ['DEBUG', 'INFO', 'WARN', 'ERROR']
|
|
||||||
|
|
||||||
const PROVIDER_OPTIONS = [
|
|
||||||
'MiniMax',
|
|
||||||
'GLM',
|
|
||||||
'Kimi',
|
|
||||||
'Ali',
|
|
||||||
'Codex',
|
|
||||||
'Claude',
|
|
||||||
'Gemini',
|
|
||||||
CUSTOM_OPENAI_PROVIDER
|
|
||||||
]
|
|
||||||
|
|
||||||
const PROVIDER_ENDPOINTS = {
|
|
||||||
MiniMax: 'https://api.minimaxi.com/v1',
|
|
||||||
GLM: 'https://open.bigmodel.cn/api/paas/v4/',
|
|
||||||
Kimi: 'https://api.moonshot.ai/v1',
|
|
||||||
Ali: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
|
||||||
Codex: 'https://api.openai.com/v1',
|
|
||||||
Claude: 'https://api.anthropic.com/v1/',
|
|
||||||
Gemini: 'https://generativelanguage.googleapis.com/v1beta/openai/',
|
|
||||||
[CUSTOM_OPENAI_PROVIDER]: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const RERANKER_PROVIDER_ENDPOINTS = {
|
|
||||||
Ali: 'https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank',
|
|
||||||
[CUSTOM_OPENAI_PROVIDER]: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEGACY_PROVIDER_MAP = {
|
|
||||||
'OpenAI Compatible': 'Codex',
|
|
||||||
'Azure OpenAI': CUSTOM_OPENAI_PROVIDER,
|
|
||||||
Ollama: CUSTOM_OPENAI_PROVIDER,
|
|
||||||
'自定义网关': CUSTOM_OPENAI_PROVIDER
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODEL_TEST_CONFIGS = {
|
|
||||||
main: {
|
|
||||||
label: '主模型',
|
|
||||||
providerKey: 'mainProvider',
|
|
||||||
modelKey: 'mainModel',
|
|
||||||
endpointKey: 'mainEndpoint',
|
|
||||||
apiKeyKey: 'mainApiKey',
|
|
||||||
capability: 'chat'
|
|
||||||
},
|
|
||||||
backup: {
|
|
||||||
label: '备份模型',
|
|
||||||
providerKey: 'backupProvider',
|
|
||||||
modelKey: 'backupModel',
|
|
||||||
endpointKey: 'backupEndpoint',
|
|
||||||
apiKeyKey: 'backupApiKey',
|
|
||||||
capability: 'chat'
|
|
||||||
},
|
|
||||||
embedding: {
|
|
||||||
label: 'Embedding 模型',
|
|
||||||
providerKey: 'embeddingProvider',
|
|
||||||
modelKey: 'embeddingModel',
|
|
||||||
endpointKey: 'embeddingEndpoint',
|
|
||||||
apiKeyKey: 'embeddingApiKey',
|
|
||||||
capability: 'embedding'
|
|
||||||
},
|
|
||||||
reranker: {
|
|
||||||
label: 'Reranker 模型',
|
|
||||||
providerKey: 'rerankerProvider',
|
|
||||||
modelKey: 'rerankerModel',
|
|
||||||
endpointKey: 'rerankerEndpoint',
|
|
||||||
apiKeyKey: 'rerankerApiKey',
|
|
||||||
capability: 'reranker'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MODEL_API_KEY_CONFIGS = Object.values(MODEL_TEST_CONFIGS)
|
|
||||||
const SESSION_RETENTION_OPTIONS = Array.from({ length: 10 }, (_item, index) => ({
|
|
||||||
value: index + 1,
|
|
||||||
label: `${index + 1} 天`
|
|
||||||
}))
|
|
||||||
|
|
||||||
function normalizeValue(value) {
|
|
||||||
return String(value ?? '').trim()
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeProviderValue(value, fallback = 'Codex') {
|
|
||||||
const normalized = normalizeValue(value)
|
|
||||||
|
|
||||||
if (PROVIDER_OPTIONS.includes(normalized)) {
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
if (LEGACY_PROVIDER_MAP[normalized]) {
|
|
||||||
return LEGACY_PROVIDER_MAP[normalized]
|
|
||||||
}
|
|
||||||
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
|
|
||||||
function getProviderEndpoint(provider) {
|
|
||||||
return PROVIDER_ENDPOINTS[provider] ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRerankerEndpoint(provider) {
|
|
||||||
return RERANKER_PROVIDER_ENDPOINTS[provider] ?? getProviderEndpoint(provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildDefaultState(companyProfile, currentUser) {
|
|
||||||
const companyName = normalizeValue(companyProfile?.name) || 'X-Financial'
|
|
||||||
const companyCode = normalizeValue(companyProfile?.code) || 'XF-001'
|
|
||||||
const adminEmail =
|
|
||||||
normalizeValue(companyProfile?.adminEmail) ||
|
|
||||||
normalizeValue(currentUser?.email) ||
|
|
||||||
'admin@example.com'
|
|
||||||
const adminAccount = normalizeValue(currentUser?.username) || 'superadmin'
|
|
||||||
|
|
||||||
return {
|
|
||||||
companyForm: {
|
|
||||||
companyName,
|
|
||||||
displayName: companyName,
|
|
||||||
companyCode,
|
|
||||||
recordNumber: '',
|
|
||||||
copyright: `Copyright © 2024-${CURRENT_YEAR} ${companyName}. All Rights Reserved.`
|
|
||||||
},
|
|
||||||
adminForm: {
|
|
||||||
adminAccount,
|
|
||||||
adminEmail,
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: '',
|
|
||||||
sessionTimeout: Number(import.meta.env.VITE_AUTH_IDLE_TIMEOUT_MINUTES || 30),
|
|
||||||
noticeEmail: adminEmail,
|
|
||||||
mfaEnabled: true,
|
|
||||||
strongPassword: true,
|
|
||||||
loginAlertEnabled: true,
|
|
||||||
adminPasswordConfigured: false
|
|
||||||
},
|
|
||||||
sessionForm: {
|
|
||||||
conversationRetentionDays: 3
|
|
||||||
},
|
|
||||||
llmForm: {
|
|
||||||
mainProvider: 'Codex',
|
|
||||||
mainModel: 'codex-mini-latest',
|
|
||||||
mainEndpoint: getProviderEndpoint('Codex'),
|
|
||||||
mainApiKey: '',
|
|
||||||
mainApiKeyConfigured: false,
|
|
||||||
backupProvider: 'GLM',
|
|
||||||
backupModel: 'glm-5.1',
|
|
||||||
backupEndpoint: getProviderEndpoint('GLM'),
|
|
||||||
backupApiKey: '',
|
|
||||||
backupApiKeyConfigured: false,
|
|
||||||
embeddingProvider: 'GLM',
|
|
||||||
embeddingModel: 'Embedding-3',
|
|
||||||
embeddingEndpoint: getProviderEndpoint('GLM'),
|
|
||||||
embeddingApiKey: '',
|
|
||||||
embeddingApiKeyConfigured: false,
|
|
||||||
rerankerProvider: 'Ali',
|
|
||||||
rerankerModel: 'gte-rerank-v2',
|
|
||||||
rerankerEndpoint: getRerankerEndpoint('Ali'),
|
|
||||||
rerankerApiKey: '',
|
|
||||||
rerankerApiKeyConfigured: false
|
|
||||||
},
|
|
||||||
renderForm: {
|
|
||||||
enabled: false,
|
|
||||||
publicUrl: '',
|
|
||||||
jwtSecret: '',
|
|
||||||
jwtSecretConfigured: false
|
|
||||||
},
|
|
||||||
logForm: {
|
|
||||||
level: 'INFO',
|
|
||||||
retentionDays: 180,
|
|
||||||
archiveCycle: 'weekly',
|
|
||||||
logPath: 'server/logs/app.log',
|
|
||||||
alertEmail: adminEmail,
|
|
||||||
operationAudit: true,
|
|
||||||
loginAudit: true,
|
|
||||||
maskSensitive: true
|
|
||||||
},
|
|
||||||
mailForm: {
|
|
||||||
smtpHost: 'smtp.exmail.qq.com',
|
|
||||||
port: 465,
|
|
||||||
encryption: 'SSL/TLS',
|
|
||||||
senderName: companyName,
|
|
||||||
senderAddress: adminEmail,
|
|
||||||
username: adminEmail,
|
|
||||||
password: '',
|
|
||||||
passwordConfigured: false,
|
|
||||||
alertEnabled: true,
|
|
||||||
digestEnabled: false,
|
|
||||||
digestTime: '09:00',
|
|
||||||
defaultReceiver: adminEmail
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function readStoredSettings() {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const raw = window.sessionStorage.getItem(SETTINGS_STORAGE_KEY)
|
|
||||||
if (!raw) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(raw)
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeState(baseState, overrideState) {
|
|
||||||
const mergedLlmForm = { ...baseState.llmForm, ...(overrideState?.llmForm || {}) }
|
|
||||||
|
|
||||||
mergedLlmForm.mainProvider = normalizeProviderValue(mergedLlmForm.mainProvider, baseState.llmForm.mainProvider)
|
|
||||||
mergedLlmForm.backupProvider = normalizeProviderValue(mergedLlmForm.backupProvider, baseState.llmForm.backupProvider)
|
|
||||||
mergedLlmForm.embeddingProvider = normalizeProviderValue(
|
|
||||||
mergedLlmForm.embeddingProvider,
|
|
||||||
baseState.llmForm.embeddingProvider
|
|
||||||
)
|
|
||||||
mergedLlmForm.rerankerProvider = normalizeProviderValue(
|
|
||||||
mergedLlmForm.rerankerProvider,
|
|
||||||
baseState.llmForm.rerankerProvider
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
companyForm: { ...baseState.companyForm, ...(overrideState?.companyForm || {}) },
|
|
||||||
adminForm: { ...baseState.adminForm, ...(overrideState?.adminForm || {}) },
|
|
||||||
sessionForm: { ...baseState.sessionForm, ...(overrideState?.sessionForm || {}) },
|
|
||||||
llmForm: mergedLlmForm,
|
|
||||||
renderForm: { ...baseState.renderForm, ...(overrideState?.renderForm || {}) },
|
|
||||||
logForm: { ...baseState.logForm, ...(overrideState?.logForm || {}) },
|
|
||||||
mailForm: { ...baseState.mailForm, ...(overrideState?.mailForm || {}) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function sanitizeForStorage(state) {
|
|
||||||
return {
|
|
||||||
companyForm: { ...state.companyForm },
|
|
||||||
adminForm: {
|
|
||||||
...state.adminForm,
|
|
||||||
newPassword: '',
|
|
||||||
confirmPassword: ''
|
|
||||||
},
|
|
||||||
sessionForm: { ...state.sessionForm },
|
|
||||||
llmForm: {
|
|
||||||
...state.llmForm,
|
|
||||||
mainApiKey: '',
|
|
||||||
backupApiKey: '',
|
|
||||||
embeddingApiKey: '',
|
|
||||||
rerankerApiKey: ''
|
|
||||||
},
|
|
||||||
renderForm: {
|
|
||||||
...state.renderForm,
|
|
||||||
jwtSecret: ''
|
|
||||||
},
|
|
||||||
logForm: { ...state.logForm },
|
|
||||||
mailForm: {
|
|
||||||
...state.mailForm,
|
|
||||||
password: ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModelConfiguredKey(apiKeyKey) {
|
|
||||||
return `${apiKeyKey}Configured`
|
|
||||||
}
|
|
||||||
|
|
||||||
function isModelSecretMask(value) {
|
|
||||||
return value === MODEL_SECRET_MASK
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskConfiguredModelSecrets(state) {
|
|
||||||
for (const config of MODEL_API_KEY_CONFIGS) {
|
|
||||||
const configuredKey = getModelConfiguredKey(config.apiKeyKey)
|
|
||||||
|
|
||||||
if (state.llmForm[configuredKey] && !normalizeValue(state.llmForm[config.apiKeyKey])) {
|
|
||||||
state.llmForm[config.apiKeyKey] = MODEL_SECRET_MASK
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildLlmPayload(llmForm) {
|
|
||||||
const payload = { ...llmForm }
|
|
||||||
|
|
||||||
for (const config of MODEL_API_KEY_CONFIGS) {
|
|
||||||
if (isModelSecretMask(payload[config.apiKeyKey])) {
|
|
||||||
payload[config.apiKeyKey] = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function isRenderSecretMask(value) {
|
|
||||||
return value === RENDER_SECRET_MASK
|
|
||||||
}
|
|
||||||
|
|
||||||
function maskConfiguredRenderSecret(state) {
|
|
||||||
if (state.renderForm.jwtSecretConfigured && !normalizeValue(state.renderForm.jwtSecret)) {
|
|
||||||
state.renderForm.jwtSecret = RENDER_SECRET_MASK
|
|
||||||
}
|
|
||||||
|
|
||||||
return state
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRenderPayload(renderForm) {
|
|
||||||
const payload = { ...renderForm }
|
|
||||||
|
|
||||||
if (isRenderSecretMask(payload.jwtSecret)) {
|
|
||||||
payload.jwtSecret = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
return payload
|
|
||||||
}
|
|
||||||
|
|
||||||
function persistSettings(state) {
|
|
||||||
if (typeof window === 'undefined') {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
window.sessionStorage.setItem(SETTINGS_STORAGE_KEY, JSON.stringify(sanitizeForStorage(state)))
|
|
||||||
}
|
|
||||||
|
|
||||||
function isModelConfigReady(provider, model, endpoint) {
|
|
||||||
return Boolean(normalizeValue(provider) && normalizeValue(model) && normalizeValue(endpoint))
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeSectionStatus(state) {
|
|
||||||
return {
|
|
||||||
profile: Boolean(
|
|
||||||
normalizeValue(state.companyForm.companyName) &&
|
|
||||||
normalizeValue(state.companyForm.displayName) &&
|
|
||||||
normalizeValue(state.companyForm.copyright)
|
|
||||||
),
|
|
||||||
admin: Boolean(
|
|
||||||
normalizeValue(state.adminForm.adminAccount) &&
|
|
||||||
normalizeValue(state.adminForm.adminEmail) &&
|
|
||||||
Number(state.adminForm.sessionTimeout) >= 5
|
|
||||||
),
|
|
||||||
session: Boolean(
|
|
||||||
Number(state.sessionForm.conversationRetentionDays) >= 1 &&
|
|
||||||
Number(state.sessionForm.conversationRetentionDays) <= 10
|
|
||||||
),
|
|
||||||
llm: Boolean(
|
|
||||||
isModelConfigReady(state.llmForm.mainProvider, state.llmForm.mainModel, state.llmForm.mainEndpoint) &&
|
|
||||||
isModelConfigReady(state.llmForm.backupProvider, state.llmForm.backupModel, state.llmForm.backupEndpoint) &&
|
|
||||||
isModelConfigReady(
|
|
||||||
state.llmForm.embeddingProvider,
|
|
||||||
state.llmForm.embeddingModel,
|
|
||||||
state.llmForm.embeddingEndpoint
|
|
||||||
) &&
|
|
||||||
isModelConfigReady(
|
|
||||||
state.llmForm.rerankerProvider,
|
|
||||||
state.llmForm.rerankerModel,
|
|
||||||
state.llmForm.rerankerEndpoint
|
|
||||||
)
|
|
||||||
),
|
|
||||||
rendering: Boolean(
|
|
||||||
!state.renderForm.enabled ||
|
|
||||||
(normalizeValue(state.renderForm.publicUrl) &&
|
|
||||||
(normalizeValue(state.renderForm.jwtSecret) || state.renderForm.jwtSecretConfigured))
|
|
||||||
),
|
|
||||||
logs: Boolean(
|
|
||||||
normalizeValue(state.logForm.level) &&
|
|
||||||
Number(state.logForm.retentionDays) > 0 &&
|
|
||||||
normalizeValue(state.logForm.logPath)
|
|
||||||
),
|
|
||||||
mail: Boolean(
|
|
||||||
normalizeValue(state.mailForm.smtpHost) &&
|
|
||||||
Number(state.mailForm.port) > 0 &&
|
|
||||||
normalizeValue(state.mailForm.senderAddress) &&
|
|
||||||
normalizeValue(state.mailForm.username)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'SettingsView',
|
|
||||||
setup() {
|
|
||||||
const { toast } = useToast()
|
|
||||||
const { companyProfile, currentUser, updateCompanyProfilePreview } = useSystemState()
|
|
||||||
|
|
||||||
const buildResolvedDefaults = () => buildDefaultState(companyProfile.value, currentUser.value)
|
|
||||||
const pageState = ref(mergeState(buildResolvedDefaults(), readStoredSettings()))
|
|
||||||
const activeSection = ref('profile')
|
|
||||||
const sessionRetentionPickerOpen = ref(false)
|
|
||||||
const sessionRetentionPickerRef = ref(null)
|
|
||||||
const modelTestState = ref({
|
|
||||||
main: { status: 'idle', message: '' },
|
|
||||||
backup: { status: 'idle', message: '' },
|
|
||||||
embedding: { status: 'idle', message: '' },
|
|
||||||
reranker: { status: 'idle', message: '' }
|
|
||||||
})
|
|
||||||
|
|
||||||
const sections = SECTION_DEFINITIONS
|
|
||||||
const logLevels = LOG_LEVELS
|
|
||||||
const providerOptions = PROVIDER_OPTIONS
|
|
||||||
const sessionRetentionOptions = SESSION_RETENTION_OPTIONS
|
|
||||||
|
|
||||||
const sectionStatus = computed(() => computeSectionStatus(pageState.value))
|
|
||||||
const completedSectionCount = computed(() => Object.values(sectionStatus.value).filter(Boolean).length)
|
|
||||||
const activeSectionConfig = computed(
|
|
||||||
() => sections.find((section) => section.id === activeSection.value) || sections[0]
|
|
||||||
)
|
|
||||||
|
|
||||||
function updateBrandPreviewFromState(state) {
|
|
||||||
updateCompanyProfilePreview({
|
|
||||||
name: normalizeValue(state.companyForm.displayName),
|
|
||||||
code: normalizeValue(state.companyForm.companyCode),
|
|
||||||
adminEmail: normalizeValue(state.adminForm.adminEmail)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyLoadedSnapshot(snapshot, options = {}) {
|
|
||||||
const {
|
|
||||||
mergeDraft = false,
|
|
||||||
preserveModelApiKeys = false,
|
|
||||||
preserveAdminPasswords = false,
|
|
||||||
preserveRenderSecret = false,
|
|
||||||
preserveMailPassword = false
|
|
||||||
} = options
|
|
||||||
|
|
||||||
const currentState = pageState.value
|
|
||||||
let nextState = mergeState(buildResolvedDefaults(), snapshot)
|
|
||||||
|
|
||||||
if (mergeDraft) {
|
|
||||||
nextState = mergeState(nextState, readStoredSettings())
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preserveModelApiKeys) {
|
|
||||||
nextState.llmForm.mainApiKey = currentState.llmForm.mainApiKey
|
|
||||||
nextState.llmForm.backupApiKey = currentState.llmForm.backupApiKey
|
|
||||||
nextState.llmForm.embeddingApiKey = currentState.llmForm.embeddingApiKey
|
|
||||||
nextState.llmForm.rerankerApiKey = currentState.llmForm.rerankerApiKey
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preserveAdminPasswords) {
|
|
||||||
nextState.adminForm.newPassword = currentState.adminForm.newPassword
|
|
||||||
nextState.adminForm.confirmPassword = currentState.adminForm.confirmPassword
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preserveRenderSecret) {
|
|
||||||
nextState.renderForm.jwtSecret = currentState.renderForm.jwtSecret
|
|
||||||
}
|
|
||||||
|
|
||||||
if (preserveMailPassword) {
|
|
||||||
nextState.mailForm.password = currentState.mailForm.password
|
|
||||||
}
|
|
||||||
|
|
||||||
pageState.value = maskConfiguredRenderSecret(maskConfiguredModelSecrets(nextState))
|
|
||||||
persistSettings(pageState.value)
|
|
||||||
updateBrandPreviewFromState(pageState.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadSettingsSnapshot() {
|
|
||||||
try {
|
|
||||||
const snapshot = await fetchSettings()
|
|
||||||
applyLoadedSnapshot(snapshot, { mergeDraft: true })
|
|
||||||
} catch (error) {
|
|
||||||
persistSettings(pageState.value)
|
|
||||||
updateBrandPreviewFromState(pageState.value)
|
|
||||||
toast(error.message || '无法加载已保存设置,继续使用当前会话草稿。')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildSettingsPayload() {
|
|
||||||
return {
|
|
||||||
companyForm: { ...pageState.value.companyForm },
|
|
||||||
adminForm: { ...pageState.value.adminForm },
|
|
||||||
sessionForm: { ...pageState.value.sessionForm },
|
|
||||||
llmForm: buildLlmPayload(pageState.value.llmForm),
|
|
||||||
renderForm: buildRenderPayload(pageState.value.renderForm),
|
|
||||||
logForm: { ...pageState.value.logForm },
|
|
||||||
mailForm: { ...pageState.value.mailForm }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function persistRemoteSettings(successMessage, options = {}) {
|
|
||||||
try {
|
|
||||||
const snapshot = await saveSettings(buildSettingsPayload())
|
|
||||||
applyLoadedSnapshot(snapshot, options)
|
|
||||||
toast(successMessage)
|
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
toast(error.message || '设置保存失败,请稍后重试。')
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function activateSection(sectionId) {
|
|
||||||
sessionRetentionPickerOpen.value = false
|
|
||||||
activeSection.value = sectionId
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleBoolean(formKey, field) {
|
|
||||||
pageState.value[formKey][field] = !pageState.value[formKey][field]
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSessionRetentionPicker() {
|
|
||||||
sessionRetentionPickerOpen.value = !sessionRetentionPickerOpen.value
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeSessionRetentionPicker() {
|
|
||||||
sessionRetentionPickerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectSessionRetentionDays(value) {
|
|
||||||
pageState.value.sessionForm.conversationRetentionDays = Number(value)
|
|
||||||
closeSessionRetentionPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleDocumentPointerDown(event) {
|
|
||||||
if (!sessionRetentionPickerOpen.value) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const target = event.target
|
|
||||||
if (sessionRetentionPickerRef.value?.contains(target)) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
closeSessionRetentionPicker()
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyProviderPreset(testKey) {
|
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
|
||||||
const llmForm = pageState.value.llmForm
|
|
||||||
const provider = normalizeProviderValue(llmForm[config.providerKey], CUSTOM_OPENAI_PROVIDER)
|
|
||||||
|
|
||||||
llmForm[config.providerKey] = provider
|
|
||||||
llmForm[config.endpointKey] =
|
|
||||||
slot === 'reranker' ? getRerankerEndpoint(provider) : getProviderEndpoint(provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getModelTestState(testKey) {
|
|
||||||
return modelTestState.value[testKey] || { status: 'idle', message: '' }
|
|
||||||
}
|
|
||||||
|
|
||||||
function isModelTesting(testKey) {
|
|
||||||
return getModelTestState(testKey).status === 'testing'
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildModelTestPayload(testKey) {
|
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
|
||||||
const llmForm = pageState.value.llmForm
|
|
||||||
const apiKey = llmForm[config.apiKeyKey]
|
|
||||||
|
|
||||||
return {
|
|
||||||
provider: llmForm[config.providerKey],
|
|
||||||
model: llmForm[config.modelKey],
|
|
||||||
endpoint: llmForm[config.endpointKey],
|
|
||||||
api_key: isModelSecretMask(apiKey) ? '' : apiKey,
|
|
||||||
capability: config.capability,
|
|
||||||
slot: testKey
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearModelSecretMask(testKey) {
|
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
|
||||||
|
|
||||||
if (isModelSecretMask(pageState.value.llmForm[config.apiKeyKey])) {
|
|
||||||
pageState.value.llmForm[config.apiKeyKey] = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearRenderSecretMask() {
|
|
||||||
if (isRenderSecretMask(pageState.value.renderForm.jwtSecret)) {
|
|
||||||
pageState.value.renderForm.jwtSecret = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function testModelConnection(testKey) {
|
|
||||||
const config = MODEL_TEST_CONFIGS[testKey]
|
|
||||||
const payload = buildModelTestPayload(testKey)
|
|
||||||
|
|
||||||
if (!isModelConfigReady(payload.provider, payload.model, payload.endpoint)) {
|
|
||||||
const message = `请先完整填写${config.label}的供应商、模型名称和接口地址。`
|
|
||||||
modelTestState.value[testKey] = { status: 'error', message }
|
|
||||||
toast(message)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
modelTestState.value[testKey] = { status: 'testing', message: '正在测试模型连通性...' }
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await testModelConnectivity(payload)
|
|
||||||
|
|
||||||
modelTestState.value[testKey] = {
|
|
||||||
status: result.ok ? 'success' : 'error',
|
|
||||||
message: result.detail || (result.ok ? '模型连接成功。' : '模型连接失败。')
|
|
||||||
}
|
|
||||||
|
|
||||||
toast(modelTestState.value[testKey].message)
|
|
||||||
} catch (error) {
|
|
||||||
const message = error.message || '模型测试请求失败,请确认 FastAPI 已启动。'
|
|
||||||
modelTestState.value[testKey] = { status: 'error', message }
|
|
||||||
toast(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveProfileSection() {
|
|
||||||
const companyForm = pageState.value.companyForm
|
|
||||||
|
|
||||||
if (!normalizeValue(companyForm.companyName)) {
|
|
||||||
toast('请输入企业名称。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizeValue(companyForm.displayName)) {
|
|
||||||
toast('请输入系统显示名称。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizeValue(companyForm.copyright)) {
|
|
||||||
toast('请输入版权信息。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pageState.value.mailForm.senderName = normalizeValue(companyForm.displayName)
|
|
||||||
await persistRemoteSettings('企业信息已保存并应用到当前系统。', {
|
|
||||||
preserveModelApiKeys: true,
|
|
||||||
preserveAdminPasswords: true,
|
|
||||||
preserveRenderSecret: true,
|
|
||||||
preserveMailPassword: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveAdminSection() {
|
|
||||||
const adminForm = pageState.value.adminForm
|
|
||||||
|
|
||||||
if (!normalizeValue(adminForm.adminAccount)) {
|
|
||||||
toast('请输入管理员账号。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizeValue(adminForm.adminEmail)) {
|
|
||||||
toast('请输入管理员邮箱。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Number(adminForm.sessionTimeout) < 5) {
|
|
||||||
toast('会话超时时间不能少于 5 分钟。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminForm.newPassword) {
|
|
||||||
if (adminForm.newPassword.length < 5) {
|
|
||||||
toast('管理员密码至少需要 5 位。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (adminForm.newPassword !== adminForm.confirmPassword) {
|
|
||||||
toast('两次输入的管理员密码不一致。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistRemoteSettings('管理员安全设置已保存。', {
|
|
||||||
preserveModelApiKeys: true,
|
|
||||||
preserveAdminPasswords: false,
|
|
||||||
preserveRenderSecret: true,
|
|
||||||
preserveMailPassword: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveSessionSection() {
|
|
||||||
const sessionForm = pageState.value.sessionForm
|
|
||||||
const retentionDays = Number(sessionForm.conversationRetentionDays)
|
|
||||||
|
|
||||||
if (retentionDays < 1 || retentionDays > 10) {
|
|
||||||
toast('会话保留天数必须在 1 到 10 天之间。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistRemoteSettings('会话设置已保存。', {
|
|
||||||
preserveModelApiKeys: true,
|
|
||||||
preserveAdminPasswords: true,
|
|
||||||
preserveRenderSecret: true,
|
|
||||||
preserveMailPassword: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveLlmSection() {
|
|
||||||
const llmForm = pageState.value.llmForm
|
|
||||||
const modelConfigs = [
|
|
||||||
['主模型', llmForm.mainProvider, llmForm.mainModel, llmForm.mainEndpoint],
|
|
||||||
['备份模型', llmForm.backupProvider, llmForm.backupModel, llmForm.backupEndpoint],
|
|
||||||
['Embedding 模型', llmForm.embeddingProvider, llmForm.embeddingModel, llmForm.embeddingEndpoint],
|
|
||||||
['Reranker 模型', llmForm.rerankerProvider, llmForm.rerankerModel, llmForm.rerankerEndpoint]
|
|
||||||
]
|
|
||||||
|
|
||||||
for (const [label, provider, model, endpoint] of modelConfigs) {
|
|
||||||
if (!isModelConfigReady(provider, model, endpoint)) {
|
|
||||||
toast(`请完整填写${label}的供应商、模型名称和接口地址。`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistRemoteSettings('模型配置已保存。', {
|
|
||||||
preserveModelApiKeys: true,
|
|
||||||
preserveAdminPasswords: true,
|
|
||||||
preserveRenderSecret: true,
|
|
||||||
preserveMailPassword: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveRenderingSection() {
|
|
||||||
const renderForm = pageState.value.renderForm
|
|
||||||
|
|
||||||
if (renderForm.enabled && !normalizeValue(renderForm.publicUrl)) {
|
|
||||||
toast('启用 ONLYOFFICE 时请输入服务地址。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (renderForm.enabled && !normalizeValue(renderForm.jwtSecret) && !renderForm.jwtSecretConfigured) {
|
|
||||||
toast('启用 ONLYOFFICE 时请输入 JWT 密钥。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistRemoteSettings('文件渲染配置已保存。', {
|
|
||||||
preserveModelApiKeys: true,
|
|
||||||
preserveAdminPasswords: true,
|
|
||||||
preserveRenderSecret: false,
|
|
||||||
preserveMailPassword: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveLogsSection() {
|
|
||||||
const logForm = pageState.value.logForm
|
|
||||||
|
|
||||||
if (!normalizeValue(logForm.level) || Number(logForm.retentionDays) <= 0) {
|
|
||||||
toast('请填写有效的日志级别和留存天数。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizeValue(logForm.logPath)) {
|
|
||||||
toast('请输入日志路径。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistRemoteSettings('日志策略已保存。', {
|
|
||||||
preserveModelApiKeys: true,
|
|
||||||
preserveAdminPasswords: true,
|
|
||||||
preserveRenderSecret: true,
|
|
||||||
preserveMailPassword: true
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveMailSection() {
|
|
||||||
const mailForm = pageState.value.mailForm
|
|
||||||
|
|
||||||
if (!normalizeValue(mailForm.smtpHost) || Number(mailForm.port) <= 0) {
|
|
||||||
toast('请填写有效的 SMTP Host 和端口。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!normalizeValue(mailForm.senderAddress) || !normalizeValue(mailForm.username)) {
|
|
||||||
toast('请填写发件人邮箱和 SMTP 登录账号。')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await persistRemoteSettings('邮箱配置已保存。', {
|
|
||||||
preserveModelApiKeys: true,
|
|
||||||
preserveAdminPasswords: true,
|
|
||||||
preserveRenderSecret: true,
|
|
||||||
preserveMailPassword: false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveActiveSection() {
|
|
||||||
if (activeSection.value === 'profile') {
|
|
||||||
await saveProfileSection()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSection.value === 'admin') {
|
|
||||||
await saveAdminSection()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSection.value === 'session') {
|
|
||||||
await saveSessionSection()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSection.value === 'llm') {
|
|
||||||
await saveLlmSection()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSection.value === 'logs') {
|
|
||||||
await saveLogsSection()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (activeSection.value === 'rendering') {
|
|
||||||
await saveRenderingSection()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
await saveMailSection()
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(() => {
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.addEventListener('pointerdown', handleDocumentPointerDown)
|
|
||||||
}
|
|
||||||
loadSettingsSnapshot()
|
|
||||||
})
|
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
if (typeof document !== 'undefined') {
|
|
||||||
document.removeEventListener('pointerdown', handleDocumentPointerDown)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeSection,
|
...settings
|
||||||
activeSectionConfig,
|
|
||||||
activateSection,
|
|
||||||
applyProviderPreset,
|
|
||||||
clearRenderSecretMask,
|
|
||||||
clearModelSecretMask,
|
|
||||||
completedSectionCount,
|
|
||||||
getModelTestState,
|
|
||||||
isModelTesting,
|
|
||||||
logLevels,
|
|
||||||
modelTestState,
|
|
||||||
pageState,
|
|
||||||
providerOptions,
|
|
||||||
sessionRetentionOptions,
|
|
||||||
sessionRetentionPickerOpen,
|
|
||||||
sessionRetentionPickerRef,
|
|
||||||
saveActiveSection,
|
|
||||||
sectionStatus,
|
|
||||||
sections,
|
|
||||||
selectSessionRetentionDays,
|
|
||||||
testModelConnection,
|
|
||||||
toggleSessionRetentionPicker,
|
|
||||||
closeSessionRetentionPicker,
|
|
||||||
toggleBoolean
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -93,8 +93,11 @@ import {
|
|||||||
buildReviewStateLabel,
|
buildReviewStateLabel,
|
||||||
buildReviewStateTone,
|
buildReviewStateTone,
|
||||||
buildReviewPlainFollowupCopy,
|
buildReviewPlainFollowupCopy,
|
||||||
|
buildReviewNextStepRichCopy,
|
||||||
|
buildReviewRiskLevelCounts,
|
||||||
resolveReviewFooterActions,
|
resolveReviewFooterActions,
|
||||||
resolveReviewSaveDraftAction,
|
resolveReviewSaveDraftAction,
|
||||||
|
resolveReviewNextStepAction,
|
||||||
buildReviewPrimaryButtonLabel,
|
buildReviewPrimaryButtonLabel,
|
||||||
buildReviewIntentText,
|
buildReviewIntentText,
|
||||||
buildReviewSceneValue,
|
buildReviewSceneValue,
|
||||||
@@ -129,6 +132,7 @@ import {
|
|||||||
buildOcrSummaryFromDocuments,
|
buildOcrSummaryFromDocuments,
|
||||||
buildReviewFilePreviewsFromReviewPayload,
|
buildReviewFilePreviewsFromReviewPayload,
|
||||||
extractReviewAttachmentNames,
|
extractReviewAttachmentNames,
|
||||||
|
isTemporaryPreviewUrl,
|
||||||
mergeFilePreviews,
|
mergeFilePreviews,
|
||||||
mergeFilesWithLimit,
|
mergeFilesWithLimit,
|
||||||
mergeUploadAttachmentNames,
|
mergeUploadAttachmentNames,
|
||||||
@@ -169,6 +173,11 @@ const REVIEW_RISK_LEVEL_META = {
|
|||||||
icon: 'mdi mdi-alert-circle-outline',
|
icon: 'mdi mdi-alert-circle-outline',
|
||||||
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
suggestion: '提交前建议核对业务真实性、附件完整性和规则口径。'
|
||||||
},
|
},
|
||||||
|
info: {
|
||||||
|
label: '提示',
|
||||||
|
icon: 'mdi mdi-information-outline',
|
||||||
|
suggestion: '该项主要用于辅助判断,可结合当前单据情况简单核对。'
|
||||||
|
},
|
||||||
low: {
|
low: {
|
||||||
label: '低风险',
|
label: '低风险',
|
||||||
icon: 'mdi mdi-information-outline',
|
icon: 'mdi mdi-information-outline',
|
||||||
@@ -184,6 +193,9 @@ const REVIEW_DRAWER_MODE_FLOW = 'flow'
|
|||||||
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
|
const REVIEW_PANEL_SCOPE_OVERVIEW = 'overview'
|
||||||
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
const REVIEW_PANEL_SCOPE_DOCUMENTS = 'documents'
|
||||||
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
const REVIEW_PANEL_SCOPE_RISK = 'risk'
|
||||||
|
const REVIEW_NEXT_STEP_HREF = '#review-next-step'
|
||||||
|
const REVIEW_RISK_PANEL_HREF_PREFIX = '#review-risk'
|
||||||
|
const REVIEW_QUICK_EDIT_HREF = '#review-quick-edit'
|
||||||
const FLOW_STEP_STATUS_PENDING = 'pending'
|
const FLOW_STEP_STATUS_PENDING = 'pending'
|
||||||
const FLOW_STEP_STATUS_RUNNING = 'running'
|
const FLOW_STEP_STATUS_RUNNING = 'running'
|
||||||
const FLOW_STEP_STATUS_COMPLETED = 'completed'
|
const FLOW_STEP_STATUS_COMPLETED = 'completed'
|
||||||
@@ -326,15 +338,6 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
|
|||||||
modelKey: 'scene_label',
|
modelKey: 'scene_label',
|
||||||
placeholder: '请选择场景'
|
placeholder: '请选择场景'
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'customer_name',
|
|
||||||
label: '关联客户',
|
|
||||||
value: String(inlineState.customer_name || '').trim() || '待补充',
|
|
||||||
icon: 'mdi mdi-domain',
|
|
||||||
editor: 'text',
|
|
||||||
modelKey: 'customer_name',
|
|
||||||
placeholder: '请输入客户名称'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'attachments',
|
key: 'attachments',
|
||||||
label: '票据状态',
|
label: '票据状态',
|
||||||
@@ -346,8 +349,20 @@ function buildReviewFactCards(reviewPayload, inlineState = createEmptyInlineRevi
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (shouldShowReviewFactCard(reviewPayload, 'customer_name', inlineState.customer_name)) {
|
||||||
|
cards.splice(cards.length - 1, 0, {
|
||||||
|
key: 'customer_name',
|
||||||
|
label: '关联客户',
|
||||||
|
value: String(inlineState.customer_name || '').trim() || '待补充',
|
||||||
|
icon: 'mdi mdi-domain',
|
||||||
|
editor: 'text',
|
||||||
|
modelKey: 'customer_name',
|
||||||
|
placeholder: '请输入客户名称'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
|
if (shouldShowReviewFactCard(reviewPayload, 'location', inlineState.location)) {
|
||||||
cards.splice(4, 0, {
|
cards.splice(cards.length - 1, 0, {
|
||||||
key: 'location',
|
key: 'location',
|
||||||
label: '业务地点',
|
label: '业务地点',
|
||||||
value: String(inlineState.location || '').trim() || '待补充',
|
value: String(inlineState.location || '').trim() || '待补充',
|
||||||
@@ -432,18 +447,19 @@ function buildReviewRiskConversationText(item, detailTarget = {}) {
|
|||||||
const summary = String(item?.summary || '').trim()
|
const summary = String(item?.summary || '').trim()
|
||||||
const detail = String(item?.detail || '').trim()
|
const detail = String(item?.detail || '').trim()
|
||||||
const suggestion = String(item?.suggestion || '').trim()
|
const suggestion = String(item?.suggestion || '').trim()
|
||||||
|
const isInfo = String(item?.level || '').trim() === 'info'
|
||||||
const detailHref = String(detailTarget?.href || '').trim()
|
const detailHref = String(detailTarget?.href || '').trim()
|
||||||
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
|
const detailLabel = String(detailTarget?.label || '').trim() || '进入该单据详情重新填写'
|
||||||
const lines = [`${title}`]
|
const lines = [`${title}`]
|
||||||
|
|
||||||
if (summary) {
|
if (summary) {
|
||||||
lines.push('', `风险点:${summary}`)
|
lines.push('', `${isInfo ? '提示内容' : '风险点'}:${summary}`)
|
||||||
}
|
}
|
||||||
if (detail && detail !== summary) {
|
if (detail && detail !== summary) {
|
||||||
lines.push('', `规则依据:${detail}`)
|
lines.push('', `规则依据:${detail}`)
|
||||||
}
|
}
|
||||||
if (suggestion) {
|
if (suggestion) {
|
||||||
lines.push('', `修改建议:${suggestion}`)
|
lines.push('', `${isInfo ? '处理建议' : '修改建议'}:${suggestion}`)
|
||||||
}
|
}
|
||||||
if (detailHref) {
|
if (detailHref) {
|
||||||
lines.push('', `[${detailLabel}](${detailHref})`)
|
lines.push('', `[${detailLabel}](${detailHref})`)
|
||||||
@@ -539,6 +555,11 @@ export default {
|
|||||||
getSessionRuntimeRefs: () => sessionRuntimeRefs
|
getSessionRuntimeRefs: () => sessionRuntimeRefs
|
||||||
})
|
})
|
||||||
const deleteSessionDialogOpen = ref(false)
|
const deleteSessionDialogOpen = ref(false)
|
||||||
|
const nextStepConfirmDialog = ref({
|
||||||
|
open: false,
|
||||||
|
message: null,
|
||||||
|
action: null
|
||||||
|
})
|
||||||
const reviewActionBusy = ref(false)
|
const reviewActionBusy = ref(false)
|
||||||
const deleteSessionBusy = ref(false)
|
const deleteSessionBusy = ref(false)
|
||||||
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
const reviewDrawerMode = ref(REVIEW_DRAWER_MODE_REVIEW)
|
||||||
@@ -839,6 +860,7 @@ export default {
|
|||||||
extractReviewAttachmentNames,
|
extractReviewAttachmentNames,
|
||||||
mergeFilesWithLimit,
|
mergeFilesWithLimit,
|
||||||
mergeFilePreviews,
|
mergeFilePreviews,
|
||||||
|
isTemporaryPreviewUrl,
|
||||||
resolveAttachmentPreviewKind,
|
resolveAttachmentPreviewKind,
|
||||||
resolveDocumentPreview,
|
resolveDocumentPreview,
|
||||||
buildFilePreviews,
|
buildFilePreviews,
|
||||||
@@ -1418,9 +1440,10 @@ export default {
|
|||||||
nextTick(scrollToBottom)
|
nextTick(scrollToBottom)
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveReviewRiskDetailTarget() {
|
function resolveReviewDetailTarget(message = null) {
|
||||||
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
|
const latestDraftMessage = [...messages.value].reverse().find((item) => item?.draftPayload)
|
||||||
const candidates = [
|
const candidates = [
|
||||||
|
message?.draftPayload,
|
||||||
currentInsight.value.agent?.draftPayload,
|
currentInsight.value.agent?.draftPayload,
|
||||||
latestReviewMessage.value?.draftPayload,
|
latestReviewMessage.value?.draftPayload,
|
||||||
latestDraftMessage?.draftPayload,
|
latestDraftMessage?.draftPayload,
|
||||||
@@ -1443,6 +1466,74 @@ export default {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveReviewRiskDetailTarget() {
|
||||||
|
return resolveReviewDetailTarget()
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildReviewNextStepRichCopyForMessage(message) {
|
||||||
|
const target = resolveReviewDetailTarget(message)
|
||||||
|
return buildReviewNextStepRichCopy(message?.reviewPayload, {
|
||||||
|
detailHref: target.href || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMessageBubbleClass(message) {
|
||||||
|
if (message?.role !== 'assistant' || !resolveReviewNextStepAction(message?.reviewPayload)) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const counts = buildReviewRiskLevelCounts(message.reviewPayload)
|
||||||
|
if (counts.high > 0) {
|
||||||
|
return 'message-bubble-review-risk-high'
|
||||||
|
}
|
||||||
|
if (counts.medium > 0) {
|
||||||
|
return 'message-bubble-review-risk-medium'
|
||||||
|
}
|
||||||
|
if (counts.low > 0) {
|
||||||
|
return 'message-bubble-review-risk-low'
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openReviewNextStepConfirm(message) {
|
||||||
|
const action = resolveReviewNextStepAction(message?.reviewPayload)
|
||||||
|
if (!action) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextStepConfirmDialog.value = {
|
||||||
|
open: true,
|
||||||
|
message,
|
||||||
|
action
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeReviewNextStepConfirm() {
|
||||||
|
if (reviewActionBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
nextStepConfirmDialog.value = {
|
||||||
|
open: false,
|
||||||
|
message: null,
|
||||||
|
action: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmReviewNextStepSubmit() {
|
||||||
|
const message = nextStepConfirmDialog.value.message
|
||||||
|
const action = nextStepConfirmDialog.value.action
|
||||||
|
if (!message || !action || reviewActionBusy.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await handleReviewActionInternal(message, action)
|
||||||
|
} finally {
|
||||||
|
nextStepConfirmDialog.value = {
|
||||||
|
open: false,
|
||||||
|
message: null,
|
||||||
|
action: null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function isWorkbenchBusy() {
|
function isWorkbenchBusy() {
|
||||||
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
|
return submitting.value || reviewActionBusy.value || sessionSwitchBusy.value
|
||||||
}
|
}
|
||||||
@@ -1665,6 +1756,31 @@ export default {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const href = String(anchor.getAttribute('href') || '').trim()
|
const href = String(anchor.getAttribute('href') || '').trim()
|
||||||
|
if (href === REVIEW_NEXT_STEP_HREF) {
|
||||||
|
event.preventDefault()
|
||||||
|
openReviewNextStepConfirm(message)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href.startsWith(REVIEW_RISK_PANEL_HREF_PREFIX)) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (reviewRiskDrawerAvailable.value) {
|
||||||
|
switchReviewDrawerMode(REVIEW_DRAWER_MODE_RISK)
|
||||||
|
} else {
|
||||||
|
toast('当前没有需要额外处理的风险信息。')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (href === REVIEW_QUICK_EDIT_HREF) {
|
||||||
|
event.preventDefault()
|
||||||
|
if (reviewOverviewDrawerAvailable.value) {
|
||||||
|
switchReviewDrawerMode(REVIEW_DRAWER_MODE_REVIEW)
|
||||||
|
toast('已打开右侧核对信息,可以直接修改当前单据。')
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (href.startsWith('/app/')) {
|
if (href.startsWith('/app/')) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
router.push(href)
|
router.push(href)
|
||||||
@@ -1738,12 +1854,12 @@ export default {
|
|||||||
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
|
reviewDrawerTitle, reviewOverviewDrawerAvailable, reviewDocumentDrawerAvailable, reviewRiskDrawerAvailable, reviewFlowDrawerAvailable, reviewDocumentDrawerLabel, reviewDocumentDrawerIcon, reviewRiskDrawerLabel, reviewRiskDrawerIcon, reviewFlowDrawerLabel, reviewFlowDrawerIcon, activeReviewDocument, activeReviewDocumentIndex, activeReviewDocumentPreview, canPreviewActiveReviewDocument,
|
||||||
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
|
reviewIntentText, reviewFactCards, reviewCategoryOptions, reviewOtherCategoryOptions, reviewSelectedOtherCategory, reviewInlineDirty, reviewInlineForm, reviewInlineEditorKey, reviewInlineErrors, reviewOtherCategoryOpen, reviewInlinePendingFiles, DATE_INPUT_FORMAT, REVIEW_SCENE_OTHER_OPTION, REVIEW_SCENE_OPTIONS, REVIEW_OTHER_CATEGORY_OPTIONS,
|
||||||
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
|
workbenchVisible, reviewPanelConfidence, reviewRiskSummary, reviewRiskItems, reviewRiskEmpty, recognizedNarratives, reviewRecognitionNotes, reviewDocumentSummaries, reviewDocumentCount, reviewDocumentDirty, reviewHasUnsavedChanges,
|
||||||
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
travelCalculatorOpen, travelCalculatorBusy, travelCalculatorError, travelCalculatorResult, travelCalculatorForm, travelCalculatorCanSubmit, deleteSessionDialogOpen, nextStepConfirmDialog, reviewActionBusy, deleteSessionBusy, documentPreviewDialog, shortcuts,
|
||||||
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
|
resolveReviewMissingSlotCards, resolveReviewRiskBriefs, buildReviewHeadline, buildReviewSubline, buildReviewStateLabel, buildReviewStateTone, buildReviewPlainFollowupCopy, buildReviewPlainFollowupForMessage, buildReviewNextStepRichCopyForMessage, buildMessageBubbleClass, resolveReviewFooterActions, resolveReviewSaveDraftAction, buildReviewPrimaryButtonLabel, buildReviewMainMessageText,
|
||||||
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
renderMarkdown, buildExpenseQueryWindowLabel, buildExpenseQueryHint, getExpenseQueryActivePage, getExpenseQueryTotalPages, getExpenseQueryVisibleRecords, resolveDocumentPreview, triggerFileUpload, applyComposerDateSelection, handleFilesChange, handleComposerInput, handleComposerEnter, runShortcut, runWelcomeQuickAction: runShortcut, handleSuggestedAction, isSuggestedActionSelected, askHotKnowledgeQuestion, resolveKnowledgeRankLabel, resolveKnowledgeRankTone,
|
||||||
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
refreshFlowRunDetail, formatFlowStepDuration, resolveFlowStepStatusLabel, resolveFlowStepDetail, toggleInsightPanel, openTravelCalculator, toggleTravelCalculator, closeTravelCalculator, submitTravelCalculator, switchToReviewOverviewDrawer, toggleReviewDocumentDrawer, toggleReviewRiskDrawer, toggleReviewFlowDrawer, toggleAttachedFilesExpanded, removeAttachedFile, clearAttachedFiles,
|
||||||
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
requestCloseWorkbench, emitCloseAfterLeave, handleAssistantModalAfterEnter, openExpenseQueryRecord, handleExpenseQueryRecordClick, setExpenseQueryPage, shiftExpenseQueryPage, openDeleteSessionDialog, closeDeleteSessionDialog, confirmDeleteCurrentSession, openInlineReviewEditor, closeInlineReviewEditor, commitInlineReviewEditor, clearInlineReviewFieldError, selectInlineScene, selectReviewCategory, selectReviewOtherCategory,
|
||||||
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,12 @@ const EXPENSE_TYPE_OPTIONS = [
|
|||||||
{ value: 'flight_ticket', label: '机票' },
|
{ value: 'flight_ticket', label: '机票' },
|
||||||
{ value: 'hotel_ticket', label: '住宿票' },
|
{ value: 'hotel_ticket', label: '住宿票' },
|
||||||
{ value: 'ride_ticket', label: '乘车' },
|
{ value: 'ride_ticket', label: '乘车' },
|
||||||
{ value: 'entertainment', label: '业务招待费' },
|
{ value: 'office', label: '办公用品费' },
|
||||||
{ value: 'office', label: '办公费' },
|
|
||||||
{ value: 'meeting', label: '会务费' },
|
{ value: 'meeting', label: '会务费' },
|
||||||
{ value: 'training', label: '培训费' },
|
{ value: 'training', label: '培训费' },
|
||||||
{ value: 'hotel', label: '住宿费' },
|
{ value: 'hotel', label: '住宿费' },
|
||||||
{ value: 'transport', label: '交通费' },
|
{ value: 'transport', label: '交通费' },
|
||||||
{ value: 'meal', label: '餐费' },
|
{ value: 'meal', label: '业务招待费' },
|
||||||
{ value: 'travel_allowance', label: '出差补贴' },
|
{ value: 'travel_allowance', label: '出差补贴' },
|
||||||
{ value: 'other', label: '其他费用' }
|
{ value: 'other', label: '其他费用' }
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -39,6 +39,26 @@ export const MAX_OCR_DOCUMENTS = 10
|
|||||||
export const VISIBLE_ATTACHMENT_CHIPS = 2
|
export const VISIBLE_ATTACHMENT_CHIPS = 2
|
||||||
export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association'
|
export const ATTACHMENT_ASSOCIATION_CONFIRM_HREF = '#confirm-attachment-association'
|
||||||
|
|
||||||
|
export function buildUnsavedDraftAttachmentConfirmationMessage({ fileNames = [] } = {}) {
|
||||||
|
const names = (Array.isArray(fileNames) ? fileNames : [])
|
||||||
|
.map((item) => String(item || '').trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
const attachmentLine = names.length
|
||||||
|
? `本次待归集附件:${names.length} 份(${names.join('、')})`
|
||||||
|
: '本次待归集附件:待识别'
|
||||||
|
|
||||||
|
return [
|
||||||
|
'当前这笔报销信息还没有保存为草稿。',
|
||||||
|
'',
|
||||||
|
'如果继续上传票据,我需要先把当前已识别的信息保存成一张草稿单据,再识别并归集本次附件。',
|
||||||
|
'',
|
||||||
|
attachmentLine,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
`如果 **[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})**,我会先保存这笔未保存单据,再把此次上传的附件归集到该单据。`
|
||||||
|
].join('\n').trim()
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeOcrDocuments(payload) {
|
export function normalizeOcrDocuments(payload) {
|
||||||
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
const documents = Array.isArray(payload?.documents) ? payload.documents : []
|
||||||
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
|
return documents.slice(0, MAX_OCR_DOCUMENTS).map((item) => ({
|
||||||
@@ -333,6 +353,10 @@ export function resolveDocumentPreview(filePreviews, filename) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isTemporaryPreviewUrl(url) {
|
||||||
|
return String(url || '').trim().toLowerCase().startsWith('blob:')
|
||||||
|
}
|
||||||
|
|
||||||
export function buildFileIdentity(file) {
|
export function buildFileIdentity(file) {
|
||||||
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
return [file?.name, file?.size, file?.lastModified, file?.type].join('__')
|
||||||
}
|
}
|
||||||
@@ -374,18 +398,39 @@ export function mergeFilesWithLimit(existingFiles, incomingFiles, limit = MAX_AT
|
|||||||
|
|
||||||
export function mergeFilePreviews(existingPreviews, incomingPreviews) {
|
export function mergeFilePreviews(existingPreviews, incomingPreviews) {
|
||||||
const result = []
|
const result = []
|
||||||
const seen = new Set()
|
const indexByKey = new Map()
|
||||||
|
|
||||||
for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) {
|
for (const preview of [...(existingPreviews || []), ...(incomingPreviews || [])]) {
|
||||||
const key = [preview?.filename, preview?.kind].join('__')
|
const key = [preview?.filename, preview?.kind].join('__')
|
||||||
if (!preview?.filename || seen.has(key)) continue
|
if (!preview?.filename) continue
|
||||||
seen.add(key)
|
|
||||||
result.push(preview)
|
const existingIndex = indexByKey.get(key)
|
||||||
|
if (existingIndex === undefined) {
|
||||||
|
indexByKey.set(key, result.length)
|
||||||
|
result.push(preview)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPreview = result[existingIndex]
|
||||||
|
const nextUrl = String(preview?.url || '').trim()
|
||||||
|
const existingUrl = String(existingPreview?.url || '').trim()
|
||||||
|
if (nextUrl && (!existingUrl || isTemporaryPreviewUrl(existingUrl) || nextUrl !== existingUrl)) {
|
||||||
|
result[existingIndex] = preview
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function filterPersistableFilePreviews(filePreviews) {
|
||||||
|
return (Array.isArray(filePreviews) ? filePreviews : [])
|
||||||
|
.filter((preview) => {
|
||||||
|
const filename = String(preview?.filename || '').trim()
|
||||||
|
const url = String(preview?.url || '').trim()
|
||||||
|
return filename && !isTemporaryPreviewUrl(url)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function inferPreviewKindFromUrl(url) {
|
function inferPreviewKindFromUrl(url) {
|
||||||
const normalized = String(url || '').trim().toLowerCase()
|
const normalized = String(url || '').trim().toLowerCase()
|
||||||
if (!normalized) return ''
|
if (!normalized) return ''
|
||||||
|
|||||||
@@ -65,6 +65,12 @@ export const FLOW_STEP_FALLBACKS = {
|
|||||||
runningText: '正在把已确认信息保存为草稿...',
|
runningText: '正在把已确认信息保存为草稿...',
|
||||||
completedText: '草稿已保存'
|
completedText: '草稿已保存'
|
||||||
},
|
},
|
||||||
|
'attachment-association': {
|
||||||
|
title: '票据关联草稿',
|
||||||
|
tool: 'database.expense_claims.save_or_submit',
|
||||||
|
runningText: '正在把本次票据关联到已保存草稿...',
|
||||||
|
completedText: '票据已归集到草稿'
|
||||||
|
},
|
||||||
'expense-scene-selection': {
|
'expense-scene-selection': {
|
||||||
title: '报销场景确认',
|
title: '报销场景确认',
|
||||||
tool: 'UserConfirmation',
|
tool: 'UserConfirmation',
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const EXPENSE_RISK_LEVEL_LABELS = {
|
|||||||
medium: '中风险',
|
medium: '中风险',
|
||||||
warning: '中风险',
|
warning: '中风险',
|
||||||
low: '低风险',
|
low: '低风险',
|
||||||
info: '低风险'
|
info: '提示'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeExpenseQueryRiskItem(item, index = 0) {
|
export function normalizeExpenseQueryRiskItem(item, index = 0) {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const DOCUMENT_TYPE_LABELS = {
|
|||||||
travel_ticket: '行程单/机票/车票',
|
travel_ticket: '行程单/机票/车票',
|
||||||
flight_itinerary: '机票/航班行程单',
|
flight_itinerary: '机票/航班行程单',
|
||||||
train_ticket: '火车/高铁票',
|
train_ticket: '火车/高铁票',
|
||||||
|
ship_ticket: '轮船票',
|
||||||
hotel_invoice: '酒店住宿票据',
|
hotel_invoice: '酒店住宿票据',
|
||||||
taxi_receipt: '出租车/网约车票据',
|
taxi_receipt: '出租车/网约车票据',
|
||||||
parking_toll_receipt: '停车/通行费票据',
|
parking_toll_receipt: '停车/通行费票据',
|
||||||
@@ -21,10 +22,10 @@ export const EXPENSE_TYPE_LABELS = {
|
|||||||
travel: '差旅费',
|
travel: '差旅费',
|
||||||
hotel: '住宿费',
|
hotel: '住宿费',
|
||||||
transport: '交通费',
|
transport: '交通费',
|
||||||
meal: '伙食费',
|
meal: '业务招待费',
|
||||||
meeting: '会务费',
|
meeting: '会务费',
|
||||||
entertainment: '业务招待费',
|
entertainment: '业务招待费',
|
||||||
office: '办公费',
|
office: '办公用品费',
|
||||||
training: '培训费',
|
training: '培训费',
|
||||||
communication: '通讯费',
|
communication: '通讯费',
|
||||||
welfare: '福利费',
|
welfare: '福利费',
|
||||||
@@ -95,7 +96,6 @@ export const REVIEW_FALLBACK_GROUP_CODES = [
|
|||||||
'hotel',
|
'hotel',
|
||||||
'meal',
|
'meal',
|
||||||
'meeting',
|
'meeting',
|
||||||
'entertainment',
|
|
||||||
'office',
|
'office',
|
||||||
'training',
|
'training',
|
||||||
'communication',
|
'communication',
|
||||||
@@ -106,14 +106,13 @@ export const REVIEW_CATEGORY_PRESET_OPTIONS = [
|
|||||||
{ key: 'travel', label: '差旅费' },
|
{ key: 'travel', label: '差旅费' },
|
||||||
{ key: 'transport', label: '交通费' },
|
{ key: 'transport', label: '交通费' },
|
||||||
{ key: 'hotel', label: '住宿费' },
|
{ key: 'hotel', label: '住宿费' },
|
||||||
{ key: 'meal', label: '餐费' },
|
{ key: 'meal', label: '业务招待费' },
|
||||||
{ key: 'entertainment', label: '业务招待费' },
|
{ key: 'office', label: '办公用品费' },
|
||||||
{ key: 'other_trigger', label: '其他类型', is_other: true }
|
{ key: 'other_trigger', label: '其他类型', is_other: true }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const REVIEW_OTHER_CATEGORY_OPTIONS = [
|
export const REVIEW_OTHER_CATEGORY_OPTIONS = [
|
||||||
{ key: 'meeting', label: '会务费' },
|
{ key: 'meeting', label: '会务费' },
|
||||||
{ key: 'office', label: '办公费' },
|
|
||||||
{ key: 'training', label: '培训费' },
|
{ key: 'training', label: '培训费' },
|
||||||
{ key: 'communication', label: '通讯费' },
|
{ key: 'communication', label: '通讯费' },
|
||||||
{ key: 'welfare', label: '福利费' },
|
{ key: 'welfare', label: '福利费' },
|
||||||
@@ -139,7 +138,7 @@ export const CATEGORY_CONFIDENCE_KEYWORDS = {
|
|||||||
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
|
travel: [/出差|差旅|行程|机票|火车|高铁|航班/],
|
||||||
hotel: [/住宿|酒店|宾馆|民宿/],
|
hotel: [/住宿|酒店|宾馆|民宿/],
|
||||||
transport: [TRANSPORT_KEYWORD_PATTERN],
|
transport: [TRANSPORT_KEYWORD_PATTERN],
|
||||||
meal: [/餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
|
meal: [/业务招待|招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同|餐费|用餐|午餐|晚餐|早餐|伙食|餐饮/],
|
||||||
meeting: [/会务|会议|论坛|展会|参会|会场/],
|
meeting: [/会务|会议|论坛|展会|参会|会场/],
|
||||||
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
|
entertainment: [/招待|宴请|请客|请客户|客户.*吃饭|商务用餐|陪同/],
|
||||||
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
|
office: [/办公|工位|耗材|白板|键盘|鼠标|打印|文具|采购/],
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyIn
|
|||||||
buildReviewSlotMap(reviewPayload).expense_type?.value ||
|
buildReviewSlotMap(reviewPayload).expense_type?.value ||
|
||||||
''
|
''
|
||||||
)
|
)
|
||||||
if (['travel', 'hotel', 'transport'].includes(expenseType)) {
|
if (['travel', 'hotel'].includes(expenseType)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,8 +164,8 @@ export function isTravelReviewPayload(reviewPayload, inlineState = createEmptyIn
|
|||||||
const documentType = String(item?.document_type || '').trim().toLowerCase()
|
const documentType = String(item?.document_type || '').trim().toLowerCase()
|
||||||
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
|
const suggestedType = resolveExpenseTypeCode(item?.suggested_expense_type || item?.scene_label || '')
|
||||||
return (
|
return (
|
||||||
['flight_itinerary', 'train_ticket', 'hotel_invoice', 'taxi_receipt', 'transport_receipt'].includes(documentType) ||
|
['flight_itinerary', 'train_ticket', 'hotel_invoice'].includes(documentType) ||
|
||||||
['travel', 'hotel', 'transport'].includes(suggestedType)
|
['travel', 'hotel'].includes(suggestedType)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -735,6 +735,7 @@ export function buildLocallySyncedReviewPayload(reviewPayload, inlineState = cre
|
|||||||
can_proceed: canProceed,
|
can_proceed: canProceed,
|
||||||
missing_slots: allMissingSlots,
|
missing_slots: allMissingSlots,
|
||||||
slot_cards: nextSlotCards,
|
slot_cards: nextSlotCards,
|
||||||
|
edit_fields: mergeInlineReviewFields(reviewPayload.edit_fields || [], inlineState),
|
||||||
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
|
confirmation_actions: buildLocallySyncedReviewActions(reviewPayload, canProceed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1359,12 +1360,58 @@ export function resolveReviewSaveDraftAction(reviewPayload) {
|
|||||||
export function resolveReviewFooterActions(reviewPayload) {
|
export function resolveReviewFooterActions(reviewPayload) {
|
||||||
return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => {
|
return (Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).filter((item) => {
|
||||||
const actionType = String(item?.action_type || '').trim()
|
const actionType = String(item?.action_type || '').trim()
|
||||||
return ['next_step', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)
|
return ['link_to_existing_draft', 'create_new_claim_from_documents'].includes(actionType)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function buildReviewRiskLevelCounts(reviewPayload) {
|
||||||
|
return (Array.isArray(reviewPayload?.risk_briefs) ? reviewPayload.risk_briefs : []).reduce(
|
||||||
|
(counts, item) => {
|
||||||
|
const level = normalizeReviewRiskLevel(item?.level)
|
||||||
|
if (level === 'high' || level === 'medium' || level === 'low') {
|
||||||
|
counts[level] += 1
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
},
|
||||||
|
{ low: 0, medium: 0, high: 0 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function resolveReviewNextStepAction(reviewPayload) {
|
||||||
|
return (
|
||||||
|
(Array.isArray(reviewPayload?.confirmation_actions) ? reviewPayload.confirmation_actions : []).find(
|
||||||
|
(item) => String(item?.action_type || '').trim() === 'next_step'
|
||||||
|
) || null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export function buildReviewNextStepRichCopy(reviewPayload, { detailHref = '' } = {}) {
|
||||||
|
const nextStepAction = resolveReviewNextStepAction(reviewPayload)
|
||||||
|
if (!nextStepAction) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = buildReviewRiskLevelCounts(reviewPayload)
|
||||||
|
const riskSummary = `现存在 ${counts.low} 条低风险,${counts.medium} 条中风险,${counts.high} 条高风险,具体情况请看 [右侧](#review-risk-panel) 风险信息提示窗。`
|
||||||
|
const lines = [`系统识别您的单据已经填写完所有已知信息,${riskSummary}`]
|
||||||
|
|
||||||
|
if (reviewPayload?.can_proceed && counts.medium === 0 && counts.high === 0) {
|
||||||
|
const editHref = String(detailHref || '').trim() || '#review-quick-edit'
|
||||||
|
lines.push(
|
||||||
|
`系统确认您可以 [继续下一步](#review-next-step) 进行单据的提交,如果您确认信息无误,请点击富文本按钮;如果你还需要继续修改信息,请点击 [快速修改单据信息](${editHref})。`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return lines.join('\n\n')
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) {
|
export function buildReviewPrimaryButtonLabel(reviewPayload, draftPayload) {
|
||||||
const action = resolveReviewPrimaryAction(reviewPayload)
|
const action = resolveReviewPrimaryAction(reviewPayload)
|
||||||
if (!action) return '确认'
|
if (!action) return '确认'
|
||||||
@@ -1444,7 +1491,8 @@ export function normalizeReviewRiskLevel(level) {
|
|||||||
const normalized = String(level || '').trim().toLowerCase()
|
const normalized = String(level || '').trim().toLowerCase()
|
||||||
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
|
if (normalized === 'danger' || normalized === 'error' || normalized === 'critical') return 'high'
|
||||||
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
|
if (normalized === 'warn' || normalized === 'warning' || normalized === 'medium') return 'medium'
|
||||||
if (normalized === 'info' || normalized === 'notice' || normalized === 'low') return 'low'
|
if (normalized === 'info' || normalized === 'notice') return 'info'
|
||||||
|
if (normalized === 'low') return 'low'
|
||||||
if (normalized === 'high') return normalized
|
if (normalized === 'high') return normalized
|
||||||
return 'low'
|
return 'low'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,20 @@ export const EXPENSE_TYPE_OPTIONS = [
|
|||||||
{ value: 'ferry_ticket', label: '轮船票' },
|
{ value: 'ferry_ticket', label: '轮船票' },
|
||||||
{ value: 'hotel_ticket', label: '住宿票' },
|
{ value: 'hotel_ticket', label: '住宿票' },
|
||||||
{ value: 'ride_ticket', label: '乘车' },
|
{ value: 'ride_ticket', label: '乘车' },
|
||||||
{ value: 'entertainment', label: '业务招待费' },
|
{ value: 'office', label: '办公用品费' },
|
||||||
{ value: 'office', label: '办公费' },
|
|
||||||
{ value: 'meeting', label: '会务费' },
|
{ value: 'meeting', label: '会务费' },
|
||||||
{ value: 'training', label: '培训费' },
|
{ value: 'training', label: '培训费' },
|
||||||
{ value: 'hotel', label: '住宿费' },
|
{ value: 'hotel', label: '住宿费' },
|
||||||
{ value: 'transport', label: '交通费' },
|
{ value: 'transport', label: '交通费' },
|
||||||
{ value: 'meal', label: '餐费' },
|
{ value: 'meal', label: '业务招待费' },
|
||||||
{ value: 'travel_allowance', label: '出差补贴' },
|
{ value: 'travel_allowance', label: '出差补贴' },
|
||||||
{ value: 'other', label: '其他费用' }
|
{ value: 'other', label: '其他费用' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const LEGACY_EXPENSE_TYPE_LABELS = {
|
||||||
|
entertainment: '业务招待费'
|
||||||
|
}
|
||||||
|
|
||||||
export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
|
||||||
'travel',
|
'travel',
|
||||||
'meeting',
|
'meeting',
|
||||||
@@ -47,7 +50,10 @@ export function normalizeExpenseType(value) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function resolveExpenseTypeLabel(value) {
|
export function resolveExpenseTypeLabel(value) {
|
||||||
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalizeExpenseType(value))?.label || '其他费用'
|
const normalized = normalizeExpenseType(value)
|
||||||
|
return EXPENSE_TYPE_OPTIONS.find((option) => option.value === normalized)?.label
|
||||||
|
|| LEGACY_EXPENSE_TYPE_LABELS[normalized]
|
||||||
|
|| '其他费用'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isSystemGeneratedExpenseItemSource(source) {
|
export function isSystemGeneratedExpenseItemSource(source) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
const DOCUMENT_TYPE_LABELS = {
|
const DOCUMENT_TYPE_LABELS = {
|
||||||
flight_itinerary: '机票/航班行程单',
|
flight_itinerary: '机票/航班行程单',
|
||||||
train_ticket: '火车/高铁票',
|
train_ticket: '火车/高铁票',
|
||||||
|
ship_ticket: '轮船票',
|
||||||
hotel_invoice: '酒店住宿票据',
|
hotel_invoice: '酒店住宿票据',
|
||||||
taxi_receipt: '出租车/网约车票据',
|
taxi_receipt: '出租车/网约车票据',
|
||||||
parking_toll_receipt: '停车/通行费票据',
|
parking_toll_receipt: '停车/通行费票据',
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ export function useTravelReimbursementAttachments({
|
|||||||
extractReviewAttachmentNames,
|
extractReviewAttachmentNames,
|
||||||
mergeFilesWithLimit,
|
mergeFilesWithLimit,
|
||||||
mergeFilePreviews,
|
mergeFilePreviews,
|
||||||
|
isTemporaryPreviewUrl,
|
||||||
resolveAttachmentPreviewKind,
|
resolveAttachmentPreviewKind,
|
||||||
resolveDocumentPreview,
|
resolveDocumentPreview,
|
||||||
buildFilePreviews,
|
buildFilePreviews,
|
||||||
@@ -117,7 +118,12 @@ export function useTravelReimbursementAttachments({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const filename = String(metadata?.file_name || '').trim()
|
const filename = String(metadata?.file_name || '').trim()
|
||||||
if (!metadata?.previewable || !filename || resolveDocumentPreview(reviewFilePreviews.value, filename)) {
|
const existingPreview = resolveDocumentPreview(reviewFilePreviews.value, filename)
|
||||||
|
if (
|
||||||
|
!metadata?.previewable ||
|
||||||
|
!filename ||
|
||||||
|
(existingPreview?.url && !isTemporaryPreviewUrl(existingPreview.url))
|
||||||
|
) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -236,6 +236,7 @@ export function useTravelReimbursementFlow({
|
|||||||
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
|
const startedAt = Number.isFinite(explicitStartedAt) && explicitStartedAt > 0
|
||||||
? explicitStartedAt
|
? explicitStartedAt
|
||||||
: Date.now()
|
: Date.now()
|
||||||
|
flowFinishedAt.value = 0
|
||||||
upsertFlowStep(key, {
|
upsertFlowStep(key, {
|
||||||
...normalizedPatch,
|
...normalizedPatch,
|
||||||
status: FLOW_STEP_STATUS_RUNNING,
|
status: FLOW_STEP_STATUS_RUNNING,
|
||||||
@@ -446,7 +447,7 @@ export function useTravelReimbursementFlow({
|
|||||||
detail: '正在把已确认信息保存为草稿...'
|
detail: '正在把已确认信息保存为草稿...'
|
||||||
},
|
},
|
||||||
link_to_existing_draft: {
|
link_to_existing_draft: {
|
||||||
key: 'expense-claim-draft',
|
key: 'attachment-association',
|
||||||
title: '票据关联草稿',
|
title: '票据关联草稿',
|
||||||
tool: 'database.expense_claims.save_or_submit',
|
tool: 'database.expense_claims.save_or_submit',
|
||||||
detail: '正在把本次票据关联到现有草稿...'
|
detail: '正在把本次票据关联到现有草稿...'
|
||||||
@@ -504,7 +505,7 @@ export function useTravelReimbursementFlow({
|
|||||||
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' }
|
||||||
}
|
}
|
||||||
if (responseMessage.includes('关联')) {
|
if (responseMessage.includes('关联')) {
|
||||||
return { key: 'expense-claim-draft', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||||
}
|
}
|
||||||
if (responseMessage.includes('新建')) {
|
if (responseMessage.includes('新建')) {
|
||||||
return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
return { key: 'expense-claim-draft', title: '新建报销草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
|
||||||
|
|||||||
@@ -59,7 +59,8 @@ export function useTravelReimbursementReviewDrawer({
|
|||||||
open: false,
|
open: false,
|
||||||
filename: '',
|
filename: '',
|
||||||
kind: 'file',
|
kind: 'file',
|
||||||
url: ''
|
url: '',
|
||||||
|
renderKey: ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const activeReviewFilePreviews = computed(() => reviewFilePreviews.value)
|
const activeReviewFilePreviews = computed(() => reviewFilePreviews.value)
|
||||||
@@ -364,7 +365,12 @@ export function useTravelReimbursementReviewDrawer({
|
|||||||
open: true,
|
open: true,
|
||||||
filename: activeReviewDocument.value.filename,
|
filename: activeReviewDocument.value.filename,
|
||||||
kind: activeReviewDocumentPreview.value.kind,
|
kind: activeReviewDocumentPreview.value.kind,
|
||||||
url: activeReviewDocumentPreview.value.url
|
url: activeReviewDocumentPreview.value.url,
|
||||||
|
renderKey: [
|
||||||
|
activeReviewDocument.value.filename,
|
||||||
|
activeReviewDocumentPreview.value.kind,
|
||||||
|
Date.now()
|
||||||
|
].join('__')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ import {
|
|||||||
readAssistantSessionSnapshot,
|
readAssistantSessionSnapshot,
|
||||||
writeAssistantSessionSnapshot
|
writeAssistantSessionSnapshot
|
||||||
} from '../../utils/assistantSessionSnapshot.js'
|
} from '../../utils/assistantSessionSnapshot.js'
|
||||||
import { buildReviewFilePreviewsFromMessages } from './travelReimbursementAttachmentModel.js'
|
import {
|
||||||
|
buildReviewFilePreviewsFromMessages,
|
||||||
|
filterPersistableFilePreviews
|
||||||
|
} from './travelReimbursementAttachmentModel.js'
|
||||||
import {
|
import {
|
||||||
SESSION_TYPE_EXPENSE,
|
SESSION_TYPE_EXPENSE,
|
||||||
SESSION_TYPE_KNOWLEDGE,
|
SESSION_TYPE_KNOWLEDGE,
|
||||||
@@ -106,7 +109,7 @@ export function useTravelReimbursementSessionState({
|
|||||||
currentInsight:
|
currentInsight:
|
||||||
state.currentInsight
|
state.currentInsight
|
||||||
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
|
|| buildWelcomeInsight(props.entrySource, linkedRequest.value, sessionType, currentUser.value),
|
||||||
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
|
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
|
||||||
composerDraft: String(state.composerDraft || ''),
|
composerDraft: String(state.composerDraft || ''),
|
||||||
attachedFiles: [],
|
attachedFiles: [],
|
||||||
composerFilesExpanded: false,
|
composerFilesExpanded: false,
|
||||||
@@ -164,7 +167,7 @@ export function useTravelReimbursementSessionState({
|
|||||||
conversationId: String(state.conversationId || '').trim(),
|
conversationId: String(state.conversationId || '').trim(),
|
||||||
draftClaimId: String(state.draftClaimId || '').trim(),
|
draftClaimId: String(state.draftClaimId || '').trim(),
|
||||||
currentInsight: state.currentInsight || null,
|
currentInsight: state.currentInsight || null,
|
||||||
reviewFilePreviews: Array.isArray(state.reviewFilePreviews) ? state.reviewFilePreviews : [],
|
reviewFilePreviews: filterPersistableFilePreviews(state.reviewFilePreviews),
|
||||||
composerDraft: String(state.composerDraft || ''),
|
composerDraft: String(state.composerDraft || ''),
|
||||||
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
composerUploadIntent: String(state.composerUploadIntent || '').trim(),
|
||||||
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
insightPanelCollapsed: Boolean(state.insightPanelCollapsed)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||||
buildAttachmentAssociationConfirmationMessage
|
buildAttachmentAssociationConfirmationMessage,
|
||||||
|
buildUnsavedDraftAttachmentConfirmationMessage
|
||||||
} from './travelReimbursementAttachmentModel.js'
|
} from './travelReimbursementAttachmentModel.js'
|
||||||
|
|
||||||
export function useTravelReimbursementSubmitComposer(ctx) {
|
export function useTravelReimbursementSubmitComposer(ctx) {
|
||||||
@@ -103,10 +104,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildConfirmedAssociationText(message) {
|
function buildConfirmedAssociationText(message) {
|
||||||
return String(message?.text || '').replace(
|
return String(message?.text || '')
|
||||||
`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`,
|
.replace(`[确认](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确认')
|
||||||
'已确认'
|
.replace(`[确定](${ATTACHMENT_ASSOCIATION_CONFIRM_HREF})`, '已确定')
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveReviewPanelScope({
|
function resolveReviewPanelScope({
|
||||||
@@ -159,6 +159,55 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
message.meta = ['已确认归集']
|
message.meta = ['已确认归集']
|
||||||
persistSessionState()
|
persistSessionState()
|
||||||
|
|
||||||
|
if (pending.mode === 'save_then_associate') {
|
||||||
|
const inheritedReviewContext = buildReviewFormContextFromPayload(
|
||||||
|
activeReviewPayload.value,
|
||||||
|
reviewInlineForm.value
|
||||||
|
)
|
||||||
|
const savePayload = await submitComposer({
|
||||||
|
rawText: '请先把当前已识别的报销信息保存为草稿,随后继续归集本次上传的附件。',
|
||||||
|
userText: '',
|
||||||
|
files: [],
|
||||||
|
skipUserMessage: true,
|
||||||
|
pendingText: '正在先保存未保存单据...',
|
||||||
|
systemGenerated: true,
|
||||||
|
extraContext: {
|
||||||
|
...runtime.extraContext,
|
||||||
|
...inheritedReviewContext,
|
||||||
|
review_action: 'save_draft'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const savedClaimId = String(savePayload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()
|
||||||
|
const savedClaimNo = String(savePayload?.result?.draft_payload?.claim_no || '').trim()
|
||||||
|
if (!savedClaimId) {
|
||||||
|
toast('当前单据还没有保存成功,请稍后重试。')
|
||||||
|
return savePayload
|
||||||
|
}
|
||||||
|
|
||||||
|
return submitComposer({
|
||||||
|
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${savedClaimNo || '当前草稿'}`,
|
||||||
|
userText: `保存草稿并归集 ${runtime.fileNames.length} 份票据`,
|
||||||
|
files: runtime.files,
|
||||||
|
uploadDisposition: 'continue_existing',
|
||||||
|
skipDraftAssociationPrompt: true,
|
||||||
|
skipUserMessage: true,
|
||||||
|
appendToCurrentFlow: true,
|
||||||
|
systemGenerated: true,
|
||||||
|
pendingText: savedClaimNo
|
||||||
|
? `草稿 ${savedClaimNo} 已保存,正在识别并归集附件...`
|
||||||
|
: '草稿已保存,正在识别并归集附件...',
|
||||||
|
associationConfirmed: true,
|
||||||
|
extraContext: {
|
||||||
|
...runtime.extraContext,
|
||||||
|
review_action: 'link_to_existing_draft',
|
||||||
|
draft_claim_id: savedClaimId,
|
||||||
|
selected_claim_id: savedClaimId,
|
||||||
|
selected_claim_no: savedClaimNo,
|
||||||
|
attachment_association_confirmed: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return submitComposer({
|
return submitComposer({
|
||||||
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
rawText: `确认将本次上传的 ${runtime.fileNames.length} 份票据归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
||||||
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
userText: `确认归集到草稿 ${runtime.claimNo || '当前草稿'}`,
|
||||||
@@ -231,6 +280,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
|
|
||||||
const rawText = resolveComposerSubmitText(options.rawText).trim()
|
const rawText = resolveComposerSubmitText(options.rawText).trim()
|
||||||
const systemGenerated = Boolean(options.systemGenerated)
|
const systemGenerated = Boolean(options.systemGenerated)
|
||||||
|
const appendToCurrentFlow = Boolean(options.appendToCurrentFlow)
|
||||||
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
|
const normalizedFiles = isKnowledgeSession.value ? [] : Array.from(options.files ?? attachedFiles.value)
|
||||||
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
|
const fileMergeResult = mergeFilesWithLimit([], normalizedFiles, MAX_ATTACHMENTS)
|
||||||
const files = fileMergeResult.files
|
const files = fileMergeResult.files
|
||||||
@@ -308,6 +358,47 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
|
||||||
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
|
||||||
|
|
||||||
|
const hasUnsavedReviewDraft = Boolean(
|
||||||
|
!isKnowledgeSession.value &&
|
||||||
|
files.length &&
|
||||||
|
activeReviewPayload.value &&
|
||||||
|
!String(draftClaimId.value || '').trim() &&
|
||||||
|
!detailScopedClaimId &&
|
||||||
|
!resolvedUploadDisposition &&
|
||||||
|
!options.skipDraftAssociationPrompt &&
|
||||||
|
!reviewAction
|
||||||
|
)
|
||||||
|
if (hasUnsavedReviewDraft) {
|
||||||
|
const associationId = createPendingAttachmentAssociationId()
|
||||||
|
pendingAttachmentAssociations.set(associationId, {
|
||||||
|
files,
|
||||||
|
fileNames,
|
||||||
|
filePreviews: buildComposerFilePreviews(files),
|
||||||
|
extraContext
|
||||||
|
})
|
||||||
|
resetFlowRun()
|
||||||
|
if (!options.skipUserMessage) {
|
||||||
|
messages.value.push(createMessage('user', userText, fileNames))
|
||||||
|
}
|
||||||
|
messages.value.push(createMessage(
|
||||||
|
'assistant',
|
||||||
|
buildUnsavedDraftAttachmentConfirmationMessage({ fileNames }),
|
||||||
|
[],
|
||||||
|
{
|
||||||
|
meta: ['等待确认保存并归集'],
|
||||||
|
pendingAttachmentAssociation: {
|
||||||
|
id: associationId,
|
||||||
|
mode: 'save_then_associate',
|
||||||
|
status: 'pending',
|
||||||
|
fileNames
|
||||||
|
}
|
||||||
|
}
|
||||||
|
))
|
||||||
|
nextTick(scrollToBottom)
|
||||||
|
persistSessionState()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!isKnowledgeSession.value &&
|
!isKnowledgeSession.value &&
|
||||||
files.length &&
|
files.length &&
|
||||||
@@ -363,7 +454,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetFlowRun()
|
if (!appendToCurrentFlow) {
|
||||||
|
resetFlowRun()
|
||||||
|
} else {
|
||||||
|
clearFlowSimulationTimers()
|
||||||
|
}
|
||||||
if (rawText && !reviewAction) {
|
if (rawText && !reviewAction) {
|
||||||
startFlowStep('intent', '正在识别业务意图...')
|
startFlowStep('intent', '正在识别业务意图...')
|
||||||
if (waitForExpenseIntentConfirmation) {
|
if (waitForExpenseIntentConfirmation) {
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import {
|
|||||||
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
ATTACHMENT_ASSOCIATION_CONFIRM_HREF,
|
||||||
buildAttachmentAssociationConfirmationMessage,
|
buildAttachmentAssociationConfirmationMessage,
|
||||||
buildOcrFilePreviews,
|
buildOcrFilePreviews,
|
||||||
buildReviewFilePreviewsFromReviewPayload
|
buildReviewFilePreviewsFromReviewPayload,
|
||||||
|
buildUnsavedDraftAttachmentConfirmationMessage,
|
||||||
|
filterPersistableFilePreviews,
|
||||||
|
mergeFilePreviews
|
||||||
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
|
} from '../src/views/scripts/travelReimbursementAttachmentModel.js'
|
||||||
import {
|
import {
|
||||||
buildDraftAssociationQueryPayload,
|
buildDraftAssociationQueryPayload,
|
||||||
@@ -99,6 +102,14 @@ test('attachment upload association uses conversation selection instead of legac
|
|||||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSubmitComposer.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const flowSource = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementFlow.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const conversationSource = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/travelReimbursementConversationModel.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
|
assert.doesNotMatch(viewSource, /检测到你已有单据事件|uploadDecisionDialogOpen|continueExistingUpload|createNewUploadDocument/)
|
||||||
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
|
assert.doesNotMatch(submitComposerSource, /uploadDecisionDialogOpen|hasExistingDocumentEvent|skipUploadDecisionPrompt/)
|
||||||
@@ -112,6 +123,26 @@ test('attachment upload association uses conversation selection instead of legac
|
|||||||
submitComposerSource,
|
submitComposerSource,
|
||||||
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
|
/files\.length[\s\S]*!resolvedUploadDisposition[\s\S]*!options\.skipDraftAssociationPrompt[\s\S]*!reviewAction/
|
||||||
)
|
)
|
||||||
|
assert.match(submitComposerSource, /mode:\s*'save_then_associate'/)
|
||||||
|
assert.match(submitComposerSource, /review_action:\s*'save_draft'[\s\S]*review_action:\s*'link_to_existing_draft'/)
|
||||||
|
assert.match(submitComposerSource, /appendToCurrentFlow:\s*true/)
|
||||||
|
assert.match(submitComposerSource, /const appendToCurrentFlow = Boolean\(options\.appendToCurrentFlow\)/)
|
||||||
|
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/)
|
||||||
|
assert.match(flowSource, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
|
||||||
|
assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
|
||||||
|
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('unsaved review attachment prompt asks for explicit rich-text confirmation', () => {
|
||||||
|
const message = buildUnsavedDraftAttachmentConfirmationMessage({
|
||||||
|
fileNames: ['taxi.pdf']
|
||||||
|
})
|
||||||
|
const rendered = renderMarkdown(message)
|
||||||
|
|
||||||
|
assert.match(message, /当前这笔报销信息还没有保存为草稿/)
|
||||||
|
assert.match(message, /本次待归集附件:1 份/)
|
||||||
|
assert.match(message, new RegExp(`\\*\\*\\[确定\\]\\(${ATTACHMENT_ASSOCIATION_CONFIRM_HREF}\\)\\*\\*`))
|
||||||
|
assert.match(rendered, /<strong><a href="#confirm-attachment-association" class="markdown-action-link markdown-action-link-confirm">确定<\/a><\/strong>/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('OCR preview builders keep hotel receipt image previews when preview kind is omitted', () => {
|
test('OCR preview builders keep hotel receipt image previews when preview kind is omitted', () => {
|
||||||
@@ -137,6 +168,26 @@ test('OCR preview builders keep hotel receipt image previews when preview kind i
|
|||||||
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
assert.deepEqual(reviewPreviews, [{ filename: 'hotel.png', kind: 'image', url: dataUrl }])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('file preview cache replaces temporary object urls and never persists them', () => {
|
||||||
|
const merged = mergeFilePreviews(
|
||||||
|
[
|
||||||
|
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/old-preview' },
|
||||||
|
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ filename: 'invoice.pdf', kind: 'pdf', url: 'blob:http://localhost/new-preview' },
|
||||||
|
{ filename: 'hotel.png', kind: 'image' }
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert.equal(merged.length, 2)
|
||||||
|
assert.equal(merged[0].url, 'blob:http://localhost/new-preview')
|
||||||
|
assert.equal(merged[1].url, 'data:image/png;base64,stable')
|
||||||
|
assert.deepEqual(filterPersistableFilePreviews(merged), [
|
||||||
|
{ filename: 'hotel.png', kind: 'image', url: 'data:image/png;base64,stable' }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test('draft association query keeps a single candidate selectable in the conversation', () => {
|
test('draft association query keeps a single candidate selectable in the conversation', () => {
|
||||||
const payload = buildDraftAssociationQueryPayload([
|
const payload = buildDraftAssociationQueryPayload([
|
||||||
{
|
{
|
||||||
@@ -182,6 +233,30 @@ test('expense query payload keeps structured risk items for claim-level risk dri
|
|||||||
assert.equal(payload.records[0].riskItems[0].summary, '住宿金额超过城市标准')
|
assert.equal(payload.records[0].riskItems[0].summary, '住宿金额超过城市标准')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('expense query info items render as prompts instead of low risk', () => {
|
||||||
|
const payload = normalizeExpenseQueryPayload({
|
||||||
|
result_type: 'expense_claim_list',
|
||||||
|
records: [
|
||||||
|
{
|
||||||
|
claim_id: 'claim-info',
|
||||||
|
claim_no: 'EXP-202605-010',
|
||||||
|
amount: 59.1,
|
||||||
|
risk_flags: [
|
||||||
|
{
|
||||||
|
key: 'normal-tip',
|
||||||
|
level: 'info',
|
||||||
|
title: '票据提示',
|
||||||
|
summary: '票据已识别,当前没有异常。'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(payload.records[0].riskItems[0].levelLabel, '提示')
|
||||||
|
assert.notEqual(payload.records[0].riskItems[0].levelLabel, '低风险')
|
||||||
|
})
|
||||||
|
|
||||||
test('expense query hint guides users to the reimbursement center after the top five results', () => {
|
test('expense query hint guides users to the reimbursement center after the top five results', () => {
|
||||||
const payload = normalizeExpenseQueryPayload({
|
const payload = normalizeExpenseQueryPayload({
|
||||||
result_type: 'expense_claim_list',
|
result_type: 'expense_claim_list',
|
||||||
|
|||||||
117
web/tests/knowledge-ingest-log-model.test.mjs
Normal file
117
web/tests/knowledge-ingest-log-model.test.mjs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
buildKnowledgeIngestLogModel,
|
||||||
|
isKnowledgeIngestRun
|
||||||
|
} from '../src/utils/knowledgeIngestLogModel.js'
|
||||||
|
|
||||||
|
function buildRun() {
|
||||||
|
return {
|
||||||
|
status: 'running',
|
||||||
|
route_json: {
|
||||||
|
job_type: 'knowledge_index_sync',
|
||||||
|
folder: '制度文件',
|
||||||
|
phase: 'indexing',
|
||||||
|
progress: {
|
||||||
|
total_documents: 2,
|
||||||
|
completed_documents: 1,
|
||||||
|
failed_documents: 0,
|
||||||
|
percent: 55
|
||||||
|
},
|
||||||
|
knowledge_ingest: {
|
||||||
|
status: 'running',
|
||||||
|
phase: 'indexing',
|
||||||
|
current_document_id: 'doc-2',
|
||||||
|
graph: {
|
||||||
|
chunk_count: 5,
|
||||||
|
entity_count: 3,
|
||||||
|
relation_count: 2,
|
||||||
|
entities: ['远光软件', '支出管理'],
|
||||||
|
relations: [{ source: '远光软件', target: '支出管理', type: '关联' }]
|
||||||
|
},
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
document_id: 'doc-1',
|
||||||
|
name: '公司支出管理办法.pdf',
|
||||||
|
folder: '制度文件',
|
||||||
|
extension: 'pdf',
|
||||||
|
status: 'succeeded',
|
||||||
|
phase: 'indexed',
|
||||||
|
chunk_count: 3,
|
||||||
|
entity_count: 2,
|
||||||
|
relation_count: 1,
|
||||||
|
chunks: [{ id: 'chunk-1', order: 0, tokens: 21, summary: '支出管理范围' }],
|
||||||
|
sections: [{ title: '第一章 总则', excerpt: '适用于公司支出。' }],
|
||||||
|
events: [{ at: '2026-05-22T08:00:00Z', level: 'info', message: '完成' }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
document_id: 'doc-2',
|
||||||
|
name: '费用审批台账.xlsx',
|
||||||
|
folder: '制度文件',
|
||||||
|
extension: 'xlsx',
|
||||||
|
status: 'running',
|
||||||
|
phase: 'indexing',
|
||||||
|
chunk_count: 2,
|
||||||
|
entity_count: 1,
|
||||||
|
relation_count: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function testDetectsKnowledgeIngestRun() {
|
||||||
|
assert.equal(isKnowledgeIngestRun(buildRun()), true)
|
||||||
|
assert.equal(isKnowledgeIngestRun({ route_json: { job_type: 'daily_check' } }), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function testBuildsInteractiveModel() {
|
||||||
|
const model = buildKnowledgeIngestLogModel(buildRun())
|
||||||
|
|
||||||
|
assert.equal(model.available, true)
|
||||||
|
assert.equal(model.folder, '制度文件')
|
||||||
|
assert.equal(model.selectedDocumentId, 'doc-2')
|
||||||
|
assert.equal(model.documents.length, 2)
|
||||||
|
assert.equal(model.documents[0].statusLabel, '已完成')
|
||||||
|
assert.equal(model.documents[0].chunks[0].summary, '支出管理范围')
|
||||||
|
assert.equal(model.graph.entityCount, 3)
|
||||||
|
assert.equal(model.graph.relations[0].source, '远光软件')
|
||||||
|
assert.equal(model.metrics[1].value, '5')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testFallsBackToToolCallDocuments() {
|
||||||
|
const model = buildKnowledgeIngestLogModel({
|
||||||
|
status: 'succeeded',
|
||||||
|
route_json: {
|
||||||
|
job_type: 'knowledge_index_sync',
|
||||||
|
requested_document_ids: ['doc-1']
|
||||||
|
},
|
||||||
|
tool_calls: [
|
||||||
|
{
|
||||||
|
response_json: {
|
||||||
|
documents: [
|
||||||
|
{
|
||||||
|
document_id: 'doc-1',
|
||||||
|
name: '归集结果.docx',
|
||||||
|
status: 'succeeded',
|
||||||
|
chunk_count: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.equal(model.documents[0].name, '归集结果.docx')
|
||||||
|
assert.equal(model.graph.chunkCount, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
testDetectsKnowledgeIngestRun()
|
||||||
|
testBuildsInteractiveModel()
|
||||||
|
testFallsBackToToolCallDocuments()
|
||||||
|
console.log('knowledge ingest log model tests passed')
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
32
web/tests/navigation-route-resolution.test.mjs
Normal file
32
web/tests/navigation-route-resolution.test.mjs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
|
||||||
|
import {
|
||||||
|
resolveAppViewFromRoute,
|
||||||
|
resolveTargetRouteName
|
||||||
|
} from '../src/composables/useNavigation.js'
|
||||||
|
|
||||||
|
function testDerivesViewFromRouteName() {
|
||||||
|
assert.equal(resolveAppViewFromRoute({ name: 'app-log-detail', meta: {} }), 'logs')
|
||||||
|
assert.equal(resolveAppViewFromRoute({ name: 'app-request-detail', meta: {} }), 'requests')
|
||||||
|
assert.equal(resolveAppViewFromRoute({ name: 'app-policies', meta: { appView: 'logs' } }), 'policies')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testFallsBackToValidMeta() {
|
||||||
|
assert.equal(resolveAppViewFromRoute({ name: 'custom', meta: { appView: 'employees' } }), 'employees')
|
||||||
|
assert.equal(resolveAppViewFromRoute({ name: 'custom', meta: { appView: 'unknown' } }), 'overview')
|
||||||
|
}
|
||||||
|
|
||||||
|
function testResolvesMainRouteNames() {
|
||||||
|
assert.equal(resolveTargetRouteName('logs'), 'app-logs')
|
||||||
|
assert.equal(resolveTargetRouteName('policies'), 'app-policies')
|
||||||
|
assert.equal(resolveTargetRouteName('missing'), 'app-overview')
|
||||||
|
}
|
||||||
|
|
||||||
|
function run() {
|
||||||
|
testDerivesViewFromRouteName()
|
||||||
|
testFallsBackToValidMeta()
|
||||||
|
testResolvesMainRouteNames()
|
||||||
|
console.log('navigation route resolution tests passed')
|
||||||
|
}
|
||||||
|
|
||||||
|
run()
|
||||||
@@ -24,6 +24,19 @@ test('local flow intent preview names transport expense for riding fare text', (
|
|||||||
(item) => item.includes('交通出行') && item.includes('交通费')
|
(item) => item.includes('交通出行') && item.includes('交通费')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
assert.ok(
|
||||||
|
buildLocalExtractionProgressMessages(ridingFareMessage).some(
|
||||||
|
(item) => item.includes('正在判断待补项') && !item.includes('客户名称') && !item.includes('参与人员')
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local flow recognizes broader reimbursement scene keywords', () => {
|
||||||
|
assert.equal(inferLocalFlowCandidates('报销会议场地费').expenseType, '会务费')
|
||||||
|
assert.equal(inferLocalFlowCandidates('报销打印纸和硒鼓').expenseType, '办公用品费')
|
||||||
|
assert.equal(inferLocalFlowCandidates('报销培训课程费').expenseType, '培训费')
|
||||||
|
assert.equal(inferLocalFlowCandidates('报销手机话费和流量费').expenseType, '通讯费')
|
||||||
|
assert.equal(inferLocalFlowCandidates('报销员工体检费').expenseType, '福利费')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('semantic intent detail includes recognized expense type', () => {
|
test('semantic intent detail includes recognized expense type', () => {
|
||||||
|
|||||||
@@ -3,7 +3,14 @@ import { readFileSync } from 'node:fs'
|
|||||||
import test from 'node:test'
|
import test from 'node:test'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
import { buildReviewPlainFollowupCopy } from '../src/views/scripts/travelReimbursementReviewModel.js'
|
import {
|
||||||
|
buildLocallySyncedReviewPayload,
|
||||||
|
buildReviewNextStepRichCopy,
|
||||||
|
buildReviewPlainFollowupCopy,
|
||||||
|
isTravelReviewPayload,
|
||||||
|
resolveReviewFooterActions
|
||||||
|
} from '../src/views/scripts/travelReimbursementReviewModel.js'
|
||||||
|
import { renderMarkdown } from '../src/utils/markdown.js'
|
||||||
|
|
||||||
const createViewTemplate = readFileSync(
|
const createViewTemplate = readFileSync(
|
||||||
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
fileURLToPath(new URL('../src/views/TravelReimbursementCreateView.vue', import.meta.url)),
|
||||||
@@ -33,6 +40,26 @@ const attachmentsScript = readFileSync(
|
|||||||
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementAttachments.js', import.meta.url)),
|
||||||
'utf8'
|
'utf8'
|
||||||
)
|
)
|
||||||
|
const sessionStateScript = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/views/scripts/useTravelReimbursementSessionState.js', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const createViewBaseStyles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const createViewPart2Styles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part2.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const createViewPart3Styles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part3.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
const createViewPart4Styles = readFileSync(
|
||||||
|
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part4.css', import.meta.url)),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
test('review drawer tools expose the default review tab before conditional document and risk tabs', () => {
|
||||||
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
assert.match(createViewTemplate, /v-if="activeReviewPayload && reviewOverviewDrawerAvailable"[\s\S]*title="报销识别核对"[\s\S]*@click="switchToReviewOverviewDrawer"/)
|
||||||
@@ -58,6 +85,157 @@ test('review drawer tool buttons switch modes instead of toggling the active mod
|
|||||||
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
assert.doesNotMatch(createViewScript, /REVIEW_DRAWER_MODE_FLOW\s*\?\s*REVIEW_DRAWER_MODE_REVIEW/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('document review drawer fills sidebar height and preview dialog is centered', () => {
|
||||||
|
assert.match(createViewTemplate, /class="insight-body"[\s\S]*:class="\{ 'document-review-body': isReviewDocumentDrawer \}"/)
|
||||||
|
assert.match(createViewBaseStyles, /\.insight-panel-shell\s*\{[\s\S]*display:\s*flex;[\s\S]*min-height:\s*0;/)
|
||||||
|
assert.match(createViewPart2Styles, /\.insight-body\.document-review-body\s*\{[\s\S]*display:\s*flex;[\s\S]*overflow:\s*hidden;/)
|
||||||
|
assert.match(createViewPart2Styles, /\.review-ticket-drawer\s*\{[\s\S]*grid-template-rows:\s*auto minmax\(0,\s*1fr\);[\s\S]*height:\s*100%;[\s\S]*overflow:\s*hidden;/)
|
||||||
|
assert.match(createViewPart2Styles, /\.review-document-stage\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\);/)
|
||||||
|
assert.match(createViewPart2Styles, /\.review-document-scroll\s*\{[\s\S]*max-height:\s*none;[\s\S]*min-height:\s*0;/)
|
||||||
|
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image\s*\{[\s\S]*place-items:\s*center;[\s\S]*min-height:\s*220px;/)
|
||||||
|
assert.match(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*height:\s*auto;[\s\S]*object-fit:\s*contain;/)
|
||||||
|
assert.doesNotMatch(createViewPart2Styles, /\.review-document-preview-card\.image img\s*\{[\s\S]*object-fit:\s*cover;/)
|
||||||
|
assert.match(createViewPart4Styles, /\.review-overlay\s*\{[\s\S]*align-items:\s*center;[\s\S]*justify-content:\s*center;/)
|
||||||
|
assert.match(createViewPart4Styles, /\.review-preview-modal\s*\{[\s\S]*margin:\s*auto;[\s\S]*flex:\s*none;/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('document preview avoids restored stale object urls', () => {
|
||||||
|
assert.match(createViewTemplate, /v-if="documentPreviewDialog\.kind === 'image'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
|
||||||
|
assert.match(createViewTemplate, /v-else-if="documentPreviewDialog\.kind === 'pdf'"[\s\S]*:key="documentPreviewDialog\.renderKey"/)
|
||||||
|
assert.match(reviewDrawerScript, /renderKey:\s*''/)
|
||||||
|
assert.match(reviewDrawerScript, /renderKey:\s*\[[\s\S]*Date\.now\(\)[\s\S]*\]\.join\('__'\)/)
|
||||||
|
assert.match(attachmentsScript, /isTemporaryPreviewUrl/)
|
||||||
|
assert.match(attachmentsScript, /existingPreview\?\.url && !isTemporaryPreviewUrl\(existingPreview\.url\)/)
|
||||||
|
assert.match(sessionStateScript, /filterPersistableFilePreviews\(state\.reviewFilePreviews\)/)
|
||||||
|
assert.doesNotMatch(sessionStateScript, /filterPersistableFilePreviews\(nextState\.reviewFilePreviews\)/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local transport review no longer uses the travel hotel template', () => {
|
||||||
|
const reviewPayload = {
|
||||||
|
slot_cards: [
|
||||||
|
{
|
||||||
|
key: 'expense_type',
|
||||||
|
label: '报销类型',
|
||||||
|
value: '交通费',
|
||||||
|
normalized_value: 'transport',
|
||||||
|
status: 'identified'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
document_cards: [
|
||||||
|
{
|
||||||
|
document_type: 'taxi_receipt',
|
||||||
|
suggested_expense_type: 'transport',
|
||||||
|
scene_label: '交通费'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.equal(isTravelReviewPayload(reviewPayload, { expense_type: '交通费' }), false)
|
||||||
|
assert.match(createViewScript, /shouldShowReviewFactCard\(reviewPayload, 'customer_name'/)
|
||||||
|
assert.doesNotMatch(
|
||||||
|
createViewScript,
|
||||||
|
/key:\s*'customer_name'[\s\S]{0,220}placeholder:\s*'请输入客户名称'[\s\S]{0,80}\},\s*\{[\s\S]{0,80}key:\s*'attachments'/
|
||||||
|
)
|
||||||
|
assert.match(createViewTemplate, /placeholder="例如:出租车\/网约车票据 \/ 火车\/高铁票"/)
|
||||||
|
assert.doesNotMatch(createViewTemplate, /票据场景[\s\S]{0,260}例如:业务招待费 \/ 差旅费/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('local save of changed reimbursement category updates edit fields too', () => {
|
||||||
|
const nextPayload = buildLocallySyncedReviewPayload(
|
||||||
|
{
|
||||||
|
can_proceed: false,
|
||||||
|
edit_fields: [
|
||||||
|
{ key: 'expense_type', label: '报销分类', value: '交通费' },
|
||||||
|
{ key: 'reason', label: '事由', value: '打车去客户现场' }
|
||||||
|
],
|
||||||
|
slot_cards: [
|
||||||
|
{
|
||||||
|
key: 'expense_type',
|
||||||
|
label: '报销类型',
|
||||||
|
value: '交通费',
|
||||||
|
normalized_value: 'transport',
|
||||||
|
required: true,
|
||||||
|
status: 'identified'
|
||||||
|
}
|
||||||
|
],
|
||||||
|
confirmation_actions: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
expense_type: '办公用品费',
|
||||||
|
reason_value: '右侧核对后改为办公用品费'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const expenseTypeField = nextPayload.edit_fields.find((item) => item.key === 'expense_type')
|
||||||
|
assert.equal(expenseTypeField.value, '办公用品费')
|
||||||
|
assert.equal(nextPayload.slot_cards[0].value, '办公用品费')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('next step action uses rich text guidance and confirm dialog instead of footer button', () => {
|
||||||
|
const reviewPayload = {
|
||||||
|
can_proceed: true,
|
||||||
|
risk_briefs: [
|
||||||
|
{ level: 'low', title: '票据提示', content: '普通提示' }
|
||||||
|
],
|
||||||
|
confirmation_actions: [
|
||||||
|
{ action_type: 'save_draft', label: '保存为草稿' },
|
||||||
|
{ action_type: 'next_step', label: '继续下一步', emphasis: 'primary' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const copy = buildReviewNextStepRichCopy(reviewPayload, { detailHref: '/app/requests/claim-1' })
|
||||||
|
const rendered = renderMarkdown(copy)
|
||||||
|
|
||||||
|
assert.match(copy, /系统识别您的单据已经填写完所有已知信息/)
|
||||||
|
assert.match(copy, /现存在 1 条低风险,0 条中风险,0 条高风险/)
|
||||||
|
assert.doesNotMatch(copy, /#review-risk-low/)
|
||||||
|
assert.doesNotMatch(copy, /#review-risk-medium/)
|
||||||
|
assert.doesNotMatch(copy, /#review-risk-high/)
|
||||||
|
assert.match(copy, /\[右侧\]\(#review-risk-panel\) 风险信息提示窗/)
|
||||||
|
assert.match(copy, /\[继续下一步\]\(#review-next-step\)/)
|
||||||
|
assert.match(copy, /\[快速修改单据信息\]\(\/app\/requests\/claim-1\)/)
|
||||||
|
assert.doesNotMatch(rendered, /markdown-risk-link-/)
|
||||||
|
assert.match(rendered, /<span class="markdown-risk-text-low">低风险<\/span>/)
|
||||||
|
assert.match(rendered, /<span class="markdown-risk-text-medium">中风险<\/span>/)
|
||||||
|
assert.match(rendered, /<span class="markdown-risk-text-high">高风险<\/span>/)
|
||||||
|
assert.doesNotMatch(rendered, /href="#review-risk-low"/)
|
||||||
|
assert.doesNotMatch(rendered, /href="#review-risk-medium"/)
|
||||||
|
assert.doesNotMatch(rendered, /href="#review-risk-high"/)
|
||||||
|
assert.match(rendered, /markdown-action-link-risk/)
|
||||||
|
assert.match(rendered, /markdown-action-link-next/)
|
||||||
|
assert.deepEqual(resolveReviewFooterActions(reviewPayload), [])
|
||||||
|
|
||||||
|
const highRiskCopy = buildReviewNextStepRichCopy(
|
||||||
|
{
|
||||||
|
...reviewPayload,
|
||||||
|
risk_briefs: [{ level: 'high', title: '金额超标' }]
|
||||||
|
},
|
||||||
|
{ detailHref: '/app/requests/claim-1' }
|
||||||
|
)
|
||||||
|
assert.doesNotMatch(highRiskCopy, /\[继续下一步\]\(#review-next-step\)/)
|
||||||
|
|
||||||
|
assert.match(createViewTemplate, /class="review-next-step-rich-copy message-answer-markdown"[\s\S]*renderMarkdown\(buildReviewNextStepRichCopyForMessage\(message\)\)/)
|
||||||
|
assert.match(createViewTemplate, /class="message-bubble" :class="buildMessageBubbleClass\(message\)"/)
|
||||||
|
assert.match(createViewTemplate, /:open="nextStepConfirmDialog\.open"[\s\S]*title="确认提交当前单据?"[\s\S]*confirm-text="确认提交"/)
|
||||||
|
assert.match(createViewScript, /const REVIEW_NEXT_STEP_HREF = '#review-next-step'/)
|
||||||
|
assert.match(createViewScript, /buildReviewRiskLevelCounts/)
|
||||||
|
assert.match(createViewScript, /function buildMessageBubbleClass\(message\)/)
|
||||||
|
assert.match(createViewScript, /message-bubble-review-risk-high/)
|
||||||
|
assert.match(createViewScript, /message-bubble-review-risk-medium/)
|
||||||
|
assert.match(createViewScript, /message-bubble-review-risk-low/)
|
||||||
|
assert.match(createViewScript, /function openReviewNextStepConfirm\(message\)/)
|
||||||
|
assert.match(createViewScript, /async function confirmReviewNextStepSubmit\(\)/)
|
||||||
|
assert.match(createViewScript, /href === REVIEW_NEXT_STEP_HREF[\s\S]*openReviewNextStepConfirm\(message\)/)
|
||||||
|
assert.match(createViewScript, /href\.startsWith\(REVIEW_RISK_PANEL_HREF_PREFIX\)[\s\S]*switchReviewDrawerMode\(REVIEW_DRAWER_MODE_RISK\)/)
|
||||||
|
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-low\s*\{[\s\S]*border-color:\s*rgba\(37,\s*99,\s*235,/)
|
||||||
|
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-medium\s*\{[\s\S]*border-color:\s*rgba\(217,\s*119,\s*6,/)
|
||||||
|
assert.match(createViewBaseStyles, /\.message-bubble-review-risk-high\s*\{[\s\S]*border-color:\s*rgba\(220,\s*38,\s*38,/)
|
||||||
|
assert.doesNotMatch(createViewBaseStyles, /markdown-risk-link-low/)
|
||||||
|
assert.match(createViewBaseStyles, /\.markdown-risk-text-low[\s\S]*color:\s*#2563eb/)
|
||||||
|
assert.match(createViewBaseStyles, /\.markdown-risk-text-medium[\s\S]*color:\s*#d97706/)
|
||||||
|
assert.match(createViewBaseStyles, /\.markdown-risk-text-high[\s\S]*color:\s*#dc2626/)
|
||||||
|
assert.match(createViewPart3Styles, /\.review-next-step-rich-copy\s*\{[\s\S]*margin-top:\s*30px;/)
|
||||||
|
})
|
||||||
|
|
||||||
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
|
test('review risk drawer lists risk briefs without score and posts details into the conversation', () => {
|
||||||
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
|
const riskItemsBlock = createViewScript.match(/function buildReviewRiskItems\(reviewPayload\) \{[\s\S]*?\n\}\n\nfunction buildReviewRiskConversationText/)
|
||||||
assert.ok(riskItemsBlock, 'risk item builder should be present')
|
assert.ok(riskItemsBlock, 'risk item builder should be present')
|
||||||
@@ -80,8 +258,12 @@ test('review risk drawer lists risk briefs without score and posts details into
|
|||||||
)
|
)
|
||||||
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
|
assert.doesNotMatch(createViewTemplate, /\{\{\s*item\.levelLabel\s*\}\}/)
|
||||||
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
assert.match(createViewTemplate, /class="review-side-risk-icon" :title="item\.levelLabel"/)
|
||||||
|
assert.match(createViewScript, /info:\s*\{[\s\S]*label:\s*'提示'/)
|
||||||
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
assert.match(createViewScript, /medium:\s*\{[\s\S]*label:\s*'中风险'/)
|
||||||
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
assert.match(createViewScript, /low:\s*\{[\s\S]*label:\s*'低风险'/)
|
||||||
|
assert.match(createViewScript, /const isInfo = String\(item\?\.level \|\| ''\)\.trim\(\) === 'info'/)
|
||||||
|
assert.match(createViewScript, /\$\{isInfo \? '提示内容' : '风险点'\}:\$\{summary\}/)
|
||||||
|
assert.match(createViewScript, /\$\{isInfo \? '处理建议' : '修改建议'\}:\$\{suggestion\}/)
|
||||||
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
|
assert.match(createViewScript, /function normalizeReviewRiskTitle/)
|
||||||
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
|
assert.match(createViewScript, /\.replace\(\/AI\\s\*预审/)
|
||||||
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
assert.match(createViewScript, /\.replace\(\/\(高风险\|中风险\|低风险\)\/g,\s*''\)/)
|
||||||
@@ -98,7 +280,8 @@ test('review risk drawer lists risk briefs without score and posts details into
|
|||||||
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
/function appendReviewRiskBriefToConversation\(item\) \{[\s\S]*messages\.value\.push\(createMessage\('assistant'/
|
||||||
)
|
)
|
||||||
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
|
assert.match(createViewScript, /function buildReviewRiskConversationText\(item, detailTarget = \{\}\)/)
|
||||||
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
|
assert.match(createViewScript, /function resolveReviewDetailTarget\(message = null\) \{[\s\S]*router\.resolve\(\{[\s\S]*name: 'app-request-detail'/)
|
||||||
|
assert.match(createViewScript, /function resolveReviewRiskDetailTarget\(\) \{[\s\S]*return resolveReviewDetailTarget\(\)/)
|
||||||
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
|
assert.match(createViewScript, /进入 \$\{claimNo\} 详情重新填写/)
|
||||||
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
|
assert.match(createViewTemplate, /class="expense-query-risk-row"[\s\S]*appendExpenseQueryRiskToConversation\(record, risk\)/)
|
||||||
assert.match(createViewScript, /function appendExpenseQueryRiskToConversation\(record, risk\) \{[\s\S]*进入 \$\{claimNo\} 详情重新填写/)
|
assert.match(createViewScript, /function appendExpenseQueryRiskToConversation\(record, risk\) \{[\s\S]*进入 \$\{claimNo\} 详情重新填写/)
|
||||||
|
|||||||
@@ -67,15 +67,15 @@ const attachmentMeta = {
|
|||||||
severity: 'high',
|
severity: 'high',
|
||||||
label: '高风险',
|
label: '高风险',
|
||||||
headline: '票据类型不匹配',
|
headline: '票据类型不匹配',
|
||||||
summary: '交通票据挂在办公费明细下。',
|
summary: '交通票据挂在办公用品费明细下。',
|
||||||
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公费'],
|
points: ['票据识别为出租车/网约车票据', '当前费用项目为办公用品费'],
|
||||||
suggestion: '把费用项目调整为交通费,或更换为办公用品票据。'
|
suggestion: '把费用项目调整为交通费,或更换为办公用品票据。'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test('attachment insight exposes recognition fields and rule basis', () => {
|
test('attachment insight exposes recognition fields and rule basis', () => {
|
||||||
const insight = buildAttachmentInsightViewModel(attachmentMeta, {
|
const insight = buildAttachmentInsightViewModel(attachmentMeta, {
|
||||||
name: '办公费',
|
name: '办公用品费',
|
||||||
itemType: 'office'
|
itemType: 'office'
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ test('AI advice card splits every attachment risk point with basis and suggestio
|
|||||||
expenseItems: [
|
expenseItems: [
|
||||||
{
|
{
|
||||||
id: 'item-1',
|
id: 'item-1',
|
||||||
name: '办公费',
|
name: '办公用品费',
|
||||||
invoiceId: 'taxi-invoice.pdf'
|
invoiceId: 'taxi-invoice.pdf'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -278,7 +278,7 @@ test('AI advice view model exposes grouped completion and risk sections', () =>
|
|||||||
tone: 'high',
|
tone: 'high',
|
||||||
label: '高风险',
|
label: '高风险',
|
||||||
title: '票据类型不匹配',
|
title: '票据类型不匹配',
|
||||||
risk: '交通票据挂在办公费明细下。',
|
risk: '交通票据挂在办公用品费明细下。',
|
||||||
ruleBasis: ['附件类型与当前费用项目不匹配。'],
|
ruleBasis: ['附件类型与当前费用项目不匹配。'],
|
||||||
suggestion: '把费用项目调整为交通费。'
|
suggestion: '把费用项目调整为交通费。'
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user