feat: 优化差旅报销预审流程与个人工作台 UI 体系

- 完善 user_agent_application 申请差旅报销预审槽位与消息组装
- 增强预算助理报告与风险建议卡片交互
- 重构登录页视觉样式与移动端响应式适配
- 优化个人工作台、文档中心、政策中心、员工管理等页面布局
- 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型
- 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-02 14:01:51 +08:00
parent 92444e7eae
commit ca691f3ee0
107 changed files with 5663 additions and 1542 deletions

View File

@@ -0,0 +1,137 @@
# 移动端适配概念文档
## 功能一句话
让手机浏览器打开 X-Financial Web 时具备可导航、可对话、控件完整可点的移动端体验。
## 背景与问题
本轮目标是 Web 在手机浏览器中的适配,不是 `mobile/app` 原生应用。
当前 Web 已经有部分移动端样式,例如侧边栏抽屉、移动遮罩和报销助手工作台的弹层基础样式,但仍有两个直接影响手机使用的问题:
- 应用壳层已有 `mobileSidebarOpen` 状态和 `.mobile-hamburger-btn` 样式,却缺少真正可见的手机导航按钮。
- 报销智能体使用 Element Plus `el-dialog` 打开全屏工作台,但手机宽度下仍保留弹窗式留边和双栏工作台逻辑,底部输入区把附件、日期、计算器、输入框和发送按钮挤在一行,容易展示不全。
## 目标与非目标
### 目标
- 手机浏览器下提供明确的 Web 导航入口。
- 智能体对话在手机视口下以全屏工作台呈现,不保留弹窗留边。
- 对话主区、洞察侧栏、底部输入区在手机上不互相挤压。
- 附件、日期、差旅计算器和发送控件在窄屏下完整展示。
- 侧栏洞察在手机上转为覆盖式面板,不占用主对话宽度。
- 保持 X-Financial 企业 SaaS 风格:白底、细边框、低饱和、直角控件。
### 非目标
- 本轮不改 `mobile/app` 原生应用。
- 本轮不重写所有 Web 业务页面为移动端卡片流。
- 本轮不调整后端接口、数据库和智能体业务协议。
- 本轮不改变报销助手的会话、附件、日期和差旅计算器业务逻辑。
## 用户与场景
- 员工、财务或审批人员在手机浏览器中临时打开 Web 工作台。
- 用户通过侧边栏进入单据、预算、票据夹或智能体助手。
- 用户在报销智能体中上传附件、选择业务日期、打开差旅计算器并发送问题。
## 功能能力
### 手机导航入口
输入:
- 手机浏览器打开 Web。
- 视口宽度小于等于 `760px`
输出:
- 页面右上角显示移动端导航按钮。
- 点击按钮打开侧边栏抽屉。
- 点击遮罩或导航项关闭侧边栏。
### 智能体全屏工作台
输入:
- 用户从工作台、单据、票据夹或预算中心打开报销智能体。
- 当前视口为手机宽度。
输出:
- `el-dialog` 使用 100dvh 全屏,占满手机浏览器可视区域。
- 工作台不再保留外边距和圆角弹窗感。
- 对话主面板独占宽度。
- 右侧洞察面板在打开时覆盖主对话,不挤压主对话宽度。
### 底部输入控件
输入:
- 用户添加附件、选择日期、打开差旅计算器或输入文本。
输出:
- 工具按钮在手机上独占一行,固定三列展示。
- 输入框和发送按钮在下一行展示。
- 日期与差旅计算器浮层改为固定底部浮层,宽度适配手机视口。
- 附件区域可滚动,避免把输入区挤出屏幕。
## 方案设计
### Web 壳层
-`AppShellRouteView.vue` 增加 `.mobile-hamburger-btn` 模板节点。
- 复用现有 `mobileSidebarOpen` 状态和遮罩关闭逻辑。
-`app.css` 中补齐按钮默认隐藏,手机媒体查询内显示。
### 报销智能体
- 保持 `TravelReimbursementCreateView.vue` 的业务结构不变。
-`travel-reimbursement-create-view-part4.css` 的手机断点中覆盖 Element Plus 弹层、工作台、布局和输入区样式。
- 手机断点下:
- overlay padding 设为 `0`
- 工作台 `height/min-height` 使用 `100dvh`
- `assistant-layout` 改为单列。
- `insight-panel-shell` 改为绝对定位覆盖面板。
- `composer-row` 改为两行网格布局。
## 算法与公式
当前功能不涉及显式数学公式。
核心断点规则:
```css
@media (max-width: 760px) {
/* phone browser layout */
}
```
## 测试方案
- 静态回归测试:`node --test web/tests/app-shell-mobile-browser.test.mjs`
- Web 构建:`npm.cmd --prefix web run build`
- 手机视口浏览器验证:
- 以 390x844 或相近视口打开 Web。
- 验证导航按钮可见且侧栏可打开。
- 打开报销智能体,验证工作台占满手机视口。
- 验证底部附件、日期、差旅计算器、输入框和发送按钮完整展示。
- 打开洞察面板,验证其覆盖展示而不是挤压主对话。
## 指标与验收
- `mobile/app` 无本轮改动。
- 手机浏览器下 Web 存在可点击导航入口。
- 报销智能体不再呈现带留边的弹窗效果。
- 底部输入工具控件不被挤出屏幕。
- 定向静态测试通过。
- Web 构建通过。
## 风险与开放问题
- 其他业务页面仍可能存在表格密度过高的问题,需要按页面继续做字段折叠或卡片化。
- 一些二级确认弹窗、票据预览和日期控件需要后续逐页检查。
- 手机浏览器地址栏收起/展开会改变视口高度,后续应继续用真实设备补充验证。

View File

@@ -0,0 +1,27 @@
# 移动端适配 TODO
## 调研与边界
- [x] 确认本轮范围是手机浏览器打开 Web不是 `mobile/app` 原生应用。证据:`CONCEPT.md` 已明确目标与非目标。[CONCEPT: 目标与非目标]
- [x] 梳理 Web 应用壳层移动端状态。证据:`AppShellRouteView.vue` 已有 `mobileSidebarOpen` 和遮罩,但缺少按钮节点。[CONCEPT: 手机导航入口]
- [x] 梳理报销智能体弹层结构。证据:`TravelReimbursementCreateView.vue` 使用 `el-dialog``assistant-layout``composer-row` 和洞察侧栏。[CONCEPT: 背景与问题]
## Web 实现
- [x] 在 Web 壳层补充手机导航按钮。证据:`AppShellRouteView.vue` 新增 `.mobile-hamburger-btn`,点击打开 `mobileSidebarOpen`。[CONCEPT: Web 壳层]
- [x] 补齐手机导航按钮默认隐藏与手机断点显示。证据:`app.css` 新增默认隐藏,`760px` 断点内显示按钮。[CONCEPT: Web 壳层]
- [x] 将报销智能体手机视口改为真正全屏。证据:`travel-reimbursement-create-view-part4.css` 覆盖 overlay padding、dialog 圆角和工作台 `100dvh`。[CONCEPT: 智能体全屏工作台]
- [x] 将手机端洞察侧栏改为覆盖式面板。证据:`insight-panel-shell` 在手机断点下使用绝对定位和 `translateX` 切换。[CONCEPT: 智能体全屏工作台]
- [x] 重排手机端底部输入区。证据:`composer-row` 改为两行网格,工具按钮独占一行,输入框和发送按钮在下一行。[CONCEPT: 底部输入控件]
- [x] 调整日期和差旅计算器浮层。证据:手机断点下浮层使用固定底部定位并限制最大高度。[CONCEPT: 底部输入控件]
## 测试与验证
- [x] 运行 `node --test web/tests/app-shell-mobile-browser.test.mjs`。证据2 个测试通过。[CONCEPT: 测试方案]
- [x] 运行 `npm.cmd --prefix web run build`。证据:构建通过,保留既有 VueUse 注释和 chunk 体积 warning。[CONCEPT: 测试方案]
- [ ] 使用手机视口打开 Web验证导航、智能体全屏、底部控件完整展示和洞察覆盖面板。[CONCEPT: 测试方案]
## 后续增强
- [ ] 继续盘点高频表格页面的手机浏览器阅读体验。[CONCEPT: 风险与开放问题]
- [ ] 逐页检查二级确认弹窗、票据预览、日期选择和复杂筛选在手机浏览器里的表现。[CONCEPT: 风险与开放问题]

View File

@@ -703,7 +703,7 @@ def pay_expense_claim(
"/claims/{claim_id}", "/claims/{claim_id}",
response_model=ExpenseClaimActionResponse, response_model=ExpenseClaimActionResponse,
summary="删除报销单", summary="删除报销单",
description="申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档单;已归档单据仅高级管理员可删除,财务人员没有删除权限。", description="申请单仅系统管理员可删除;报销单申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。",
responses={ responses={
status.HTTP_404_NOT_FOUND: { status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse, "model": ErrorResponse,
@@ -725,8 +725,11 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser
if claim is None: if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found") raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
claim_no = str(claim.claim_no or "").strip()
expense_type = str(claim.expense_type or "").strip().lower()
document_label = "申请单" if claim_no.upper().startswith(("AP-", "APP-")) or expense_type.endswith("_application") else "报销单"
return ExpenseClaimActionResponse( return ExpenseClaimActionResponse(
message=f"{claim.claim_no} 报销单已删除。", message=f"{claim.claim_no} {document_label}已删除。",
claim_id=claim.id, claim_id=claim.id,
status="deleted", status="deleted",
) )

View File

@@ -27,6 +27,7 @@ EXPENSE_TYPE_LABELS = {
MAX_DRAFT_CLAIMS_PER_USER = 3 MAX_DRAFT_CLAIMS_PER_USER = 3
EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned") EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned")
SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"} SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"}
OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"}
TRAVEL_DETAIL_ITEM_TYPES = { TRAVEL_DETAIL_ITEM_TYPES = {
"train_ticket", "train_ticket",
"flight_ticket", "flight_ticket",

View File

@@ -307,6 +307,13 @@ class ExpenseClaimDraftFlowMixin:
claim.risk_flags_json = final_risk_flags claim.risk_flags_json = final_risk_flags
self.db.flush() self.db.flush()
skip_primary_item = self._should_skip_application_link_placeholder_item(
claim=claim,
context_json=context_json,
document_specs=document_specs,
attachment_count=attachment_count,
amount=amount,
)
if document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS): if document_specs and (is_new_claim or review_action in DOCUMENT_ASSOCIATION_REVIEW_ACTIONS):
if review_action == "link_to_existing_draft" and claim.items: if review_action == "link_to_existing_draft" and claim.items:
self._append_document_items( self._append_document_items(
@@ -319,6 +326,8 @@ class ExpenseClaimDraftFlowMixin:
item_specs=document_specs, item_specs=document_specs,
) )
self._sync_claim_from_items(claim) self._sync_claim_from_items(claim)
elif skip_primary_item:
self._sync_application_link_draft_without_items(claim)
else: else:
self._upsert_primary_item( self._upsert_primary_item(
claim=claim, claim=claim,
@@ -379,6 +388,66 @@ class ExpenseClaimDraftFlowMixin:
"invoice_count": int(claim.invoice_count or 0), "invoice_count": int(claim.invoice_count or 0),
} }
def _sync_application_link_draft_without_items(self, claim: ExpenseClaim) -> None:
claim.amount = Decimal("0.00")
claim.invoice_count = 0
claim.risk_flags_json = self._merge_claim_attachment_risk_flags(claim, [])
claim.risk_flags_json = self._merge_claim_platform_risk_preview_flags(claim, [])
def _should_skip_application_link_placeholder_item(
self,
*,
claim: ExpenseClaim | None,
context_json: dict[str, Any],
document_specs: list[dict[str, Any]],
attachment_count: int,
amount: Decimal | None,
) -> bool:
if document_specs or attachment_count > 0:
return False
if claim is not None and list(claim.items or []):
return False
if self._build_application_link_flag(context_json) is None:
return False
application_amounts = self._resolve_application_amount_candidates(context_json)
review_values = self._normalize_context_object(context_json.get("review_form_values"))
raw_amount = str(review_values.get("amount") or "").strip()
if raw_amount:
parsed_amount = self._parse_context_money_amount(raw_amount)
if parsed_amount is None:
return True
return bool(application_amounts and parsed_amount in application_amounts)
if amount is None or amount <= Decimal("0.00"):
return True
return bool(application_amounts and amount in application_amounts)
@classmethod
def _resolve_application_amount_candidates(cls, context_json: dict[str, Any]) -> set[Decimal]:
review_values = cls._normalize_context_object(context_json.get("review_form_values"))
scene_selection = cls._normalize_context_object(context_json.get("expense_scene_selection"))
candidates: set[Decimal] = set()
for source in (review_values, scene_selection, context_json):
for key in ("application_amount", "application_amount_label", "applicationAmount", "applicationAmountLabel"):
parsed = cls._parse_context_money_amount(source.get(key))
if parsed is not None:
candidates.add(parsed)
return candidates
@staticmethod
def _parse_context_money_amount(value: Any) -> Decimal | None:
raw_value = str(value or "").strip()
if not raw_value:
return None
compact = re.sub(r"[^\d.\-]", "", raw_value.replace(",", ""))
if not compact or compact in {"-", ".", "-."}:
return None
try:
return Decimal(compact).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
@staticmethod @staticmethod
def _merge_application_link_flag( def _merge_application_link_flag(
risk_flags: list[Any], risk_flags: list[Any],

View File

@@ -23,6 +23,7 @@ from app.services.expense_claim_constants import (
AI_REVIEW_REPEAT_RISK_WARNING_COUNT, AI_REVIEW_REPEAT_RISK_WARNING_COUNT,
DOCUMENT_FACT_ITEM_TYPES, DOCUMENT_FACT_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES, LOCATION_REQUIRED_EXPENSE_TYPES,
OPTIONAL_ATTACHMENT_ITEM_TYPES,
SYSTEM_GENERATED_ITEM_TYPES, SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
@@ -399,6 +400,20 @@ class ExpenseClaimItemSyncMixin:
return 1 return 1
return max(0, int(policy.min_attachment_count or 0)) return max(0, int(policy.min_attachment_count or 0))
@staticmethod
def _is_attachment_required_item_type(item_type: str | None) -> bool:
normalized = str(item_type or "").strip().lower()
return normalized not in SYSTEM_GENERATED_ITEM_TYPES and normalized not in OPTIONAL_ATTACHMENT_ITEM_TYPES
def _resolve_claim_required_attachment_count(self, claim: ExpenseClaim) -> int:
required_items = [
item for item in list(claim.items or [])
if self._is_attachment_required_item_type(item.item_type)
]
if not required_items:
return 0
return min(self._resolve_min_attachment_count(claim.expense_type), len(required_items))
def _build_scene_reason_corpus(self, claim: ExpenseClaim) -> str: def _build_scene_reason_corpus(self, claim: ExpenseClaim) -> str:
parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()] parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()]
for item in claim.items: for item in claim.items:
@@ -454,16 +469,16 @@ class ExpenseClaimItemSyncMixin:
def _format_submission_blocked_message(issues: list[str]) -> str: def _format_submission_blocked_message(issues: list[str]) -> str:
normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()] normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()]
if not normalized_issues: if not normalized_issues:
return "AI预审未通过,但没有返回明确原因,请刷新草稿后重试。" return "自动检测未通过,但没有返回明确原因,请刷新草稿后重试。"
return "AI预审暂未通过,原因如下:\n" + "\n".join( return "自动检测暂未通过,原因如下:\n" + "\n".join(
f"{index}. {issue}" for index, issue in enumerate(normalized_issues, start=1) f"{index}. {issue}" for index, issue in enumerate(normalized_issues, start=1)
) )
def _validate_claim_for_submission(self, claim: ExpenseClaim) -> list[str]: def _validate_claim_for_submission(self, claim: ExpenseClaim) -> list[str]:
issues: list[str] = [] issues: list[str] = []
claim_location_required = self._is_location_required_expense_type(claim.expense_type) claim_location_required = self._is_location_required_expense_type(claim.expense_type)
claim_min_attachment_count = self._resolve_min_attachment_count(claim.expense_type) claim_min_attachment_count = self._resolve_claim_required_attachment_count(claim)
if self._is_missing_value(claim.employee_name): if self._is_missing_value(claim.employee_name):
issues.append("申请人未完善") issues.append("申请人未完善")
@@ -498,7 +513,7 @@ class ExpenseClaimItemSyncMixin:
issues.append(f"{prefix}缺少地点") issues.append(f"{prefix}缺少地点")
if item.item_amount is None or item.item_amount <= Decimal("0.00"): if item.item_amount is None or item.item_amount <= Decimal("0.00"):
issues.append(f"{prefix}缺少金额") issues.append(f"{prefix}缺少金额")
if not is_system_generated and self._is_missing_value(item.invoice_id): if self._is_attachment_required_item_type(item.item_type) and self._is_missing_value(item.invoice_id):
issues.append(f"{prefix}缺少票据标识") issues.append(f"{prefix}缺少票据标识")
return issues return issues

View File

@@ -68,7 +68,7 @@ class ExpenseClaimPreReviewMixin:
), ),
), ),
) )
claim.approval_stage = "AI预审" if not is_application_claim else claim.approval_stage claim.approval_stage = "待提交" if not is_application_claim else claim.approval_stage
claim.submitted_at = None claim.submitted_at = None
self.db.commit() self.db.commit()
@@ -105,16 +105,16 @@ class ExpenseClaimPreReviewMixin:
business_stage: str, business_stage: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
if passed: if passed:
message = "AI预审通过,费用明细和附件可进入下一步提交审批。" message = "自动检测通过,费用明细和附件可提交审批。"
else: else:
message = f"AI预审发现 {blocking_count} 条重大风险,请逐条填写原因后再进入下一步" message = f"自动检测发现 {blocking_count} 条重大风险,请逐条填写原因后再提交审批"
return with_risk_business_stage( return with_risk_business_stage(
{ {
"source": "ai_pre_review", "source": "ai_pre_review",
"event_type": "expense_claim_ai_pre_review", "event_type": "expense_claim_ai_pre_review",
"severity": "info" if passed else "high", "severity": "info" if passed else "high",
"label": "AI预审通过" if passed else "AI预审未通过", "label": "自动检测通过" if passed else "自动检测未通过",
"message": message, "message": message,
"status": "passed" if passed else "failed", "status": "passed" if passed else "failed",
"passed": passed, "passed": passed,

View File

@@ -198,7 +198,7 @@ class ExpenseClaimReviewPreviewMixin:
if review_message: if review_message:
break break
return { return {
"message": review_message or f"报销单 {claim.claim_no} AI预审后转为待补充,请先修正后再提交。", "message": review_message or f"报销单 {claim.claim_no}自动检测后转为待补充,请先修正后再提交。",
"submission_blocked": True, "submission_blocked": True,
"draft_only": False, "draft_only": False,
"claim_id": claim.id, "claim_id": claim.id,
@@ -211,7 +211,7 @@ class ExpenseClaimReviewPreviewMixin:
return { return {
"message": ( "message": (
f"报销单 {claim.claim_no} 已完成 AI预审" f"报销单 {claim.claim_no} 已完成自动检测"
f"当前节点为 {claim.approval_stage or '审批中'}" f"当前节点为 {claim.approval_stage or '审批中'}"
), ),
"draft_only": False, "draft_only": False,

View File

@@ -62,9 +62,9 @@ class ExpenseClaimRiskReviewMixin(
{ {
"source": "submission_review", "source": "submission_review",
"severity": "high", "severity": "high",
"label": "AI预审重点复核", "label": "自动检测重点复核",
"message": ( "message": (
f"AI预审发现 {len(high_attachment_flags)} 条高风险附件," f"自动检测发现 {len(high_attachment_flags)} 条高风险附件,"
"已随单流转给审批人重点复核。" "已随单流转给审批人重点复核。"
), ),
} }
@@ -74,9 +74,9 @@ class ExpenseClaimRiskReviewMixin(
{ {
"source": "submission_review", "source": "submission_review",
"severity": "medium", "severity": "medium",
"label": "AI预审提醒", "label": "自动检测提醒",
"message": ( "message": (
f"AI预审发现 {len(medium_attachment_flags)} 条中风险附件," f"自动检测发现 {len(medium_attachment_flags)} 条中风险附件,"
"已随单流转给审批人复核。" "已随单流转给审批人复核。"
), ),
} }
@@ -90,7 +90,7 @@ class ExpenseClaimRiskReviewMixin(
"source": "submission_review", "source": "submission_review",
"severity": "medium", "severity": "medium",
"label": "审批链待分配", "label": "审批链待分配",
"message": "AI预审发现直属领导缺失,已提交到审批环节等待分配或复核。", "message": "自动检测发现直属领导缺失,已提交到审批环节等待分配或复核。",
} }
) )
@@ -147,7 +147,7 @@ class ExpenseClaimRiskReviewMixin(
) )
if attention_reasons: if attention_reasons:
summary_message = "AI预审发现需审批重点关注事项:" + "".join( summary_message = "自动检测发现需审批重点关注事项:" + "".join(
dict.fromkeys(attention_reasons) dict.fromkeys(attention_reasons)
) )
review_flags.insert( review_flags.insert(
@@ -155,7 +155,7 @@ class ExpenseClaimRiskReviewMixin(
{ {
"source": "submission_review", "source": "submission_review",
"severity": "medium", "severity": "medium",
"label": "AI预审重点复核", "label": "自动检测重点复核",
"message": summary_message, "message": summary_message,
}, },
) )
@@ -167,7 +167,7 @@ class ExpenseClaimRiskReviewMixin(
"approval_stage": "直属领导审批", "approval_stage": "直属领导审批",
"risk_flags": preserved_flags + review_flags, "risk_flags": preserved_flags + review_flags,
"message": ( "message": (
f"报销单 {claim.claim_no} 已完成 AI预审" f"报销单 {claim.claim_no} 已完成自动检测"
f"现已提交给直属领导 {manager_name or '审批人'} 审批。" f"现已提交给直属领导 {manager_name or '审批人'} 审批。"
), ),
"passed": True, "passed": True,

View File

@@ -11,7 +11,7 @@ from pathlib import Path
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
from sqlalchemy import func, or_, select from sqlalchemy import delete, func, or_, select
from sqlalchemy import inspect as sqlalchemy_inspect from sqlalchemy import inspect as sqlalchemy_inspect
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, selectinload from sqlalchemy.orm import Session, selectinload
@@ -21,6 +21,8 @@ from app.core.agent_enums import AgentAssetDomain, AgentAssetStatus, AgentAssetT
from app.models.agent_asset import AgentAsset from app.models.agent_asset import AgentAsset
from app.models.employee import Employee from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.hermes_report import HermesRiskReport
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.schemas.ontology import OntologyEntity, OntologyParseResult from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import ( from app.schemas.reimbursement import (
ExpenseClaimItemCreate, ExpenseClaimItemCreate,
@@ -560,6 +562,9 @@ class ExpenseClaimService(
if claim is None: if claim is None:
return None return None
if self._is_expense_application_claim(claim) and not current_user.is_admin:
raise ValueError("申请单只有系统管理员可以删除。")
if self._access_policy.is_archived_claim(claim) and not current_user.is_admin: if self._access_policy.is_archived_claim(claim) and not current_user.is_admin:
raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。") raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。")
@@ -572,6 +577,7 @@ class ExpenseClaimService(
resource_id = claim.id resource_id = claim.id
self._release_budget_for_delete(claim, current_user) self._release_budget_for_delete(claim, current_user)
self._delete_claim_analysis_records(resource_id)
self._attachment_storage.delete_claim_files(claim) self._attachment_storage.delete_claim_files(claim)
self.db.delete(claim) self.db.delete(claim)
self.db.commit() self.db.commit()
@@ -588,6 +594,16 @@ class ExpenseClaimService(
return claim return claim
def _delete_claim_analysis_records(self, claim_id: str) -> None:
observation_ids = select(RiskObservation.id).where(RiskObservation.claim_id == claim_id)
self.db.execute(
delete(RiskObservationFeedback).where(
RiskObservationFeedback.observation_id.in_(observation_ids)
)
)
self.db.execute(delete(RiskObservation).where(RiskObservation.claim_id == claim_id))
self.db.execute(delete(HermesRiskReport).where(HermesRiskReport.claim_id == claim_id))
def return_claim( def return_claim(
self, self,
claim_id: str, claim_id: str,
@@ -740,8 +756,6 @@ class ExpenseClaimService(

View File

@@ -14,6 +14,7 @@ CITY_CONSISTENCY_SEMANTIC_TYPES = {
"travel_city_consistency", "travel_city_consistency",
"travel_route_city_consistency", "travel_route_city_consistency",
} }
ROUTE_CITY_SPLIT_PATTERN = re.compile(r"\s*(?:至|到|→|->||-|—|~||/|、||,|;|)\s*")
class RiskRuleTemplateExecutor: class RiskRuleTemplateExecutor:
@@ -612,19 +613,32 @@ class RiskRuleTemplateExecutor:
) -> list[str]: ) -> list[str]:
if len(route_values) < 2: if len(route_values) < 2:
return [] return []
allowed = {value.lower() for value in [*reference_values, *home_values] if value} allowed_values = [value for value in [*reference_values, *home_values] if value]
if not allowed: if not allowed_values:
return [] return []
candidates = route_values if home_values else route_values[1:-1] candidates = route_values if home_values else route_values[1:-1]
unexpected: list[str] = [] unexpected: list[str] = []
for city in candidates: for city in candidates:
normalized = city.lower() if RiskRuleTemplateExecutor._values_overlap([city], allowed_values):
if normalized in allowed:
continue continue
if city not in unexpected: if city not in unexpected:
unexpected.append(city) unexpected.append(city)
return unexpected return unexpected
@staticmethod
def _expand_route_city_values(values: list[Any]) -> list[Any]:
expanded: list[Any] = []
for value in values:
if isinstance(value, (list, tuple, set)):
expanded.extend(RiskRuleTemplateExecutor._expand_route_city_values(list(value)))
continue
text = str(value or "").strip()
if not text:
continue
parts = [part.strip() for part in ROUTE_CITY_SPLIT_PATTERN.split(text) if part.strip()]
expanded.extend(parts if len(parts) >= 2 else [text])
return expanded
def _resolve_attachment_values( def _resolve_attachment_values(
self, field_key: str, contexts: list[dict[str, Any]] self, field_key: str, contexts: list[dict[str, Any]]
) -> list[str]: ) -> list[str]:
@@ -643,7 +657,7 @@ class RiskRuleTemplateExecutor:
else self._scan_document_values(document_info, "city") else self._scan_document_values(document_info, "city")
) )
elif field_key == "route_cities": elif field_key == "route_cities":
values.extend(self._scan_document_values(document_info, field_key)) values.extend(self._expand_route_city_values(self._scan_document_values(document_info, field_key)))
else: else:
values.extend(self._scan_document_values(document_info, field_key)) values.extend(self._scan_document_values(document_info, field_key))
return self._normalize_values(values) return self._normalize_values(values)
@@ -878,9 +892,9 @@ class RiskRuleTemplateExecutor:
left_set = {value.lower() for value in left_values} left_set = {value.lower() for value in left_values}
right_set = {value.lower() for value in right_values} right_set = {value.lower() for value in right_values}
if operator in {"equals", "in", "overlap"}: if operator in {"equals", "in", "overlap"}:
return bool(left_set & right_set) return RiskRuleTemplateExecutor._values_overlap(left_values, right_values)
if operator in {"not_equals", "not_in", "not_overlap"}: if operator in {"not_equals", "not_in", "not_overlap"}:
return not bool(left_set & right_set) return not RiskRuleTemplateExecutor._values_overlap(left_values, right_values)
if operator == "contains_any": if operator == "contains_any":
return any(any(right in left for right in right_set) for left in left_set) return any(any(right in left for right in right_set) for left in left_set)
return bool(left_set & right_set) return bool(left_set & right_set)

View File

@@ -4,7 +4,7 @@ import re
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from sqlalchemy import select from sqlalchemy import or_, select
from app.api.deps import CurrentUserContext from app.api.deps import CurrentUserContext
from app.models.financial_record import ExpenseClaim from app.models.financial_record import ExpenseClaim
@@ -20,7 +20,10 @@ from app.services.document_numbering import (
build_document_number, build_document_number,
generate_unique_expense_claim_no, generate_unique_expense_claim_no,
) )
from app.services.user_agent_application_dates import expand_application_time_with_days from app.services.user_agent_application_dates import (
expand_application_time_with_days,
resolve_application_days_from_time_range,
)
from app.services.user_agent_application_locations import normalize_application_location from app.services.user_agent_application_locations import normalize_application_location
from app.services.application_system_estimate import apply_application_system_estimate_to_facts from app.services.application_system_estimate import apply_application_system_estimate_to_facts
@@ -32,6 +35,43 @@ APPLICATION_CONTEXT_VALUES = {
"preapproval", "preapproval",
} }
APPLICATION_BASE_FIELDS = ("time", "location", "reason") APPLICATION_BASE_FIELDS = ("time", "location", "reason")
APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间")
APPLICATION_FIELD_LABELS = (
"申请类型",
"费用类型",
"姓名",
"申请人",
"部门",
"岗位",
"职级",
"直属领导",
*APPLICATION_TIME_LABELS,
"地点",
"业务地点",
"发生地点",
"目的地",
"事由",
"申请事由",
"出差事由",
"原因",
"用途",
"天数",
"出差天数",
"申请天数",
"出行方式",
"交通方式",
"交通工具",
"出行工具",
"用户预估费用",
"预估费用",
"预计总费用",
"预计费用",
"预计金额",
"申请金额",
"预算",
"金额",
"费用",
)
APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船") APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船")
APPLICATION_TRANSPORT_KEYWORDS = { APPLICATION_TRANSPORT_KEYWORDS = {
"飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"), "飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"),
@@ -64,6 +104,18 @@ APPLICATION_SUBMIT_KEYWORDS = (
"直接提交", "直接提交",
) )
APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "", "好的", "可以", "没问题"} APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "", "好的", "可以", "没问题"}
APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "", "null", "none"}
APPLICATION_DUPLICATE_IGNORED_STATUSES = {
"cancelled",
"canceled",
"void",
"voided",
"deleted",
"已取消",
"已作废",
"作废",
"已删除",
}
class UserAgentApplicationMixin: class UserAgentApplicationMixin:
@@ -119,7 +171,12 @@ class UserAgentApplicationMixin:
step = self._resolve_expense_application_step(payload, facts) step = self._resolve_expense_application_step(payload, facts)
application_claim = None application_claim = None
if step == "submitted": if step == "submitted":
application_claim = self._create_expense_application_record(payload, facts) application_claim = self._find_duplicate_expense_application_record(payload, facts)
if application_claim is not None:
step = "duplicate"
facts["duplicate_application_stage"] = str(application_claim.approval_stage or "").strip()
else:
application_claim = self._create_expense_application_record(payload, facts)
facts["application_no"] = application_claim.claim_no facts["application_no"] = application_claim.claim_no
facts["application_claim_id"] = application_claim.id facts["application_claim_id"] = application_claim.id
facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim) facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim)
@@ -128,7 +185,11 @@ class UserAgentApplicationMixin:
citations=[], citations=[],
suggested_actions=self._build_expense_application_actions(step, facts), suggested_actions=self._build_expense_application_actions(step, facts),
query_payload=None, query_payload=None,
draft_payload=self._build_submitted_application_payload(application_claim, facts), draft_payload=(
self._build_submitted_application_payload(application_claim, facts)
if step == "submitted"
else None
),
review_payload=None, review_payload=None,
risk_flags=risk_flags, risk_flags=risk_flags,
requires_confirmation=step == "preview", requires_confirmation=step == "preview",
@@ -170,6 +231,19 @@ class UserAgentApplicationMixin:
] ]
) )
if step == "duplicate":
application_no = str(facts.get("application_no") or "").strip()
stage = str(facts.get("duplicate_application_stage") or "").strip() or "处理中"
time_label = self._resolve_application_time_label(facts)
return "\n\n".join(
[
f"检测到同一申请人、同一申请类型、同一{time_label}已存在申请单,系统没有重复创建。",
f"已有申请单号:{application_no}",
f"当前节点:{stage}",
"如需继续处理,请在单据中心查看该申请;如果本次业务时间不同,请先调整时间后再提交。",
]
)
return "\n\n".join( return "\n\n".join(
[ [
"这是费用申请核对结果,请核对:", "这是费用申请核对结果,请核对:",
@@ -225,13 +299,27 @@ class UserAgentApplicationMixin:
facts[key] = value facts[key] = value
context_json = payload.context_json or {} context_json = payload.context_json or {}
current_user = getattr(payload, "current_user", None) context_time = self._resolve_application_time_from_context(context_json)
if context_time and self._should_prefer_context_application_time(facts.get("time", ""), context_time):
facts["time"] = context_time
current_user = self._build_application_current_user(payload)
employee = ExpenseClaimAccessPolicy(self.db).resolve_current_employee(current_user)
if not facts["applicant"]: if not facts["applicant"]:
facts["applicant"] = str( facts["applicant"] = str(
context_json.get("name") context_json.get("name")
or context_json.get("user_name") or context_json.get("user_name")
or context_json.get("applicant") or context_json.get("applicant")
or getattr(current_user, "name", "") or (employee.name if employee is not None else "")
or current_user.name
or ""
).strip()
if not facts["grade"]:
facts["grade"] = str(
context_json.get("grade")
or context_json.get("employee_grade")
or context_json.get("employeeGrade")
or current_user.grade
or (employee.grade if employee is not None else "")
or "" or ""
).strip() ).strip()
if not facts["department"]: if not facts["department"]:
@@ -239,7 +327,12 @@ class UserAgentApplicationMixin:
context_json.get("department") context_json.get("department")
or context_json.get("department_name") or context_json.get("department_name")
or context_json.get("departmentName") or context_json.get("departmentName")
or getattr(current_user, "department_name", "") or current_user.department_name
or (
employee.organization_unit.name
if employee is not None and employee.organization_unit is not None
else ""
)
or "" or ""
).strip() ).strip()
if not facts["position"]: if not facts["position"]:
@@ -247,6 +340,8 @@ class UserAgentApplicationMixin:
context_json.get("position") context_json.get("position")
or context_json.get("employee_position") or context_json.get("employee_position")
or context_json.get("employeePosition") or context_json.get("employeePosition")
or current_user.position
or (employee.position if employee is not None else "")
or "" or ""
).strip() ).strip()
if not facts["manager_name"]: if not facts["manager_name"]:
@@ -255,7 +350,17 @@ class UserAgentApplicationMixin:
or context_json.get("managerName") or context_json.get("managerName")
or context_json.get("direct_manager_name") or context_json.get("direct_manager_name")
or context_json.get("directManagerName") or context_json.get("directManagerName")
or getattr(current_user, "manager_name", "") or current_user.manager_name
or (
employee.manager.name
if employee is not None and employee.manager is not None
else ""
)
or (
employee.organization_unit.manager_name
if employee is not None and employee.organization_unit is not None
else ""
)
or "" or ""
).strip() ).strip()
@@ -266,6 +371,10 @@ class UserAgentApplicationMixin:
facts.get("days", ""), facts.get("days", ""),
payload.context_json or {}, payload.context_json or {},
) )
if self._is_application_missing_value(facts.get("days", "")):
range_days = resolve_application_days_from_time_range(facts.get("time", ""))
if range_days:
facts["days"] = f"{range_days}"
apply_application_system_estimate_to_facts(facts) apply_application_system_estimate_to_facts(facts)
return facts return facts
@@ -285,11 +394,12 @@ class UserAgentApplicationMixin:
return value return value
return "" return ""
reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason"))
return { return {
"application_type": pick("applicationType", "application_type"), "application_type": pick("applicationType", "application_type"),
"time": pick("time", "timeRange", "time_range"), "time": pick("time", "timeRange", "time_range"),
"location": pick("location"), "location": pick("location"),
"reason": pick("reason"), "reason": reason,
"days": pick("days"), "days": pick("days"),
"transport_mode": pick("transportMode", "transport_mode"), "transport_mode": pick("transportMode", "transport_mode"),
"amount": pick("amount"), "amount": pick("amount"),
@@ -313,6 +423,10 @@ class UserAgentApplicationMixin:
"policy_total_amount": pick("policyTotalAmount", "policy_total_amount"), "policy_total_amount": pick("policyTotalAmount", "policy_total_amount"),
} }
@staticmethod
def _is_application_missing_value(value: object) -> bool:
return str(value or "").strip().lower() in APPLICATION_MISSING_VALUES
def _resolve_expense_application_step( def _resolve_expense_application_step(
self, self,
payload: UserAgentRequest, payload: UserAgentRequest,
@@ -384,10 +498,16 @@ class UserAgentApplicationMixin:
def _resolve_application_time_from_text(message: str) -> str: def _resolve_application_time_from_text(message: str) -> str:
labeled = UserAgentApplicationMixin._resolve_application_labeled_value( labeled = UserAgentApplicationMixin._resolve_application_labeled_value(
message, message,
("发生时间", "业务发生时间", "申请时间", "时间"), APPLICATION_TIME_LABELS,
) )
if labeled: if labeled:
return labeled return labeled
range_match = re.search(
r"(?P<start>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)\s*(?:至|到|~|—||--)\s*(?P<end>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
str(message or ""),
)
if range_match:
return f"{range_match.group('start').rstrip('')}{range_match.group('end').rstrip('')}"
match = re.search( match = re.search(
r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", r"(?P<date>20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)",
str(message or ""), str(message or ""),
@@ -406,11 +526,26 @@ class UserAgentApplicationMixin:
return start_date if start_date == end_date else f"{start_date}{end_date}" return start_date if start_date == end_date else f"{start_date}{end_date}"
return display_value return display_value
@staticmethod
def _should_prefer_context_application_time(current_time: str, context_time: str) -> bool:
current = str(current_time or "").strip()
context = str(context_time or "").strip()
if not context:
return False
if not current:
return True
if "" not in context:
return False
current_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", current)
context_dates = re.findall(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", context)
return len(current_dates) <= 1 and len(context_dates) >= 2 and current_dates[:1] == context_dates[:1]
@staticmethod @staticmethod
def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str: def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str:
label_pattern = "|".join(re.escape(label) for label in labels) label_pattern = "|".join(re.escape(label) for label in labels)
next_label_pattern = "|".join(re.escape(label) for label in APPLICATION_FIELD_LABELS)
match = re.search( match = re.search(
rf"(?:{label_pattern})[:]\s*(?P<value>[^\n;]+)", rf"(?:{label_pattern})[:]\s*(?P<value>[\s\S]*?)(?=\s*(?:{next_label_pattern})[:]|[\n;]|$)",
str(message or ""), str(message or ""),
) )
return match.group("value").strip() if match else "" return match.group("value").strip() if match else ""
@@ -478,7 +613,7 @@ class UserAgentApplicationMixin:
("事由", "申请事由", "出差事由", "原因", "用途"), ("事由", "申请事由", "出差事由", "原因", "用途"),
) )
if labeled: if labeled:
return labeled return UserAgentApplicationMixin._cleanup_application_reason_candidate(labeled)
text = str(message or "").strip() text = str(message or "").strip()
if not text: if not text:
@@ -492,7 +627,15 @@ class UserAgentApplicationMixin:
if not candidates: if not candidates:
return "" return ""
return max(candidates, key=len) business_candidate = next(
(
candidate
for candidate in candidates
if any(keyword in candidate for keyword in APPLICATION_REASON_VERBS)
),
"",
)
return business_candidate or max(candidates, key=len)
@staticmethod @staticmethod
def _cleanup_application_reason_candidate(segment: str) -> str: def _cleanup_application_reason_candidate(segment: str) -> str:
@@ -501,10 +644,12 @@ class UserAgentApplicationMixin:
return "" return ""
text = re.sub( text = re.sub(
r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)[:]\s*", r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[:]\s*",
"", "",
text, text,
) )
if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?\s*(?:至|到|~|—||--)\s*20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
return ""
if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text): if re.fullmatch(r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?", text):
return "" return ""
if re.fullmatch(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", text): if re.fullmatch(r"(?P<days>\d+|[一二两三四五六七八九十]{1,3})\s*天", text):
@@ -617,8 +762,8 @@ class UserAgentApplicationMixin:
return { return {
"expense_type": "申请类型", "expense_type": "申请类型",
"amount": "系统预估费用", "amount": "系统预估费用",
"time_range": "发生时间", "time_range": "申请时间",
"time": "发生时间", "time": "申请时间",
"location": "地点", "location": "地点",
"reason": "申请事由", "reason": "申请事由",
"days": "天数", "days": "天数",
@@ -656,7 +801,7 @@ class UserAgentApplicationMixin:
@staticmethod @staticmethod
def _resolve_application_prefill_config(field: str) -> tuple[str, str]: def _resolve_application_prefill_config(field: str) -> tuple[str, str]:
config = { config = {
"time": ("补充发生时间", "申请时间段:"), "time": ("补充申请时间", "申请时间段:"),
"location": ("补充地点", "地点:"), "location": ("补充地点", "地点:"),
"reason": ("补充申请事由", "事由:"), "reason": ("补充申请事由", "事由:"),
"days": ("补充天数", "天数:"), "days": ("补充天数", "天数:"),
@@ -699,7 +844,17 @@ class UserAgentApplicationMixin:
return "差旅费用申请" return "差旅费用申请"
@staticmethod @staticmethod
def _build_application_summary(facts: dict[str, str]) -> str: def _resolve_application_time_label(facts: dict[str, str]) -> str:
application_type = str(facts.get("application_type") or "").strip()
if "差旅" in application_type or "出差" in application_type:
return "行程时间"
if "招待" in application_type or "宴请" in application_type or "餐饮" in application_type:
return "招待时间"
return "申请时间"
@classmethod
def _build_application_summary(cls, facts: dict[str, str]) -> str:
time_label = cls._resolve_application_time_label(facts)
return "\n".join( return "\n".join(
f"{label}{value or '待补充'}" f"{label}{value or '待补充'}"
for label, value in ( for label, value in (
@@ -709,7 +864,7 @@ class UserAgentApplicationMixin:
("岗位", facts.get("position", "")), ("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")), ("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")), ("直属领导", facts.get("manager_name", "")),
("发生时间", facts.get("time", "")), (time_label, facts.get("time", "")),
("地点", facts.get("location", "")), ("地点", facts.get("location", "")),
("事由", facts.get("reason", "")), ("事由", facts.get("reason", "")),
("天数", facts.get("days", "")), ("天数", facts.get("days", "")),
@@ -722,12 +877,14 @@ class UserAgentApplicationMixin:
) )
) )
@staticmethod @classmethod
def _build_application_summary_table( def _build_application_summary_table(
cls,
facts: dict[str, str], facts: dict[str, str],
*, *,
include_empty: bool = True, include_empty: bool = True,
) -> str: ) -> str:
time_label = cls._resolve_application_time_label(facts)
rows = [ rows = [
("申请类型", facts.get("application_type", "")), ("申请类型", facts.get("application_type", "")),
("姓名", facts.get("applicant", "")), ("姓名", facts.get("applicant", "")),
@@ -735,7 +892,7 @@ class UserAgentApplicationMixin:
("岗位", facts.get("position", "")), ("岗位", facts.get("position", "")),
("职级", facts.get("grade", "")), ("职级", facts.get("grade", "")),
("直属领导", facts.get("manager_name", "")), ("直属领导", facts.get("manager_name", "")),
("发生时间", facts.get("time", "")), (time_label, facts.get("time", "")),
("地点", facts.get("location", "")), ("地点", facts.get("location", "")),
("事由", facts.get("reason", "")), ("事由", facts.get("reason", "")),
("天数", facts.get("days", "")), ("天数", facts.get("days", "")),
@@ -816,6 +973,90 @@ class UserAgentApplicationMixin:
self.db.refresh(claim) self.db.refresh(claim)
return claim return claim
def _find_duplicate_expense_application_record(
self,
payload: UserAgentRequest,
facts: dict[str, str],
) -> ExpenseClaim | None:
current_user = self._build_application_current_user(payload)
access_policy = ExpenseClaimAccessPolicy(self.db)
employee = access_policy.resolve_current_employee(current_user)
employee_id = employee.id if employee is not None else None
employee_name = str(current_user.username or current_user.name or payload.user_id or "anonymous").strip()
if employee is not None:
employee_name = str(employee.name or employee.employee_no or employee.email or employee_name).strip()
employee_filter = ExpenseClaim.employee_name == employee_name
if employee_id is not None:
employee_filter = or_(ExpenseClaim.employee_id == employee_id, employee_filter)
stmt = (
select(ExpenseClaim)
.where(
ExpenseClaim.expense_type == self._resolve_application_expense_type_code(facts),
employee_filter,
)
.order_by(ExpenseClaim.id.desc())
.limit(100)
)
occurred_at = self._parse_application_occurred_at(facts.get("time", ""))
for claim in self.db.scalars(stmt).all():
if self._is_ignored_application_duplicate_status(claim.status):
continue
if self._matches_application_business_time(claim, facts, occurred_at):
return claim
return None
@staticmethod
def _is_ignored_application_duplicate_status(status: str | None) -> bool:
return str(status or "").strip().lower() in APPLICATION_DUPLICATE_IGNORED_STATUSES
@classmethod
def _matches_application_business_time(
cls,
claim: ExpenseClaim,
facts: dict[str, str],
occurred_at: datetime,
) -> bool:
current_time = cls._normalize_application_time_identity(facts.get("time"))
existing_detail = cls._extract_application_detail_from_claim(claim)
existing_time = cls._normalize_application_time_identity(existing_detail.get("time"))
if current_time and existing_time:
return current_time == existing_time
if claim.occurred_at is None:
return False
return claim.occurred_at.date() == occurred_at.date()
@staticmethod
def _normalize_application_time_identity(value: object) -> str:
normalized = str(value or "").strip()
if not normalized:
return ""
normalized = (
normalized.replace("", "")
.replace("~", "")
.replace("", "")
.replace("", "")
.replace("", "")
.replace("/", "-")
)
return re.sub(r"\s+", "", normalized)
@staticmethod
def _extract_application_detail_from_claim(claim: ExpenseClaim) -> dict[str, object]:
flags = claim.risk_flags_json
if isinstance(flags, dict):
flags = [flags]
if not isinstance(flags, list):
return {}
for item in flags:
if not isinstance(item, dict):
continue
detail = item.get("application_detail")
if isinstance(detail, dict):
return detail
return {}
@staticmethod @staticmethod
def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]: def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]:
return with_risk_business_stage( return with_risk_business_stage(
@@ -895,6 +1136,24 @@ class UserAgentApplicationMixin:
or context_json.get("departmentName") or context_json.get("departmentName")
or "" or ""
).strip(), ).strip(),
cost_center=str(context_json.get("cost_center") or context_json.get("costCenter") or "").strip(),
position=str(
context_json.get("position")
or context_json.get("employee_position")
or context_json.get("employeePosition")
or ""
).strip(),
grade=str(
context_json.get("grade")
or context_json.get("employee_grade")
or context_json.get("employeeGrade")
or ""
).strip(),
employee_no=str(
context_json.get("employee_no")
or context_json.get("employeeNo")
or ""
).strip(),
manager_name=str( manager_name=str(
context_json.get("manager_name") context_json.get("manager_name")
or context_json.get("managerName") or context_json.get("managerName")

View File

@@ -43,6 +43,20 @@ def resolve_application_days_count(days_text: str) -> int:
return _parse_chinese_number(chinese_match.group(0)) return _parse_chinese_number(chinese_match.group(0))
def resolve_application_days_from_time_range(time_text: str) -> int:
matches = re.findall(
r"20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?",
str(time_text or ""),
)
if len(matches) < 2:
return 0
start_date = _parse_application_date(matches[0])
end_date = _parse_application_date(matches[-1])
if start_date is None or end_date is None or end_date < start_date:
return 0
return (end_date - start_date).days + 1
def _resolve_start_date(time_text: str, context_json: dict[str, Any]) -> date | None: def _resolve_start_date(time_text: str, context_json: dict[str, Any]) -> date | None:
if time_text: if time_text:
match = re.search( match = re.search(

View File

@@ -183,9 +183,14 @@ class UserAgentReviewMessageMixin:
if draft_payload is not None and draft_payload.claim_no: if draft_payload is not None and draft_payload.claim_no:
return ( return (
f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}" f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}"
"后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交" "系统已完成草稿规则校验,风险与异常可在单据详情查看"
"如果还有其他票据,可以继续在当前对话上传,我会归集到这张草稿。"
) )
return "已按您当前确认的信息保存为草稿。后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。" return (
"已按您当前确认的信息保存为草稿。"
"系统已完成草稿规则校验,风险与异常可在单据详情查看。"
"如果还有其他票据,可以继续在当前对话上传,我会归集到这张草稿。"
)
if review_action == "link_to_existing_draft": if review_action == "link_to_existing_draft":
document_count = self._resolve_review_document_count(payload) document_count = self._resolve_review_document_count(payload)
followup_copy = self._build_review_action_followup_copy(review_payload) followup_copy = self._build_review_action_followup_copy(review_payload)
@@ -221,7 +226,7 @@ class UserAgentReviewMessageMixin:
"如果确有特殊情况,请先在附加说明中补充原因;补充后可以继续提交给审批人重点复核。" "如果确有特殊情况,请先在附加说明中补充原因;补充后可以继续提交给审批人重点复核。"
) )
return ( return (
"AI预审暂未通过,所以还没有提交到审批人。\n" "自动检测暂未通过,所以还没有提交到审批人。\n"
f"{reason_lines}\n" f"{reason_lines}\n"
"请先处理以上项目;处理完成后再点继续下一步。" "请先处理以上项目;处理完成后再点继续下一步。"
) )
@@ -266,7 +271,7 @@ class UserAgentReviewMessageMixin:
"如确有特殊情况,请在附加说明中补充原因后继续提交审批。" "如确有特殊情况,请在附加说明中补充原因后继续提交审批。"
) )
return ( return (
f"AI预审未通过:{reason_text}" f"自动检测未通过:{reason_text}"
"请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。" "请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。"
) )
@@ -478,7 +483,7 @@ class UserAgentReviewMessageMixin:
if missing_slots: if missing_slots:
return f"当前仍有 {''.join(missing_slots)},暂时只能保存为草稿,补齐后再继续下一步。" return f"当前仍有 {''.join(missing_slots)},暂时只能保存为草稿,补齐后再继续下一步。"
if receipt_briefs: if receipt_briefs:
return "当前必需票据已具备;如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传,也可以继续下一步或保存草稿" return "当前仍有必需票据待补充,暂时只能保存为草稿;补齐后再继续下一步"
if review_payload.can_proceed: if review_payload.can_proceed:
return "当前信息已较完整,您可以继续下一步,也可以先保存为草稿。" return "当前信息已较完整,您可以继续下一步,也可以先保存为草稿。"
return "" return ""
@@ -511,17 +516,9 @@ class UserAgentReviewMessageMixin:
for item in travel_receipt_state.get("required_missing_labels", []) for item in travel_receipt_state.get("required_missing_labels", [])
if str(item).strip() if str(item).strip()
] ]
optional_labels = [
str(item).strip()
for item in travel_receipt_state.get("optional_missing_labels", [])
if str(item).strip()
]
provide_items: list[str] = [] provide_items: list[str] = []
if required_labels: if required_labels:
provide_items.append("1. 酒店住宿发票/住宿清单(必须,当前待上传)") provide_items.append("1. 酒店住宿发票/住宿清单(必须,当前待上传)")
if optional_labels:
provide_items.append(f"{len(provide_items) + 1}. 市内交通/乘车票据(非必须,如打车、地铁、停车等)")
sections = [ sections = [
f"您好,{user_name}。我先按票据信息做一次差旅预检。", f"您好,{user_name}。我先按票据信息做一次差旅预检。",
@@ -546,11 +543,6 @@ class UserAgentReviewMessageMixin:
"处理建议:酒店票据仍缺失,暂时不能继续下一步。" "处理建议:酒店票据仍缺失,暂时不能继续下一步。"
"您可以先保存为草稿,补齐后再提交。" "您可以先保存为草稿,补齐后再提交。"
) )
elif can_proceed and optional_labels:
sections.append(
"处理建议:必需票据已具备。"
"如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。"
)
elif can_proceed: elif can_proceed:
sections.append( sections.append(
"处理建议:当前信息已较完整,确认无误后可以继续下一步;" "处理建议:当前信息已较完整,确认无误后可以继续下一步;"

View File

@@ -232,6 +232,17 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。", evidence="来源于用户修改后的结构化表单。",
) )
application_time = str(review_form_values.get("application_business_time") or "").strip()
if application_time:
return self._build_slot_value(
value=application_time,
raw_value=application_time,
normalized_value=application_time,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的发生时间依据。",
)
time_range = payload.ontology.time_range time_range = payload.ontology.time_range
if time_range.start_date and time_range.end_date: if time_range.start_date and time_range.end_date:
normalized_value = ( normalized_value = (
@@ -265,6 +276,16 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。", evidence="来源于用户修改后的结构化表单。",
) )
application_location = str(review_form_values.get("application_location") or "").strip()
if application_location:
return self._build_slot_value(
value=application_location,
normalized_value=application_location,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的地点依据。",
)
if str(payload.context_json.get("entry_source") or "").strip() == "detail": if str(payload.context_json.get("entry_source") or "").strip() == "detail":
request_context = payload.context_json.get("request_context") request_context = payload.context_json.get("request_context")
if isinstance(request_context, dict): if isinstance(request_context, dict):
@@ -370,6 +391,17 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。", evidence="来源于用户修改后的结构化表单。",
) )
application_reason = str(review_form_values.get("application_reason") or "").strip()
if application_reason:
return self._build_slot_value(
value=application_reason,
raw_value=application_reason,
normalized_value=application_reason,
source="detail_context",
confidence=0.9,
evidence="来源于已关联申请单,作为本次报销草稿的事由依据。",
)
inferred_reason = self._infer_reason_from_claim_groups( inferred_reason = self._infer_reason_from_claim_groups(
claim_groups=claim_groups, claim_groups=claim_groups,
) )
@@ -420,6 +452,22 @@ class UserAgentReviewSlotMixin:
evidence="来源于用户修改后的结构化表单。", evidence="来源于用户修改后的结构化表单。",
) )
application_amount = str(
review_form_values.get("application_amount")
or review_form_values.get("application_amount_label")
or ""
).strip()
if application_amount:
normalized = self._normalize_amount_text(application_amount)
return self._build_slot_value(
value=normalized,
raw_value=application_amount,
normalized_value=normalized,
source="detail_context",
confidence=0.86,
evidence="来源于已关联申请单,作为本次报销草稿的金额依据。",
)
amount_value = entity_map.get("amount", "") amount_value = entity_map.get("amount", "")
if amount_value: if amount_value:
normalized = self._normalize_amount_text(amount_value) normalized = self._normalize_amount_text(amount_value)

View File

@@ -99,9 +99,7 @@ class UserAgentReviewTravelReceiptMixin:
} }
has_hotel_invoice = any(self._is_review_hotel_card(card) for card in document_cards) has_hotel_invoice = any(self._is_review_hotel_card(card) for card in document_cards)
has_local_transport = any(self._is_local_transport_receipt_card(card) for card in document_cards)
required_missing_labels = [] if has_hotel_invoice else ["酒店的报销票据待上传(必须)"] required_missing_labels = [] if has_hotel_invoice else ["酒店的报销票据待上传(必须)"]
optional_missing_labels = [] if has_local_transport else ["市内交通/乘车票据可继续上传(非必须)"]
ticket_amount = sum( ticket_amount = sum(
(self._extract_amount_decimal_from_card(card) or Decimal("0.00")) (self._extract_amount_decimal_from_card(card) or Decimal("0.00"))
for card in long_distance_cards for card in long_distance_cards
@@ -116,9 +114,9 @@ class UserAgentReviewTravelReceiptMixin:
"destination": self._resolve_travel_receipt_destination(payload, long_distance_cards), "destination": self._resolve_travel_receipt_destination(payload, long_distance_cards),
"days": self._resolve_travel_receipt_days(payload, long_distance_cards), "days": self._resolve_travel_receipt_days(payload, long_distance_cards),
"has_hotel_invoice": has_hotel_invoice, "has_hotel_invoice": has_hotel_invoice,
"has_local_transport": has_local_transport, "has_local_transport": any(self._is_local_transport_receipt_card(card) for card in document_cards),
"required_missing_labels": required_missing_labels, "required_missing_labels": required_missing_labels,
"optional_missing_labels": optional_missing_labels, "optional_missing_labels": [],
"blocks_next_step": bool(required_missing_labels), "blocks_next_step": bool(required_missing_labels),
} }
@@ -273,32 +271,20 @@ class UserAgentReviewTravelReceiptMixin:
for item in travel_receipt_state.get("required_missing_labels", []) for item in travel_receipt_state.get("required_missing_labels", [])
if str(item).strip() if str(item).strip()
] ]
optional_labels = [ if not required_labels:
str(item).strip()
for item in travel_receipt_state.get("optional_missing_labels", [])
if str(item).strip()
]
if not required_labels and not optional_labels:
return [] return []
content_parts = [*required_labels, *optional_labels]
required_text = "".join(required_labels) required_text = "".join(required_labels)
optional_text = "".join(optional_labels)
return [ return [
UserAgentReviewRiskBrief( UserAgentReviewRiskBrief(
title="差旅票据待补充", title="差旅票据待补充",
level="warning" if required_labels else "info", level="warning",
content="".join(content_parts), content=required_text,
detail=( detail=(
"系统已识别到长途交通票据,会按差旅报销口径核对住宿、交通等票据完整性。" "系统已识别到长途交通票据,会按差旅报销口径核对住宿、交通等票据完整性。"
+ (f"当前必须补充:{required_text}" if required_text else "") + f"当前必须补充:{required_text}"
+ (f"当前还可以补充:{optional_text}" if optional_text else "")
),
suggestion=(
"请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。"
if required_labels
else "如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传;没有也可以进入下一步或保存草稿。"
), ),
suggestion="请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。",
) )
] ]
@@ -606,6 +592,10 @@ class UserAgentReviewTravelReceiptMixin:
message = str(payload.tool_payload.get("message") or "").strip() message = str(payload.tool_payload.get("message") or "").strip()
for prefix in ( for prefix in (
"提交前请先补全信息:", "提交前请先补全信息:",
"自动检测暂未通过,原因如下:",
"自动检测未通过,原因如下:",
"自动检测暂未通过:",
"自动检测未通过:",
"AI预审暂未通过原因如下", "AI预审暂未通过原因如下",
"AI预审未通过原因如下", "AI预审未通过原因如下",
"AI预审暂未通过", "AI预审暂未通过",
@@ -618,7 +608,9 @@ class UserAgentReviewTravelReceiptMixin:
reasons.extend( reasons.extend(
item.strip() item.strip()
for item in re.split(r"[;\n]+", message) for item in re.split(r"[;\n]+", message)
if item.strip() and not item.strip().startswith("AI预审暂未通过") if item.strip()
and not item.strip().startswith("AI预审暂未通过")
and not item.strip().startswith("自动检测暂未通过")
) )
return list(dict.fromkeys(reason for reason in reasons if reason)) return list(dict.fromkeys(reason for reason in reasons if reason))

View File

@@ -165,6 +165,32 @@ def test_validate_claim_for_submission_still_requires_location_for_travel_claim(
assert any("缺少地点" in item for item in issues) assert any("缺少地点" in item for item in issues)
def test_validate_claim_for_submission_does_not_require_optional_ride_receipt() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="transport", location="待补充")
claim.invoice_count = 0
claim.items[0].item_type = "ride_ticket"
claim.items[0].invoice_id = ""
issues = service._validate_claim_for_submission(claim)
assert "票据附件数量不足" not in issues
assert not any("缺少票据标识" in item for item in issues)
def test_validate_claim_for_submission_still_requires_hotel_receipt() -> None:
service = ExpenseClaimService.__new__(ExpenseClaimService)
claim = build_claim(expense_type="hotel", location="北京")
claim.invoice_count = 0
claim.items[0].item_type = "hotel_ticket"
claim.items[0].invoice_id = ""
issues = service._validate_claim_for_submission(claim)
assert "票据附件数量不足" in issues
assert any("缺少票据标识" in item for item in issues)
def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None: def test_save_or_submit_preview_does_not_create_claim_without_explicit_action() -> None:
user_id = "preview-only@example.com" user_id = "preview-only@example.com"
message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报" message = "业务发生时间:2026-03-04打车去客户现场交通费32元请帮我看看怎么报"
@@ -342,6 +368,80 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署" assert link_flag["application_detail"]["application_reason"] == "支撑国网仿生产环境部署"
def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_item() -> None:
user_id = "linked-application-no-receipt@example.com"
message = (
"报销类型:差旅费\n"
"关联申请单AP-202606-001 / 支撑国网仿生产服务器部署 / 2026-02-20 至 2026-02-23 / 上海 / ¥3,000\n"
"报销票据:草稿生成后在详情中上传"
)
with build_session() as db:
employee = Employee(
employee_no="E5104",
name="关联员工",
email=user_id,
grade="P5",
)
db.add(employee)
db.commit()
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id=user_id,
)
)
result = ExpenseClaimService(db).upsert_draft_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": "¥3,000",
"reason": "支撑国网仿生产服务器部署",
"location": "上海",
"business_location": "上海",
"time_range": "2026-02-20 至 2026-02-23",
"business_time": "2026-02-20 至 2026-02-23",
"application_claim_id": "application-linked-no-receipt",
"application_claim_no": "AP-202606-001",
"application_reason": "支撑国网仿生产服务器部署",
"application_location": "上海",
"application_amount": "3000",
"application_amount_label": "¥3,000",
"application_business_time": "2026-02-20 至 2026-02-23",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-linked-no-receipt",
"application_claim_no": "AP-202606-001",
},
},
)
claim = db.get(ExpenseClaim, result["claim_id"])
assert claim is not None
assert claim.expense_type == "travel"
assert claim.reason == "支撑国网仿生产服务器部署"
assert claim.location == "上海"
assert claim.amount == Decimal("0.00")
assert claim.invoice_count == 0
assert claim.items == []
link_flag = next(
flag
for flag in claim.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "application_link"
)
assert link_flag["application_claim_no"] == "AP-202606-001"
assert link_flag["application_detail"]["application_amount"] == "3000"
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)
@@ -2165,7 +2265,7 @@ def test_pre_review_claim_records_ai_result_without_submitting() -> None:
assert reviewed is not None assert reviewed is not None
assert reviewed.status == "draft" assert reviewed.status == "draft"
assert reviewed.approval_stage == "AI预审" assert reviewed.approval_stage == "待提交"
assert reviewed.submitted_at is None assert reviewed.submitted_at is None
pre_review_flag = next( pre_review_flag = next(
flag flag
@@ -3098,6 +3198,93 @@ def test_executive_can_delete_submitted_claim() -> None:
assert db.get(ExpenseClaim, claim_id) is None assert db.get(ExpenseClaim, claim_id) is None
def test_direct_manager_cannot_delete_application_claim() -> None:
current_user = CurrentUserContext(
username="manager-delete-application@example.com",
name="李经理",
role_codes=["manager"],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E-APP-DEL-MANAGER",
name="李经理",
email="manager-delete-application@example.com",
)
employee = Employee(
employee_no="E-APP-DEL-EMP",
name="张三",
email="zhangsan-application-delete@example.com",
manager=manager,
)
db.add_all([manager, employee])
db.flush()
claim = ExpenseClaim(
claim_no="APP-DEL-MANAGER-101",
employee_id=employee.id,
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="travel_application",
reason="差旅申请",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
with pytest.raises(ValueError, match="申请单只有系统管理员可以删除"):
ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert db.get(ExpenseClaim, claim_id) is not None
def test_admin_can_delete_application_claim() -> None:
current_user = CurrentUserContext(
username="superadmin",
name="系统管理员",
role_codes=["manager"],
is_admin=True,
)
with build_session() as db:
claim = ExpenseClaim(
claim_no="APP-DEL-ADMIN-101",
employee_name="张三",
department_name="市场部",
project_code=None,
expense_type="travel_application",
reason="差旅申请",
location="上海",
amount=Decimal("1200.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 5, 11, 9, 0, tzinfo=UTC),
submitted_at=datetime(2026, 5, 11, 10, 0, tzinfo=UTC),
status="submitted",
approval_stage="直属领导审批",
risk_flags_json=[],
)
db.add(claim)
db.commit()
claim_id = claim.id
deleted = ExpenseClaimService(db).delete_claim(claim_id, current_user)
assert deleted is not None
assert deleted.claim_no == "APP-DEL-ADMIN-101"
assert db.get(ExpenseClaim, claim_id) is None
def test_executive_cannot_delete_archived_claim() -> None: def test_executive_cannot_delete_archived_claim() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="executive-archive-delete@example.com", username="executive-archive-delete@example.com",

View File

@@ -268,7 +268,7 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action(
assert result["draft_payload"]["status"] == "draft" assert result["draft_payload"]["status"] == "draft"
assert response.conversation_id assert response.conversation_id
assert AgentConversationService(db).get_conversation(response.conversation_id) is not None assert AgentConversationService(db).get_conversation(response.conversation_id) is not None
assert "AI预审暂未通过" in result["answer"] assert "自动检测暂未通过" in result["answer"]
assert "所属部门未完善" in result["answer"] assert "所属部门未完善" in result["answer"]
assert "next_step" not in actions assert "next_step" not in actions
assert "save_draft" in actions assert "save_draft" in actions
@@ -710,7 +710,7 @@ def test_orchestrator_application_session_does_not_use_reimbursement_scene_promp
assert response.status == "blocked" assert response.status == "blocked"
assert response.trace_summary.scenario == "expense" assert response.trace_summary.scenario == "expense"
assert "费用申请" in result["answer"] assert "费用申请" in result["answer"]
assert "| 发生时间 | 2026-05-25" in result["answer"] assert "| 行程时间 | 2026-05-25" in result["answer"]
assert "请先在下面选择报销场景" not in result["answer"] assert "请先在下面选择报销场景" not in result["answer"]
assert result.get("review_payload") is None assert result.get("review_payload") is None

View File

@@ -16,6 +16,7 @@ from app.main import create_app
from app.models.employee import Employee from app.models.employee import Employee
from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.financial_record import ExpenseClaim, ExpenseClaimItem
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.risk_observation import RiskObservation, RiskObservationFeedback
from app.models.role import Role from app.models.role import Role
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
@@ -594,6 +595,31 @@ def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_head
client, session_factory = build_client() client, session_factory = build_client()
with session_factory() as db: with session_factory() as db:
claim, _ = seed_claim(db) claim, _ = seed_claim(db)
observation = RiskObservation(
id="risk-observation-delete-1",
observation_key="claim-delete-risk-observation-1",
subject_type="expense_claim",
subject_key=claim.id,
subject_label=claim.claim_no,
claim_id=claim.id,
claim_no=claim.claim_no,
risk_type="policy",
risk_signal="draft_pre_review",
title="草稿预审风险",
description="删除草稿时应同步清理关联风险观察。",
risk_score=70,
risk_level="medium",
confidence_score=0.8,
)
feedback = RiskObservationFeedback(
id="risk-observation-feedback-delete-1",
observation=observation,
feedback_type="confirm",
actor="auditor",
)
db.add(observation)
db.add(feedback)
db.commit()
claim_id = claim.id claim_id = claim.id
response = client.delete( response = client.delete(
@@ -608,3 +634,5 @@ def test_claim_delete_allows_draft_owner_by_employee_id_without_employee_no_head
with session_factory() as db: with session_factory() as db:
assert db.get(ExpenseClaim, claim_id) is None assert db.get(ExpenseClaim, claim_id) is None
assert db.get(RiskObservation, "risk-observation-delete-1") is None
assert db.get(RiskObservationFeedback, "risk-observation-feedback-delete-1") is None

View File

@@ -666,6 +666,82 @@ def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> No
assert result["evidence"]["city_consistency"]["reference_values"] == ["北京"] assert result["evidence"]["city_consistency"]["reference_values"] == ["北京"]
def test_travel_route_city_consistency_allows_normal_round_trip_to_declared_destination() -> None:
manifest = {
"template_key": "field_compare_v1",
"params": {
"template_key": "field_compare_v1",
"semantic_type": "travel_route_city_consistency",
"field_keys": [
"attachment.route_cities",
"claim.location",
"item.item_location",
"employee.location",
"claim.reason",
],
"attachment_city_fields": ["attachment.route_cities"],
"reference_city_fields": ["claim.location", "item.item_location"],
"home_city_fields": ["employee.location"],
"exception_fields": ["claim.reason"],
"exception_keywords": ["绕行", "跨城办事", "临时改签"],
},
"outcomes": {"fail": {"severity": "high"}},
}
claim = ExpenseClaim(
claim_no="TEST-ROUND-TRIP",
employee_name="测试员工",
department_name="测试部门",
expense_type="差旅费",
reason="去上海支撑项目部署",
location="上海",
amount=Decimal("708.00"),
currency="CNY",
invoice_count=2,
occurred_at=datetime.now(UTC),
status="draft",
)
claim.employee = Employee(
employee_no="TEST-ROUND-TRIP-EMP",
name="测试员工",
email="round-trip@example.com",
location="武汉",
)
claim.items = [
ExpenseClaimItem(
item_date=date.today(),
item_type="交通费",
item_reason="去上海支撑项目部署",
item_location="上海",
item_amount=Decimal("354.00"),
)
]
result = RiskRuleTemplateExecutor().evaluate(
manifest,
claim=claim,
contexts=[
{
"document_info": {
"fields": [
{"key": "route", "label": "行程", "value": "武汉-上海"},
],
},
"ocr_text": "铁路电子客票 2026-02-20 武汉-上海 二等座",
},
{
"document_info": {
"fields": [
{"key": "route", "label": "行程", "value": "上海-武汉"},
],
},
"ocr_text": "铁路电子客票 2026-02-23 上海-武汉 二等座",
},
],
)
assert result is None
def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None: def test_generate_complex_travel_route_rule_uses_formula_not_keyword_match(tmp_path) -> None:
text = ( text = (
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;" "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"

View File

@@ -209,7 +209,7 @@ def test_user_agent_application_context_uses_application_language() -> None:
assert "费用申请" in response.answer assert "费用申请" in response.answer
assert "| 字段 | 内容 |" in response.answer assert "| 字段 | 内容 |" in response.answer
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "支持上海国网服务器部署" in response.answer assert "支持上海国网服务器部署" in response.answer
assert "当前还需要补充:出行方式" in response.answer assert "当前还需要补充:出行方式" in response.answer
assert "请先在下面选择报销场景" not in response.answer assert "请先在下面选择报销场景" not in response.answer
@@ -224,7 +224,7 @@ def test_user_agent_application_infers_natural_reason_and_expands_single_date()
with session_factory() as db: with session_factory() as db:
response = build_application_user_agent_response(db, message) response = build_application_user_agent_response(db, message)
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in response.answer assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer assert "| 事由 | 支撑上海国网服务器部署 |" in response.answer
assert "当前还需要先补充:申请事由" not in response.answer assert "当前还需要先补充:申请事由" not in response.answer
@@ -250,7 +250,7 @@ def test_user_agent_application_normalizes_location_to_region_city() -> None:
yili_response = build_application_user_agent_response(db, yili_message) yili_response = build_application_user_agent_response(db, yili_message)
beijing_response = build_application_user_agent_response(db, beijing_message) beijing_response = build_application_user_agent_response(db, beijing_message)
assert "| 发生时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer assert "| 行程时间 | 2026-05-25 至 2026-05-27 |" in yili_response.answer
assert "| 地点 | 新疆,伊犁 |" in yili_response.answer assert "| 地点 | 新疆,伊犁 |" in yili_response.answer
assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer assert "| 事由 | 支撑新疆电力仿生产部署 |" in yili_response.answer
assert "伊犁出差" not in yili_response.answer assert "伊犁出差" not in yili_response.answer
@@ -289,7 +289,7 @@ def test_user_agent_application_uses_selected_time_and_natural_language_fields()
) )
) )
assert "| 发生时间 | 2026-05-25 |" in response.answer assert "| 行程时间 | 2026-05-25 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer assert "| 事由 | 支撑国网服务器上线部署 |" in response.answer
assert "当前还需要补充:出行方式" in response.answer assert "当前还需要补充:出行方式" in response.answer
@@ -325,6 +325,106 @@ def test_user_agent_application_builds_system_estimate_after_transport_choice()
assert response.suggested_actions == [] assert response.suggested_actions == []
def test_user_agent_application_uses_selected_date_range_and_keeps_reason() -> None:
session_factory = build_session_factory()
message = "去上海出差4天支撑国网仿生产环境部署飞机"
context_json = {
"session_type": "application",
"entry_source": "application",
"business_time_context": {
"mode": "range",
"start_date": "2026-02-20",
"end_date": "2026-02-23",
"display_value": "2026-02-20 至 2026-02-23",
},
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
assert "| 地点 | 上海市 |" in response.answer
assert "| 事由 | 支撑国网仿生产环境部署 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 发生时间 |" not in response.answer
assert "| 事由 | 2026-02-20 至 2026-02-23 |" not in response.answer
def test_user_agent_application_derives_days_from_selected_date_range() -> None:
session_factory = build_session_factory()
message = "去上海出差,支撑国网仿生产服务器部署,火车"
context_json = {
"session_type": "application",
"entry_source": "application",
"business_time_context": {
"mode": "range",
"start_date": "2026-02-20",
"end_date": "2026-02-23",
"display_value": "2026-02-20 至 2026-02-23",
},
"application_preview": {
"fields": {
"applicationType": "差旅费用申请",
"time": "2026-02-20 至 2026-02-23",
"location": "上海市",
"reason": "支撑国网仿生产服务器部署",
"days": "待补充",
"transportMode": "火车",
"grade": "P5",
}
},
"name": "曹笑竹",
"department_name": "技术部",
"position": "财务智能化产品经理",
"manager_name": "向万红",
"grade": "P5",
}
with session_factory() as db:
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"clarification_required": ontology.clarification_required},
)
)
assert "| 行程时间 | 2026-02-20 至 2026-02-23 |" in response.answer
assert "| 天数 | 4天 |" in response.answer
assert "| 天数 | 待补充 |" not in response.answer
assert "4天" in response.answer
assert "1天" not in response.answer
def test_user_agent_application_missing_base_actions_prefill_composer() -> None: def test_user_agent_application_missing_base_actions_prefill_composer() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
@@ -352,7 +452,7 @@ def test_user_agent_application_precomputes_time_from_today_and_days() -> None:
) )
assert "这是费用申请核对结果" in response.answer assert "这是费用申请核对结果" in response.answer
assert "| 发生时间 | 2026-05-29 至 2026-05-31 |" in response.answer assert "| 行程时间 | 2026-05-29 至 2026-05-31 |" in response.answer
assert response.requires_confirmation is True assert response.requires_confirmation is True
@@ -395,6 +495,45 @@ def test_user_agent_application_builds_preview_when_amount_is_ready() -> None:
assert response.suggested_actions == [] assert response.suggested_actions == []
def test_user_agent_application_preview_uses_employee_grade_profile() -> None:
session_factory = build_session_factory()
initial_message = (
"发生时间2026-05-25\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天"
)
with session_factory() as db:
employee = Employee(
employee_no="APP-GRADE-001",
name="李文静",
email="pytest-application-grade@example.com",
position="解决方案顾问",
grade="P5",
)
db.add(employee)
db.commit()
response = build_application_user_agent_response(
db,
"预计总费用12000元",
context_overrides={
"name": "李文静",
"manager_name": "王强",
},
history=[
{"role": "user", "content": initial_message},
{"role": "user", "content": "飞机"},
],
)
assert "这是费用申请核对结果" in response.answer
assert "| 姓名 | 李文静 |" in response.answer
assert "| 岗位 | 解决方案顾问 |" in response.answer
assert "| 职级 | P5 |" in response.answer
assert "| 职级 | 待补充 |" not in response.answer
def test_user_agent_application_submit_enters_leader_review() -> None: def test_user_agent_application_submit_enters_leader_review() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
initial_message = ( initial_message = (
@@ -408,7 +547,7 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
"| 字段 | 内容 |\n" "| 字段 | 内容 |\n"
"| --- | --- |\n" "| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n" "| 申请类型 | 差旅费用申请 |\n"
"| 发生时间 | 2026-05-25 |\n" "| 行程时间 | 2026-05-25 |\n"
"| 地点 | 上海市 |\n" "| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n" "| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n" "| 天数 | 3天 |\n"
@@ -443,6 +582,58 @@ def test_user_agent_application_submit_enters_leader_review() -> None:
assert claim.employee_name == "pytest" assert claim.employee_name == "pytest"
def test_user_agent_application_submit_blocks_duplicate_business_time() -> None:
session_factory = build_session_factory()
initial_message = (
"行程时间2026-05-25 至 2026-05-27\n"
"地点:上海\n"
"事由:支持上海国网服务器部署\n"
"天数3天\n"
"出行方式:飞机\n"
"预计总费用12000元"
)
preview_answer = (
"这是费用申请核对结果,请核对:\n"
"| 字段 | 内容 |\n"
"| --- | --- |\n"
"| 申请类型 | 差旅费用申请 |\n"
"| 行程时间 | 2026-05-25 至 2026-05-27 |\n"
"| 地点 | 上海市 |\n"
"| 事由 | 支持上海国网服务器部署 |\n"
"| 天数 | 3天 |\n"
"| 出行方式 | 飞机 |\n"
"| 系统预估费用 | 12000元 |\n\n"
"请核对上述信息无误,确认无误后 [确认](#application-submit) 提交至审批流程。"
)
history = [
{"role": "user", "content": initial_message},
{"role": "assistant", "content": preview_answer},
]
with session_factory() as db:
first_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
history=history,
)
first_claim = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).one()
second_response = build_application_user_agent_response(
db,
"确认提交",
context_overrides={"manager_name": "陈硕"},
history=history,
)
claims = db.query(ExpenseClaim).filter(ExpenseClaim.claim_no.like("AP-%")).all()
assert len(claims) == 1
assert "申请单据已生成" in first_response.answer
assert "已存在申请单" in second_response.answer
assert "系统没有重复创建" in second_response.answer
assert first_claim.claim_no in second_response.answer
assert second_response.draft_payload is None
def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None: def test_user_agent_knowledge_fallback_is_honest_and_personalized() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
@@ -1173,6 +1364,57 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N
assert "| 参考合计 |" in response.answer assert "| 参考合计 |" in response.answer
def test_user_agent_uses_linked_application_context_for_review_slots() -> None:
session_factory = build_session_factory()
with session_factory() as db:
message = "请生成差旅费报销草稿"
context_json = {
"grade": "P4",
"expense_scene_selection": {
"expense_type": "travel",
"expense_type_label": "差旅费",
"original_message": message,
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202606-001",
},
"review_form_values": {
"expense_type": "差旅费",
"application_claim_id": "application-linked-1",
"application_claim_no": "AP-202606-001",
"application_reason": "支撑国网仿生产环境部署",
"application_location": "北京",
"application_amount": "3000元",
"application_business_time": "2026-06-01 至 2026-06-03",
},
"user_input_text": message,
}
ontology = SemanticOntologyService(db).parse(
OntologyParseRequest(
query=message,
user_id="pytest-linked-application-review@example.com",
context_json=context_json,
)
)
response = UserAgentService(db).respond(
UserAgentRequest(
run_id=ontology.run_id,
user_id="pytest-linked-application-review@example.com",
message=message,
ontology=ontology,
context_json=context_json,
tool_payload={"draft_only": True},
)
)
assert response.review_payload is not None
slot_map = {item.key: item for item in response.review_payload.slot_cards}
assert slot_map["reason"].value == "支撑国网仿生产环境部署"
assert slot_map["location"].value == "北京"
assert slot_map["amount"].value == "3000.00元"
assert slot_map["time_range"].value == "2026-06-01 至 2026-06-03"
assert "事由说明" not in response.review_payload.missing_slots
def test_user_agent_guides_implicit_expense_draft_request() -> None: def test_user_agent_guides_implicit_expense_draft_request() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
@@ -1422,6 +1664,12 @@ def test_user_agent_keeps_travel_range_when_user_adds_receipts_after_text_contex
assert followup_slots["time_range"].value == "2026-02-20 至 2026-02-23" assert followup_slots["time_range"].value == "2026-02-20 至 2026-02-23"
assert followup_slots["location"].value == "上海" assert followup_slots["location"].value == "上海"
assert followup_slots["reason"].value == "去上海支撑上海电力服务器部署出差3天" assert followup_slots["reason"].value == "去上海支撑上海电力服务器部署出差3天"
followup_risk_text = "\n".join(
f"{item.title}\n{item.content}\n{item.detail}"
for item in followup_response.review_payload.risk_briefs
)
assert "票据城市与申报目的地不一致" not in followup_risk_text
assert "差旅目的地与票据城市不一致" not in followup_risk_text
def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None: def test_user_agent_does_not_treat_draft_saved_message_as_precheck_risk_for_transport() -> None:
@@ -1697,7 +1945,9 @@ def test_user_agent_save_draft_answer_guides_followup_to_existing_draft() -> Non
assert response.draft_payload is not None assert response.draft_payload is not None
assert response.draft_payload.claim_no == "BX202605220001" assert response.draft_payload.claim_no == "BX202605220001"
assert "已按您当前确认的信息保存为草稿 BX202605220001" in response.answer assert "已按您当前确认的信息保存为草稿 BX202605220001" in response.answer
assert "请关联这张草稿" in response.answer assert "系统已完成草稿规则校验" in response.answer
assert "继续在当前对话上传" in response.answer
assert "请关联这张草稿" not in response.answer
assert "继续保存草稿" not in response.answer assert "继续保存草稿" not in response.answer
@@ -2264,7 +2514,7 @@ def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket()
] ]
assert "继续下一步" not in [item.label for item in response.review_payload.confirmation_actions] assert "继续下一步" not in [item.label for item in response.review_payload.confirmation_actions]
assert "酒店住宿发票/住宿清单(必须,当前待上传)" in response.answer assert "酒店住宿发票/住宿清单(必须,当前待上传)" in response.answer
assert "市内交通/乘车票据(非必须" in response.answer assert "市内交通/乘车票据(非必须" not in response.answer
assert "只能保存为草稿" in response.answer or "保存为草稿" in response.answer assert "只能保存为草稿" in response.answer or "保存为草稿" in response.answer
assert "已识别信息:" in response.answer assert "已识别信息:" in response.answer
assert "酒店住宿发票/住宿清单" in response.answer assert "酒店住宿发票/住宿清单" in response.answer
@@ -2280,7 +2530,7 @@ def test_user_agent_review_payload_does_not_fill_hotel_name_from_train_ticket()
assert "列车出发时间" in field_labels assert "列车出发时间" in field_labels
def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_receipt_is_missing() -> None: def test_user_agent_review_payload_does_not_prompt_when_only_optional_ride_receipt_is_missing() -> None:
session_factory = build_session_factory() session_factory = build_session_factory()
with session_factory() as db: with session_factory() as db:
query = "我去北京出差,上传了火车票和酒店票,帮我生成差旅费报销草稿" query = "我去北京出差,上传了火车票和酒店票,帮我生成差旅费报销草稿"
@@ -2341,14 +2591,11 @@ def test_user_agent_review_payload_allows_next_step_when_only_optional_ride_rece
assert response.review_payload is not None assert response.review_payload is not None
assert response.review_payload.can_proceed is True assert response.review_payload.can_proceed is True
assert response.review_payload.missing_slots == [] assert response.review_payload.missing_slots == []
receipt_brief = next(item for item in response.review_payload.risk_briefs if item.title == "差旅票据待补充") assert not any(item.title == "差旅票据待补充" for item in response.review_payload.risk_briefs)
assert receipt_brief.level == "info"
assert "市内交通/乘车票据可继续上传(非必须)" in receipt_brief.content
assert "酒店的报销票据待上传(必须)" not in receipt_brief.content
action_types = [item.action_type for item in response.review_payload.confirmation_actions] action_types = [item.action_type for item in response.review_payload.confirmation_actions]
assert "save_draft" in action_types assert "save_draft" in action_types
assert "next_step" in action_types assert "next_step" in action_types
assert "市内交通/乘车票据(非必须" in response.answer assert "市内交通/乘车票据(非必须" not in response.answer
assert "继续下一步" in response.answer assert "继续下一步" in response.answer

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

View File

@@ -207,6 +207,10 @@
background: #fff; background: #fff;
} }
.mobile-hamburger-btn {
display: none;
}
@keyframes loginEntrySidebarIn { @keyframes loginEntrySidebarIn {
from { from {
opacity: 0; opacity: 0;
@@ -270,9 +274,28 @@
.app > .main { .app > .main {
flex: 1 1 100%; flex: 1 1 100%;
width: 100vw; width: 100vw;
max-width: 100vw;
min-width: 0;
overflow: hidden;
}
.workarea {
min-width: 0;
max-width: 100%;
padding: 16px;
}
.workarea.documents-workarea,
.workarea.receipt-folder-workarea,
.workarea.budget-workarea,
.workarea.policies-workarea,
.workarea.audit-workarea,
.workarea.employees-workarea {
overflow-x: hidden;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
} }
.workarea { padding: 16px; }
.workarea.workbench-workarea { overflow: auto; padding: 16px; } .workarea.workbench-workarea { overflow: auto; padding: 16px; }
.mobile-overlay { .mobile-overlay {

View File

@@ -477,7 +477,18 @@ td small {
@media (max-width: 760px) { @media (max-width: 760px) {
.status-tabs { .status-tabs {
gap: 18px; gap: 18px;
flex-wrap: nowrap;
overflow-x: auto; overflow-x: auto;
scrollbar-width: thin;
}
.status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.status-tabs button span {
white-space: nowrap;
} }
.filter-set, .filter-set,
@@ -492,4 +503,111 @@ td small {
display: grid; display: grid;
justify-items: stretch; justify-items: stretch;
} }
.pager {
width: 100%;
max-width: 100%;
justify-content: flex-start;
overflow-x: auto;
scrollbar-width: thin;
}
.pager button {
flex: 0 0 auto;
}
.documents-list .table-wrap {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.documents-list .table-wrap table,
.documents-list .table-wrap thead,
.documents-list .table-wrap tbody,
.documents-list .table-wrap tr,
.documents-list .table-wrap th,
.documents-list .table-wrap td {
display: block;
}
.documents-list .table-wrap table {
min-width: 0;
width: 100%;
border-collapse: separate;
}
.documents-list .table-wrap thead,
.documents-list .table-wrap colgroup {
display: none;
}
.documents-list .table-wrap tbody {
display: grid;
gap: 10px;
}
.documents-list .table-wrap tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.documents-list .table-wrap tr:hover {
background: #f8fbff;
}
.documents-list .table-wrap td {
position: relative;
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: start;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.documents-list .table-wrap td:last-child {
border-bottom: 0;
}
.documents-list .table-wrap td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.7;
}
.documents-list .table-wrap td > * {
min-width: 0;
}
.documents-list .table-wrap td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.documents-list .table-wrap td:first-child::before {
display: none;
}
.documents-list .table-wrap td[data-label="事项"] {
grid-template-columns: 1fr;
}
.documents-list .table-wrap td[data-label="事项"]::before {
margin-bottom: 2px;
}
} }

View File

@@ -654,8 +654,7 @@
.enterprise-list-page .create-request-btn, .enterprise-list-page .create-request-btn,
.enterprise-list-page .create-btn, .enterprise-list-page .create-btn,
.enterprise-list-page .export-btn, .enterprise-list-page .export-btn,
.enterprise-list-page .template-btn, .enterprise-list-page .template-btn {
.enterprise-list-page .page-size-select {
width: 100%; width: 100%;
} }
@@ -666,8 +665,35 @@
justify-items: stretch; justify-items: stretch;
} }
.enterprise-list-page .pager, .enterprise-list-page .list-foot {
gap: 10px;
margin-top: 10px;
}
.enterprise-list-page .page-summary {
justify-self: center;
max-width: 100%;
color: #64748b;
font-size: 12px;
line-height: 1.55;
text-align: center;
}
.enterprise-list-page .pager {
width: 100%;
max-width: 100%;
justify-self: center;
flex-wrap: wrap;
overflow-x: visible;
}
.enterprise-list-page .pager button {
flex: 0 0 32px;
}
.enterprise-list-page .page-size-select { .enterprise-list-page .page-size-select {
justify-self: stretch; width: 160px;
max-width: 100%;
justify-self: center;
} }
} }

View File

@@ -0,0 +1,162 @@
.workbench {
--workbench-capability-bg-image: url("../../personal-workbench-card-glass-capability.webp");
--workbench-panel-bg-image: url("../../personal-workbench-card-glass-panel.webp");
--workbench-capability-tile-size: 384px 384px;
--workbench-panel-tile-size: 512px 512px;
--workbench-glass-base:
linear-gradient(135deg, rgba(255, 255, 255, 0.76) 0%, rgba(255, 255, 255, 0.62) 54%, rgba(255, 255, 255, 0.7) 100%);
--workbench-glass-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.075) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.022) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.052) 100%);
--workbench-glass-highlight:
linear-gradient(120deg, rgba(255, 255, 255, 0.5) 0%, transparent 16%, transparent 82%, rgba(255, 255, 255, 0.22) 100%);
--workbench-glass-noise-opacity: 0.012;
--workbench-glass-blur: blur(18px) saturate(1.28);
}
.capability-card {
isolation: isolate;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16);
border-left: 3px solid color-mix(in srgb, var(--capability-color) 42%, rgba(255, 255, 255, 0.72));
background:
var(--workbench-glass-base),
linear-gradient(135deg, color-mix(in srgb, var(--capability-soft) 46%, transparent) 0%, transparent 52%, color-mix(in srgb, var(--capability-color) 11%, transparent) 100%),
var(--workbench-glass-theme-tint);
background-color: rgba(255, 255, 255, 0.64);
box-shadow:
0 10px 28px rgba(15, 23, 42, 0.055),
inset 0 1px 0 rgba(255, 255, 255, 0.84),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
backdrop-filter: var(--workbench-glass-blur);
-webkit-backdrop-filter: var(--workbench-glass-blur);
}
.capability-card::before,
.capability-card::after,
.workbench-card::before,
.workbench-card::after {
content: "";
position: absolute;
inset: 0;
z-index: 0;
display: block;
pointer-events: none;
}
.capability-card::before {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.12), transparent 38%),
var(--workbench-capability-bg-image) 0 0 / var(--workbench-capability-tile-size) repeat;
mix-blend-mode: soft-light;
opacity: var(--workbench-glass-noise-opacity);
}
.capability-card::after {
border: 1px solid rgba(255, 255, 255, 0.38);
border-left: 0;
border-radius: inherit;
background: var(--workbench-glass-highlight);
opacity: 0.58;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.58),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06);
transition: opacity 180ms var(--ease);
}
.capability-icon,
.capability-copy,
.capability-arrow {
position: relative;
z-index: 1;
}
.capability-icon {
border: 1px solid color-mix(in srgb, var(--capability-color) 18%, rgba(255, 255, 255, 0.68));
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.58), rgba(255, 255, 255, 0.24)),
color-mix(in srgb, var(--capability-soft) 72%, transparent);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.72),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08);
}
.workbench-card {
position: relative;
isolation: isolate;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
background:
linear-gradient(135deg, rgba(255, 255, 255, 0.78), rgba(255, 255, 255, 0.64) 55%, rgba(255, 255, 255, 0.72)),
var(--workbench-glass-theme-tint);
background-color: rgba(255, 255, 255, 0.66);
box-shadow:
0 12px 30px rgba(15, 23, 42, 0.052),
inset 0 1px 0 rgba(255, 255, 255, 0.86),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07);
backdrop-filter: var(--workbench-glass-blur);
-webkit-backdrop-filter: var(--workbench-glass-blur);
}
.workbench-card::before,
.workbench-card::after {
border-radius: inherit;
}
.workbench-card::before {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.1), transparent 42%),
var(--workbench-panel-bg-image) 0 0 / var(--workbench-panel-tile-size) repeat;
mix-blend-mode: soft-light;
opacity: calc(var(--workbench-glass-noise-opacity) * 0.8);
}
.workbench-card::after {
border: 1px solid rgba(255, 255, 255, 0.36);
background: var(--workbench-glass-highlight);
opacity: 0.56;
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.58),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.055);
transition: opacity 180ms var(--ease);
}
.workbench-card > * {
position: relative;
z-index: 1;
}
.todo-row,
.progress-row {
position: relative;
border-top: 0;
background: transparent;
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
}
.todo-row:first-child,
.progress-row:first-child {
box-shadow: none;
}
.todo-row:hover,
.progress-row:hover {
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.32), rgba(255, 255, 255, 0.18)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.035);
box-shadow: inset 0 1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14);
}
.capability-card:hover,
.workbench-card:hover {
box-shadow:
0 16px 36px rgba(15, 23, 42, 0.075),
inset 0 1px 0 rgba(255, 255, 255, 0.9),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.1);
}
.capability-card:hover::after,
.workbench-card:hover::after {
opacity: 0.88;
}
.capability-card:hover {
transform: translateY(-1px);
}

View File

@@ -78,10 +78,17 @@
gap: 10px; gap: 10px;
min-height: 0; min-height: 0;
padding: 7px 9px; padding: 7px 9px;
border: 1px solid var(--workbench-line-soft); border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
border-left: 2px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3); border-left: 2px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3);
border-radius: 4px; border-radius: 4px;
background: #ffffff; background:
linear-gradient(135deg, rgba(255, 255, 255, 0.76), rgba(255, 255, 255, 0.58)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.026);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.68),
inset 0 -1px 0 rgba(var(--theme-primary-rgb, 58, 124, 165), 0.055);
backdrop-filter: blur(10px) saturate(1.16);
-webkit-backdrop-filter: blur(10px) saturate(1.16);
transition: transition:
border-color 180ms var(--ease), border-color 180ms var(--ease),
background-color 180ms var(--ease); background-color 180ms var(--ease);
@@ -90,7 +97,9 @@
.insight-metric-row:hover, .insight-metric-row:hover,
.insight-profile-card:hover { .insight-profile-card:hover {
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22); border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.22);
background: #fbfdff; background:
linear-gradient(135deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.64)),
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
} }
.insight-metric-label, .insight-metric-label,

View File

@@ -15,7 +15,7 @@
} }
.assistant-hero { .assistant-hero {
--assistant-art-width: min(560px, 42vw); --assistant-bg-position: 56% center;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px; padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
} }
@@ -58,9 +58,7 @@
} }
.assistant-hero { .assistant-hero {
--assistant-art-width: min(620px, 44vw); --assistant-bg-position: 58% center;
--assistant-art-x: 48px;
--assistant-art-y: -10px;
padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px; padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px;
} }
@@ -112,12 +110,11 @@
} }
.assistant-hero { .assistant-hero {
--assistant-art-width: min(540px, 50vw); --assistant-bg-position: 62% center;
--assistant-art-x: 36px; --assistant-readability-mask:
--assistant-art-y: -8px; linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.88) 58%, rgba(255, 255, 255, 0.44) 100%);
background: --assistant-theme-tint:
linear-gradient(90deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.6) 56%, rgba(255, 255, 255, 0.22) 100%), linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.18) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.06) 58%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.14) 100%);
linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, color-mix(in srgb, var(--workbench-primary-soft) 40%, rgba(255, 255, 255, 0.5)) 58%, color-mix(in srgb, var(--workbench-secondary) 15%, rgba(255, 255, 255, 0.1)) 100%);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
} }
@@ -149,17 +146,23 @@
grid-template-rows: none; grid-template-rows: none;
gap: 14px; gap: 14px;
overflow: visible; overflow: visible;
--workbench-glass-base:
linear-gradient(135deg, rgba(255, 255, 255, 0.82), rgba(255, 255, 255, 0.68) 56%, rgba(255, 255, 255, 0.76));
--workbench-glass-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.065), rgba(var(--theme-primary-rgb, 58, 124, 165), 0.018));
--workbench-glass-noise-opacity: 0.008;
--workbench-glass-blur: blur(14px) saturate(1.2);
} }
.assistant-hero { .assistant-hero {
min-height: auto; min-height: auto;
--assistant-art-width: min(380px, 78vw); --assistant-bg-position: 68% center;
--assistant-art-x: 12px; --assistant-readability-mask:
--assistant-art-y: -6px; linear-gradient(180deg, rgba(255, 255, 255, 0.94) 0%, rgba(255, 255, 255, 0.88) 100%),
linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.72) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08) 100%);
padding: 24px 18px 24px; padding: 24px 18px 24px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.7) 100%),
color-mix(in srgb, var(--workbench-primary-soft) 22%, rgba(255, 255, 255, 0.5));
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
} }
@@ -262,7 +265,7 @@
} }
.assistant-hero { .assistant-hero {
--assistant-art-width: min(280px, 70vw); --assistant-bg-position: 72% center;
padding: 20px 14px 20px; padding: 20px 14px 20px;
} }
} }

View File

@@ -53,19 +53,25 @@
.workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; } .workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; }
.assistant-hero { .assistant-hero {
--assistant-art-width: min(680px, 46vw); --assistant-bg-position: center right;
--assistant-art-x: 56px; --assistant-bg-size: cover;
--assistant-art-y: -12px; --assistant-readability-mask:
linear-gradient(90deg, rgba(255, 255, 255, 0.95) 0%, rgba(255, 255, 255, 0.86) 42%, rgba(255, 255, 255, 0.44) 68%, rgba(255, 255, 255, 0.18) 100%);
--assistant-theme-tint:
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2) 0%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.07) 52%, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16) 100%);
position: relative; position: relative;
z-index: 2; z-index: 2;
min-height: 0; min-height: 0;
overflow: visible; overflow: hidden;
padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px; padding: var(--hero-padding-top) 20px var(--hero-padding-bottom) 52px;
border: 1px solid color-mix(in srgb, var(--workbench-primary) 14%, var(--workbench-line)); border: 1px solid color-mix(in srgb, var(--workbench-primary) 14%, var(--workbench-line));
border-radius: 4px; border-radius: 4px;
background: background:
linear-gradient(90deg, rgba(255, 255, 255, 0.8) 0%, rgba(255, 255, 255, 0.6) 44%, rgba(255, 255, 255, 0.2) 66%, rgba(255, 255, 255, 0.05) 100%), var(--assistant-readability-mask),
linear-gradient(135deg, rgba(255, 255, 255, 0.7) 0%, color-mix(in srgb, var(--workbench-primary-soft) 40%, rgba(255, 255, 255, 0.5)) 62%, color-mix(in srgb, var(--workbench-secondary) 15%, rgba(255, 255, 255, 0.1)) 100%); var(--assistant-theme-tint),
var(--assistant-bg-image) var(--assistant-bg-position) / var(--assistant-bg-size) no-repeat;
background-color: color-mix(in srgb, var(--workbench-primary-soft) 42%, #ffffff);
background-blend-mode: normal, color, luminosity;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.6); box-shadow: 0 4px 16px rgba(15, 23, 42, 0.04), inset 0 1px 0 rgba(255, 255, 255, 0.6);
@@ -73,15 +79,7 @@
} }
.assistant-hero::after { .assistant-hero::after {
content: ""; content: none;
position: absolute;
right: var(--assistant-art-x);
bottom: var(--assistant-art-y);
width: var(--assistant-art-width);
height: calc(100% + 28px);
background: var(--assistant-bg-image) right bottom / auto 112% no-repeat;
pointer-events: none;
z-index: 2;
} }
.assistant-hero::before { .assistant-hero::before {
@@ -90,7 +88,8 @@
inset: 0; inset: 0;
border-radius: inherit; border-radius: inherit;
background: background:
linear-gradient(90deg, rgba(255, 255, 255, 0.3) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 58%); linear-gradient(90deg, rgba(255, 255, 255, 0.28) 0%, rgba(255, 255, 255, 0.08) 42%, transparent 60%),
linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.08), transparent 56%);
pointer-events: none; pointer-events: none;
z-index: 1; z-index: 1;
} }
@@ -317,7 +316,6 @@
.capability-card { .capability-card {
position: relative; position: relative;
isolation: isolate;
display: grid; display: grid;
grid-template-columns: 40px minmax(0, 1fr) 10px; grid-template-columns: 40px minmax(0, 1fr) 10px;
align-items: center; align-items: center;
@@ -331,6 +329,11 @@
background: var(--workbench-surface); background: var(--workbench-surface);
text-align: left; text-align: left;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035); box-shadow: 0 1px 2px rgba(15, 23, 42, 0.035);
transition:
border-color 180ms var(--ease),
box-shadow 180ms var(--ease),
color 180ms var(--ease),
transform 180ms var(--ease);
} }
.capability-card::after { .capability-card::after {

View File

@@ -1,4 +1,7 @@
.topbar { .topbar {
box-sizing: border-box;
width: 100%;
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
@@ -14,6 +17,7 @@
.title-group { .title-group {
min-width: 0; min-width: 0;
max-width: 100%;
} }
.eyebrow { .eyebrow {
@@ -46,6 +50,8 @@
} }
.top-actions { .top-actions {
min-width: 0;
max-width: 100%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: flex-end; justify-content: flex-end;
@@ -282,6 +288,8 @@
} }
.kpi-chips { .kpi-chips {
min-width: 0;
max-width: 100%;
display: flex; display: flex;
gap: 10px; gap: 10px;
} }
@@ -531,6 +539,9 @@
@media (max-width: 960px) { @media (max-width: 960px) {
.topbar { .topbar {
width: 100%;
min-width: 0;
max-width: 100%;
flex-direction: column; flex-direction: column;
align-items: stretch; align-items: stretch;
} }
@@ -596,18 +607,26 @@
.kpi-chips { .kpi-chips {
width: 100%; width: 100%;
overflow-x: auto; min-width: 0;
padding-bottom: 2px; max-width: 100%;
scrollbar-width: thin; display: grid;
grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
gap: 8px;
overflow: visible;
padding-bottom: 0;
} }
.kpi-chip { .kpi-chip {
min-width: 118px; min-width: 0;
padding: 8px 12px; padding: 8px 12px;
} }
.chip-value,
.chip-label, .chip-label,
.chip-delta { .chip-delta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }

View File

@@ -161,6 +161,30 @@
font-size: var(--wb-fs-insight-h4, 14px); font-size: var(--wb-fs-insight-h4, 14px);
} }
.review-document-switch-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: start;
}
.review-side-head-copy {
min-width: 0;
display: grid;
gap: 4px;
text-align: left;
}
.review-side-head-copy strong {
line-height: 1.35;
}
.review-side-head-copy p {
max-width: 100%;
margin: 0;
overflow-wrap: anywhere;
word-break: normal;
}
.note-block { .note-block {
display: grid; display: grid;
gap: 6px; gap: 6px;
@@ -289,6 +313,26 @@
gap: 8px; gap: 8px;
} }
.review-document-nav {
flex: 0 0 auto;
align-items: center;
flex-wrap: nowrap;
padding: 4px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #ffffff;
white-space: nowrap;
}
.review-document-nav span {
min-width: 38px;
color: #334155;
font-size: 11px;
font-weight: 850;
text-align: center;
font-variant-numeric: tabular-nums;
}
.review-insight-switch-icon-btn, .review-insight-switch-icon-btn,
.flow-icon-btn, .flow-icon-btn,
.review-document-nav-btn, .review-document-nav-btn,

View File

@@ -16,6 +16,89 @@
background: #ffffff; background: #ffffff;
} }
.application-draft-preview.reimbursement-draft-preview {
max-width: 520px;
padding: 10px 12px;
border-color: #d8e4f0;
background: #ffffff;
}
.reimbursement-draft-card {
display: grid;
grid-template-columns: 30px minmax(0, 1fr);
align-items: center;
gap: 10px;
}
.reimbursement-draft-icon {
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.2);
border-radius: 4px;
background: #f7fbff;
color: var(--theme-primary-active, #255b7d);
font-size: 16px;
}
.reimbursement-draft-main {
min-width: 0;
display: grid;
gap: 3px;
}
.reimbursement-draft-main strong {
color: #102033;
font-size: 13px;
font-weight: 850;
line-height: 1.35;
}
.reimbursement-draft-main p {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.45;
}
.reimbursement-draft-main p span {
color: #1e293b;
font-weight: 850;
overflow-wrap: anywhere;
}
.reimbursement-draft-link {
display: inline;
margin-left: 8px;
padding: 0;
border: 0;
border-radius: 2px;
background: transparent;
color: var(--theme-primary-active, #255b7d);
font: inherit;
font-weight: 850;
line-height: inherit;
text-decoration: underline;
text-underline-offset: 3px;
cursor: pointer;
transition: color 0.18s ease, outline-color 0.18s ease;
}
.reimbursement-draft-link:hover:not(:disabled) {
color: var(--theme-primary, #3a7ca5);
}
.reimbursement-draft-link:focus-visible {
outline: 2px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
outline-offset: 2px;
}
.reimbursement-draft-link:disabled {
cursor: not-allowed;
opacity: 0.58;
}
.application-draft-preview .application-draft-head { .application-draft-preview .application-draft-head {
display: grid; display: grid;
grid-template-columns: 36px minmax(0, 1fr) auto; grid-template-columns: 36px minmax(0, 1fr) auto;

View File

@@ -1271,6 +1271,11 @@
} }
@media (max-width: 860px) { @media (max-width: 860px) {
.skill-center {
height: auto;
min-height: 100%;
}
.skill-list, .skill-list,
.detail-card, .detail-card,
.side-card, .side-card,
@@ -1278,6 +1283,25 @@
padding: 16px; padding: 16px;
} }
.skill-list {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
}
.skill-list .status-tabs {
flex-wrap: nowrap;
padding-bottom: 10px;
overflow-x: auto;
scrollbar-width: thin;
}
.skill-list .status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.list-toolbar, .list-toolbar,
.card-head, .card-head,
.detail-actions, .detail-actions,
@@ -1306,6 +1330,102 @@
width: min(100vw - 64px, 320px); width: min(100vw - 64px, 320px);
} }
.skill-list .table-wrap {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.skill-list .table-wrap table,
.skill-list .table-wrap thead,
.skill-list .table-wrap tbody,
.skill-list .table-wrap tr,
.skill-list .table-wrap th,
.skill-list .table-wrap td {
display: block;
}
.skill-list .table-wrap table {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.skill-list .table-wrap thead {
display: none;
}
.skill-list .table-wrap tbody {
display: grid;
gap: 10px;
}
.skill-list .table-wrap tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.skill-list .table-wrap td,
.audit-asset-table td:first-child,
.audit-asset-table td:not(:first-child) {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
color: #273142;
font-size: 13px;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.skill-list .table-wrap td:last-child {
border-bottom: 0;
}
.skill-list .table-wrap td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.skill-list .table-wrap td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.skill-list .table-wrap td:first-child::before {
display: none;
}
.skill-name-cell {
width: 100%;
}
.skill-name-cell span:last-child {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.list-foot {
justify-content: stretch;
}
.hero-stats, .hero-stats,
.form-grid, .form-grid,
.contract-grid { .contract-grid {
@@ -1618,3 +1738,91 @@
grid-column: span 1; grid-column: span 1;
} }
} }
@media (max-width: 860px) {
.skill-center :deep(.skill-list .table-wrap) {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.skill-center :deep(.skill-list .table-wrap table),
.skill-center :deep(.skill-list .table-wrap thead),
.skill-center :deep(.skill-list .table-wrap tbody),
.skill-center :deep(.skill-list .table-wrap tr),
.skill-center :deep(.skill-list .table-wrap th),
.skill-center :deep(.skill-list .table-wrap td) {
display: block;
}
.skill-center :deep(.skill-list .table-wrap table) {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.skill-center :deep(.skill-list .table-wrap thead) {
display: none;
}
.skill-center :deep(.skill-list .table-wrap tbody) {
display: grid;
gap: 10px;
}
.skill-center :deep(.skill-list .table-wrap tr) {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.skill-center :deep(.skill-list .table-wrap td) {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
color: #273142;
font-size: 13px;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.skill-center :deep(.skill-list .table-wrap td:last-child) {
border-bottom: 0;
}
.skill-center :deep(.skill-list .table-wrap td::before) {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.skill-center :deep(.skill-list .table-wrap td:first-child) {
grid-template-columns: 1fr;
padding-top: 0;
}
.skill-center :deep(.skill-list .table-wrap td:first-child::before) {
display: none;
}
.skill-center :deep(.skill-name-cell span:last-child) {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
}

View File

@@ -500,3 +500,49 @@
padding: 8px 16px; padding: 8px 16px;
} }
} }
@media (max-width: 760px) {
.budget-dialog-backdrop {
align-items: stretch;
justify-content: stretch;
padding: 0;
}
.budget-edit-dialog {
width: 100vw;
height: 100dvh;
max-height: 100dvh;
border-radius: 0;
}
.budget-edit-head {
min-height: 52px;
padding: 0 14px;
}
.budget-edit-body {
padding: 14px;
overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
.budget-edit-section + .budget-edit-section {
margin-top: 14px;
}
.budget-edit-table-wrap {
flex: 0 0 auto;
max-height: none;
}
.budget-edit-total {
height: auto;
min-height: 42px;
justify-content: space-between;
padding: 8px 12px;
}
.budget-edit-foot {
padding: 10px 14px calc(10px + env(safe-area-inset-bottom));
}
}

View File

@@ -445,12 +445,23 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.budget-center-page {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
}
.budget-list { .budget-list {
padding: 16px; height: auto;
min-height: 100%;
display: block;
overflow: visible;
padding: 12px;
} }
.budget-detail-page { .budget-detail-page {
padding: 16px 16px 0; padding: 12px 12px 0;
} }
.budget-select-filter, .budget-select-filter,
@@ -464,6 +475,121 @@
justify-content: space-between; justify-content: space-between;
} }
.budget-scope-tabs {
gap: 18px;
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.budget-scope-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.budget-toolbar .document-actions {
width: 100%;
justify-content: stretch;
}
.budget-table-wrap {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.budget-list-table,
.budget-list-table thead,
.budget-list-table tbody,
.budget-list-table tr,
.budget-list-table th,
.budget-list-table td {
display: block;
}
.budget-list-table,
.budget-list-table.all,
.budget-list-table.review,
.budget-list-table.archive {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.budget-list-table thead,
.budget-list-table colgroup {
display: none;
}
.budget-list-table tbody {
display: grid;
gap: 10px;
}
.budget-list-table tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
}
.budget-list-table td {
display: grid;
grid-template-columns: 86px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
color: #273142;
font-size: 13px;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.budget-list-table td:last-child {
border-bottom: 0;
}
.budget-list-table td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.budget-list-table td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.budget-list-table td:first-child::before {
display: none;
}
.budget-rate {
max-width: none;
justify-content: flex-start;
}
.budget-detail-page :deep(.detail-scroll) {
overflow: visible;
padding-right: 0;
}
.budget-detail-page :deep(.detail-grid) {
display: block;
}
.budget-period-grid { .budget-period-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -471,4 +597,71 @@
.budget-status-explain-list { .budget-status-explain-list {
grid-template-columns: minmax(0, 1fr); grid-template-columns: minmax(0, 1fr);
} }
.budget-detail-table-wrap {
overflow: visible;
}
.budget-detail-table,
.budget-detail-table thead,
.budget-detail-table tbody,
.budget-detail-table tr,
.budget-detail-table th,
.budget-detail-table td {
display: block;
}
.budget-detail-table {
min-width: 0;
border-collapse: separate;
}
.budget-detail-table thead {
display: none;
}
.budget-detail-table tbody {
display: grid;
gap: 10px;
}
.budget-detail-table tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.budget-detail-table td {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border: 0;
border-bottom: 1px dashed #edf2f7;
text-align: left;
white-space: normal;
}
.budget-detail-table td:last-child {
border-bottom: 0;
}
.budget-detail-table td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
}
.budget-detail-table td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.budget-detail-table td:first-child::before {
display: none;
}
} }

View File

@@ -168,3 +168,137 @@
justify-content: center; justify-content: center;
} }
} }
@media (max-width: 760px) {
.digital-employees-list {
padding: 12px 0;
overflow: auto;
}
.digital-employees-list > .status-tabs {
gap: 18px;
margin-top: 10px;
padding: 0 12px 1px;
}
.digital-employee-list-panel .table-wrap {
flex: 0 0 auto;
min-height: 0;
margin-top: 12px;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.digital-employee-list-panel .list-foot {
flex: 0 0 auto;
padding: 0 12px;
}
.digital-employees-table,
.digital-employees-table tbody,
.digital-employees-table tr,
.digital-employees-table td {
display: block;
}
.digital-employees-table {
width: 100%;
min-width: 0;
border-collapse: separate;
border-spacing: 0;
}
.digital-employees-table colgroup,
.digital-employees-table thead {
display: none;
}
.digital-employees-table tbody {
display: grid;
gap: 10px;
}
.digital-employees-table tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 6px;
background: #fff;
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06);
}
.digital-employees-table td {
min-height: 34px;
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 7px 0;
border: 0;
border-bottom: 1px dashed #e2e8f0;
color: #334155;
text-align: left;
white-space: normal;
}
.digital-employees-table td:last-child {
border-bottom: 0;
}
.digital-employees-table td::before {
color: #64748b;
font-size: 12px;
font-weight: 760;
line-height: 1.4;
}
.digital-employees-table td[data-label]::before {
content: attr(data-label);
}
.digital-employees-table td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.digital-employees-table td:first-child::before {
display: none;
}
.digital-employees-table .doc-kind-tag,
.digital-employees-table .type-tag,
.digital-employees-table .status-tag {
width: fit-content;
max-width: 100%;
justify-self: start;
white-space: normal;
}
.digital-skill-cell {
grid-template-columns: 38px minmax(0, 1fr);
}
.digital-skill-cell .doc-id {
white-space: normal;
}
}
@media (max-width: 480px) {
.digital-employees-table td {
grid-template-columns: 76px minmax(0, 1fr);
gap: 8px;
}
.digital-skill-cell {
grid-template-columns: 34px minmax(0, 1fr);
gap: 8px;
}
.digital-skill-avatar {
width: 34px;
height: 34px;
border-radius: 8px;
}
}

View File

@@ -222,8 +222,19 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.documents-page {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
}
.documents-list { .documents-list {
padding: 16px; height: auto;
min-height: 100%;
display: block;
overflow: visible;
padding: 12px;
} }
.document-status-filter { .document-status-filter {

View File

@@ -1202,6 +1202,13 @@ td.cell-updated {
} }
@media (max-width: 860px) { @media (max-width: 860px) {
.employee-center,
.employee-list,
.employee-detail {
height: auto;
min-height: 100%;
}
.employee-list, .employee-list,
.detail-card, .detail-card,
.side-card, .side-card,
@@ -1209,6 +1216,15 @@ td.cell-updated {
padding: 16px; padding: 16px;
} }
.employee-center,
.employee-list {
overflow: visible;
}
.employee-list {
display: block;
}
.list-toolbar, .list-toolbar,
.card-head, .card-head,
.detail-actions, .detail-actions,
@@ -1223,6 +1239,21 @@ td.cell-updated {
overflow-x: auto; overflow-x: auto;
} }
.status-tabs {
gap: 18px;
flex-wrap: nowrap;
scrollbar-width: thin;
}
.status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.status-tabs button span {
white-space: nowrap;
}
.list-foot { .list-foot {
grid-template-columns: 1fr; grid-template-columns: 1fr;
justify-items: stretch; justify-items: stretch;
@@ -1237,6 +1268,20 @@ td.cell-updated {
width: 100%; width: 100%;
} }
.toolbar-actions {
width: 100%;
flex-wrap: wrap;
}
.ghost-filter-btn,
.template-btn,
.export-btn,
.create-btn {
flex: 1 1 140px;
justify-content: center;
white-space: nowrap;
}
.picker-popover { .picker-popover {
width: min(280px, calc(100vw - 64px)); width: min(280px, calc(100vw - 64px));
} }
@@ -1246,6 +1291,18 @@ td.cell-updated {
justify-self: stretch; justify-self: stretch;
} }
.pager {
width: 100%;
max-width: 100%;
justify-content: flex-start;
overflow-x: auto;
scrollbar-width: thin;
}
.pager button {
flex: 0 0 auto;
}
.hero-stats, .hero-stats,
.form-grid, .form-grid,
.role-grid { .role-grid {
@@ -1265,4 +1322,99 @@ td.cell-updated {
.history-row-time { .history-row-time {
text-align: left; text-align: left;
} }
.employee-list .table-wrap {
flex: none;
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.employee-list .table-wrap table,
.employee-list .table-wrap thead,
.employee-list .table-wrap tbody,
.employee-list .table-wrap tr,
.employee-list .table-wrap th,
.employee-list .table-wrap td {
display: block;
}
.employee-list .table-wrap table {
min-width: 0;
width: 100%;
border-collapse: separate;
}
.employee-list .table-wrap thead,
.employee-list .table-wrap colgroup {
display: none;
}
.employee-list .table-wrap tbody {
display: grid;
gap: 10px;
}
.employee-list .table-wrap tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.employee-list .table-wrap tr:hover {
background: #f8fbff;
}
.employee-list .table-wrap td {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: start;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.employee-list .table-wrap td:last-child {
border-bottom: 0;
}
.employee-list .table-wrap td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.7;
}
.employee-list .table-wrap td > * {
min-width: 0;
}
.employee-list .table-wrap td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.employee-list .table-wrap td:first-child::before {
display: none;
}
.employee-list .table-wrap .employee-cell {
grid-template-columns: 38px minmax(0, 1fr);
}
.employee-list .table-wrap .role-stack {
justify-content: flex-start;
}
} }

File diff suppressed because it is too large Load Diff

View File

@@ -1163,6 +1163,46 @@ th {
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.knowledge-page {
height: auto;
min-height: 100%;
overflow: visible;
}
.knowledge-grid,
.knowledge-main,
.library-panel,
.library-body,
.document-area {
height: auto;
min-height: 0;
overflow: visible;
}
.knowledge-grid,
.library-panel,
.library-body,
.document-area {
display: block;
}
.knowledge-grid,
.knowledge-main,
.library-panel,
.library-body,
.document-area,
.doc-table-wrap,
.folder-tree {
width: 100%;
max-width: 100%;
min-width: 0;
box-sizing: border-box;
}
.library-panel {
padding: 12px;
}
.panel-title, .panel-title,
.preview-head, .preview-head,
.llm-wiki-section-head, .llm-wiki-section-head,
@@ -1177,6 +1217,130 @@ th {
width: 100%; width: 100%;
} }
.library-body {
margin-top: 12px;
}
.folder-rail {
display: block;
padding: 0 0 10px;
}
.folder-tree {
display: flex;
gap: 8px;
overflow-x: auto;
overflow-y: hidden;
padding-bottom: 2px;
scrollbar-width: thin;
}
.folder-tree button {
flex: 0 0 auto;
width: max-content;
min-width: 132px;
}
.upload-zone {
margin-bottom: 10px;
}
.doc-table-wrap {
overflow: visible;
}
.knowledge-document-table,
.knowledge-document-table thead,
.knowledge-document-table tbody,
.knowledge-document-table tr,
.knowledge-document-table th,
.knowledge-document-table td {
display: block;
}
.knowledge-document-table {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.knowledge-document-table thead {
display: none;
}
.knowledge-document-table tbody {
display: grid;
gap: 10px;
}
.knowledge-document-table tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05);
}
.knowledge-document-table td,
.knowledge-document-table th:not(:first-child),
.knowledge-document-table td:not(:first-child),
.knowledge-document-table td:first-child {
display: grid;
grid-template-columns: 78px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border: 0;
border-bottom: 1px dashed #edf2f7;
text-align: left;
white-space: normal;
}
.knowledge-document-table td:last-child {
border-bottom: 0;
}
.knowledge-document-table td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.knowledge-document-table td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.knowledge-document-table td:first-child::before {
display: none;
}
.knowledge-document-table .empty-row {
display: block;
padding: 12px 0;
border-bottom: 0;
text-align: center;
}
.knowledge-document-table .empty-row::before {
display: none;
}
.file-name {
white-space: normal;
}
.state-cell {
justify-items: start;
}
.row-actions {
justify-content: flex-start;
}
.summary-grid, .summary-grid,
.list-foot { .list-foot {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -676,10 +676,139 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
.receipt-folder-page {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
}
.receipt-folder-list { .receipt-folder-list {
height: auto;
min-height: 100%;
display: block;
overflow: visible;
padding: 12px; padding: 12px;
} }
.receipt-status-tabs {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
}
.receipt-status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
.receipt-folder-list .document-actions,
.receipt-folder-list .filter-set,
.receipt-folder-list .list-search,
.receipt-folder-list .filter-btn,
.receipt-folder-list .create-request-btn,
.receipt-folder-list .page-size-select {
width: 100%;
}
.receipt-folder-list .table-wrap {
min-height: 0;
max-height: none;
display: block;
overflow: visible;
border: 0;
border-radius: 0;
background: transparent;
}
.receipt-folder-list .table-wrap table,
.receipt-folder-list .table-wrap thead,
.receipt-folder-list .table-wrap tbody,
.receipt-folder-list .table-wrap tr,
.receipt-folder-list .table-wrap th,
.receipt-folder-list .table-wrap td {
display: block;
}
.receipt-folder-list .table-wrap table {
width: 100%;
min-width: 0;
border-collapse: separate;
}
.receipt-folder-list .table-wrap thead,
.receipt-folder-list .table-wrap colgroup {
display: none;
}
.receipt-folder-list .table-wrap tbody {
display: grid;
gap: 10px;
}
.receipt-folder-list .table-wrap tr {
padding: 12px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
box-shadow: 0 6px 18px rgba(15, 23, 42, .05);
}
.receipt-folder-list .table-wrap td {
display: grid;
grid-template-columns: 82px minmax(0, 1fr);
align-items: center;
gap: 10px;
min-height: 30px;
padding: 7px 0;
border-bottom: 1px dashed #edf2f7;
color: #273142;
font-size: 13px;
line-height: 1.45;
text-align: left;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.receipt-folder-list .table-wrap td:last-child {
border-bottom: 0;
}
.receipt-folder-list .table-wrap td::before {
content: attr(data-label);
color: #64748b;
font-size: 12px;
font-weight: 800;
line-height: 1.6;
}
.receipt-folder-list .table-wrap td:first-child {
grid-template-columns: 1fr;
padding-top: 0;
}
.receipt-folder-list .table-wrap td:first-child::before {
display: none;
}
.receipt-folder-list td:first-child .doc-id {
white-space: normal;
overflow: visible;
text-overflow: clip;
}
.receipt-folder-list .list-foot {
display: grid;
justify-items: stretch;
}
.receipt-folder-list .pager {
width: 100%;
justify-content: flex-start;
overflow-x: auto;
}
.receipt-detail-toolbar, .receipt-detail-toolbar,
.receipt-toolbar-actions, .receipt-toolbar-actions,
.receipt-preview-tools { .receipt-preview-tools {

View File

@@ -804,6 +804,10 @@
} }
@media (max-width: 640px) { @media (max-width: 640px) {
.settings-shell {
overflow: hidden;
}
.settings-toolbar { .settings-toolbar {
padding: 16px; padding: 16px;
} }
@@ -824,4 +828,26 @@
.settings-nav { .settings-nav {
padding: 16px 12px 12px; padding: 16px 12px 12px;
} }
.settings-nav-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
max-height: 188px;
overflow-x: hidden;
overflow-y: auto;
padding-bottom: 0;
scrollbar-width: thin;
}
.settings-nav-item {
min-width: 0;
min-height: 54px;
padding: 9px 10px;
}
.nav-item-copy strong,
.nav-item-copy small {
overflow: hidden;
text-overflow: ellipsis;
}
} }

View File

@@ -317,41 +317,75 @@
} }
@media (max-width: 760px) { @media (max-width: 760px) {
:global(.assistant-el-overlay .el-overlay-dialog) {
height: 100dvh;
max-height: 100dvh;
padding: 0;
}
:global(.assistant-el-dialog.el-dialog.is-fullscreen) {
border-radius: 0;
padding: 0;
}
.assistant-overlay { .assistant-overlay {
--assistant-viewport-inset: 10px; --assistant-viewport-inset: 0;
} }
:global(.assistant-el-overlay) { :global(.assistant-el-overlay) {
--assistant-viewport-inset: 10px; --assistant-viewport-inset: 0;
} }
.assistant-modal, .assistant-modal,
.assistant-modal-stage { .assistant-modal-stage {
border-radius: 4px; width: 100%;
height: 100%;
min-height: 0;
max-height: 100%;
border-radius: 0;
}
.assistant-modal-stage {
height: 100dvh;
max-height: 100dvh;
border: 0;
box-shadow: none;
background: #f6f9fc;
} }
.assistant-header { .assistant-header {
padding: 18px 18px 16px; min-height: 58px;
align-items: flex-start; padding: calc(10px + env(safe-area-inset-top, 0px)) 138px 10px 12px;
flex-direction: column; align-items: center;
flex-direction: row;
border-bottom: 1px solid #e5edf5;
} }
.assistant-header-actions { .assistant-header-actions {
top: 18px; top: calc(9px + env(safe-area-inset-top, 0px));
right: 18px; right: 10px;
gap: 10px; gap: 6px;
width: auto; width: auto;
justify-content: space-between; justify-content: flex-end;
} }
.assistant-toggle-btn, .assistant-toggle-btn,
.session-trash-btn, .session-trash-btn,
.assistant-close-btn, .assistant-close-btn,
.close-btn { .close-btn {
width: 40px; width: 38px;
height: 40px; height: 38px;
border-radius: 4px; border-radius: 4px;
font-size: 15px;
}
.assistant-title {
font-size: 16px; font-size: 16px;
line-height: 1.25;
}
.assistant-subtitle {
display: none;
} }
.flow-step-card header { .flow-step-card header {
@@ -359,16 +393,87 @@
} }
.assistant-layout { .assistant-layout {
padding: 14px; position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 0;
padding: 0;
overflow: hidden;
}
.dialog-panel {
border: 0;
border-radius: 0;
box-shadow: none;
}
.insight-panel-shell {
position: absolute;
inset: 0;
z-index: 70;
width: 100%;
max-height: none;
margin-left: 0;
transform: translateX(100%);
pointer-events: none;
transition: transform 240ms cubic-bezier(0.22, 1, 0.36, 1);
}
.assistant-layout.has-insight .insight-panel-shell {
transform: translateX(0);
pointer-events: auto;
}
.insight-panel-shell.collapsed {
width: 100%;
transform: translateX(100%);
}
.insight-panel {
border-radius: 0;
} }
.composer-row { .composer-row {
display: grid;
grid-template-columns: minmax(0, 1fr) var(--composer-control-size, 40px);
align-items: end;
gap: 8px; gap: 8px;
--composer-control-size: 40px; --composer-control-size: 40px;
} }
.composer-leading-actions {
grid-column: 1 / -1;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
width: 100%;
}
.composer-leading-actions .composer-side-btn,
.composer-leading-actions .tool-btn {
width: 100%;
}
.composer-date-anchor,
.travel-calculator-anchor {
min-width: 0;
}
.composer-row .composer-shell {
min-width: 0;
}
.composer-shell-body {
align-items: flex-start;
padding: 6px 10px;
}
.composer-shell textarea { .composer-shell textarea {
min-height: 32px; flex-basis: 100%;
min-height: 40px;
max-height: 104px;
padding: 8px 2px;
line-height: 20px;
} }
.travel-calculator-form { .travel-calculator-form {
@@ -376,20 +481,87 @@
} }
.dialog-toolbar { .dialog-toolbar {
padding: 16px 16px 12px; display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 8px;
padding: 8px 10px;
overflow: visible;
}
.dialog-toolbar-label {
min-width: 0;
}
.shortcut-chip-wrap {
width: 100%;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
overflow: visible;
} }
.shortcut-chip { .shortcut-chip {
width: 100%; width: 100%;
min-width: 0;
justify-content: center; justify-content: center;
} }
.shortcut-chip span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.message-list { .message-list {
padding: 16px; padding: 12px 10px;
gap: 10px;
}
.message-avatar {
width: 32px;
height: 32px;
}
.message-bubble {
max-width: 100%;
padding: 10px 12px;
}
.message-suggested-actions {
grid-template-columns: 1fr;
} }
.composer { .composer {
padding: 0 16px 16px; gap: 8px;
padding: 8px 10px calc(10px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid #e5edf5;
background: #fff;
}
.composer-files-panel {
max-height: 30dvh;
overflow-y: auto;
padding: 10px;
}
.composer-date-popover,
.travel-calculator-popover {
position: fixed;
left: 10px;
right: 10px;
bottom: calc(150px + env(safe-area-inset-bottom, 0px));
width: auto;
max-height: min(58dvh, 420px);
overflow-y: auto;
}
.composer-date-fields-range {
grid-template-columns: 1fr;
}
.composer-date-range-sep {
display: none;
} }
.composer-files-head, .composer-files-head,

View File

@@ -769,6 +769,15 @@
background: #dbe4ee; background: #dbe4ee;
} }
.application-leader-opinion-timeline.is-single {
padding-left: 0;
}
.application-leader-opinion-timeline.is-single::before,
.application-leader-opinion-timeline.is-single .application-leader-opinion-event::before {
display: none;
}
.application-leader-opinion-event { .application-leader-opinion-event {
position: relative; position: relative;
display: grid; display: grid;

View File

@@ -194,7 +194,7 @@
:class="{ 'is-disabled': skill.usesJsonRiskRule && skill.statusValue === 'generating' }" :class="{ 'is-disabled': skill.usesJsonRiskRule && skill.statusValue === 'generating' }"
@click="emit('open-asset-detail', skill)" @click="emit('open-asset-detail', skill)"
> >
<td> <td :data-label="tableColumns.name">
<div class="skill-name-cell"> <div class="skill-name-cell">
<span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span> <span class="skill-avatar" :class="skill.badgeTone">{{ skill.short }}</span>
<div> <div>
@@ -203,8 +203,8 @@
</div> </div>
</div> </div>
</td> </td>
<td>{{ skill.category }}</td> <td :data-label="tableColumns.category">{{ skill.category }}</td>
<td> <td :data-label="tableColumns.owner">
<span <span
v-if="skill.usesJsonRiskRule" v-if="skill.usesJsonRiskRule"
class="json-risk-meta-badge" class="json-risk-meta-badge"
@@ -214,20 +214,20 @@
</span> </span>
<template v-else>{{ skill.owner }}</template> <template v-else>{{ skill.owner }}</template>
</td> </td>
<td><span class="scope-pill">{{ skill.scope }}</span></td> <td :data-label="tableColumns.scope"><span class="scope-pill">{{ skill.scope }}</span></td>
<td v-if="showRuntimeColumn">{{ skill.model }}</td> <td v-if="showRuntimeColumn" :data-label="tableColumns.runtime">{{ skill.model }}</td>
<td v-if="showVersionColumn">{{ skill.versionDisplay || skill.version }}</td> <td v-if="showVersionColumn" :data-label="tableColumns.version">{{ skill.versionDisplay || skill.version }}</td>
<td v-if="showStatusColumn"> <td v-if="showStatusColumn" :data-label="tableColumns.status || '状态'">
<span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span> <span class="status-pill" :class="skill.statusTone">{{ skill.status }}</span>
</td> </td>
<td v-if="showMetricColumn">{{ skill.hitRate }}</td> <td v-if="showMetricColumn" :data-label="tableColumns.metric">{{ skill.hitRate }}</td>
<td v-if="showOnlineColumn"> <td v-if="showOnlineColumn" data-label="是否上线">
<span class="status-pill" :class="skill.isOnlineTone">{{ skill.isOnlineLabel }}</span> <span class="status-pill" :class="skill.isOnlineTone">{{ skill.isOnlineLabel }}</span>
</td> </td>
<td v-if="showEnabledColumn"> <td v-if="showEnabledColumn" data-label="是否启用">
<span class="status-pill" :class="skill.isEnabledTone">{{ skill.isEnabledLabel }}</span> <span class="status-pill" :class="skill.isEnabledTone">{{ skill.isEnabledLabel }}</span>
</td> </td>
<td>{{ skill.updatedAt }}</td> <td :data-label="tableColumns.updatedAt || '最近更新'">{{ skill.updatedAt }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -156,15 +156,15 @@
<strong class="doc-id">{{ employee.name }}</strong> <strong class="doc-id">{{ employee.name }}</strong>
</div> </div>
</td> </td>
<td><span class="doc-kind-tag application">{{ employee.skillCategory }}</span></td> <td data-label="技能类型"><span class="doc-kind-tag application">{{ employee.skillCategory }}</span></td>
<td>{{ employee.owner }}</td> <td data-label="维护归口">{{ employee.owner }}</td>
<td><span class="type-tag other">{{ employee.scope }}</span></td> <td data-label="执行计划"><span class="type-tag other">{{ employee.scope }}</span></td>
<td>{{ employee.executionMode }}</td> <td data-label="触发方式">{{ employee.executionMode }}</td>
<td> <td data-label="资产状态">
<span :class="['status-tag', employee.statusTone]">{{ employee.status }}</span> <span :class="['status-tag', employee.statusTone]">{{ employee.status }}</span>
</td> </td>
<td><span :class="['status-tag', employee.enabledTone]">{{ employee.enabledLabel }}</span></td> <td data-label="启动状态"><span :class="['status-tag', employee.enabledTone]">{{ employee.enabledLabel }}</span></td>
<td>{{ employee.updatedAt || '-' }}</td> <td data-label="最近更新">{{ employee.updatedAt || '-' }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -292,4 +292,21 @@ function changePageSize(size) {
overflow: hidden; overflow: hidden;
} }
@media (max-width: 760px) {
.digital-employee-list-panel {
overflow: auto;
}
.digital-employee-list-panel :deep(.table-wrap) {
flex: 0 0 auto;
min-height: 0;
display: block;
overflow: visible;
}
.digital-employee-list-panel :deep(.list-foot) {
flex: 0 0 auto;
padding: 0 12px 12px;
}
}
</style> </style>

View File

@@ -7,7 +7,7 @@
note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。" note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。"
/> />
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${homepageBackground})` }"> <article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${workbenchHeroBackground})` }">
<div class="assistant-copy"> <div class="assistant-copy">
<h1>{{ displayUserName }}我是您的 <span>AI 费用助手</span></h1> <h1>{{ displayUserName }}我是您的 <span>AI 费用助手</span></h1>
@@ -358,16 +358,17 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import PanelHead from '../shared/PanelHead.vue' import PanelHead from '../shared/PanelHead.vue'
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue' import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue' import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
import homepageBackground from '../../assets/homepage_backgraound.png' import workbenchHeroBackground from '../../assets/personal-workbench-hero-bg-theme-base.webp'
import { useSystemState } from '../../composables/useSystemState.js' import { useSystemState } from '../../composables/useSystemState.js'
import { useToast } from '../../composables/useToast.js' import { useToast } from '../../composables/useToast.js'
import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js' import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js'
import { import {
assistantCapabilities,
buildExpenseStatItems, buildExpenseStatItems,
filterAssistantCapabilitiesForUser,
progressItems, progressItems,
progressSteps, progressSteps,
quickPromptItems, quickPromptItems,
resolveWorkbenchCapabilityGridClass,
todoItems, todoItems,
} from '../../data/personalWorkbench.js' } from '../../data/personalWorkbench.js'
import { fetchAgentRuns } from '../../services/agentAssets.js' import { fetchAgentRuns } from '../../services/agentAssets.js'
@@ -433,9 +434,6 @@ let employeeProfileLoadSeq = 0
const MAX_ATTACHMENTS = 10 const MAX_ATTACHMENTS = 10
const SESSION_TYPE_EXPENSE = 'expense' const SESSION_TYPE_EXPENSE = 'expense'
const SESSION_TYPE_KNOWLEDGE = 'knowledge' const SESSION_TYPE_KNOWLEDGE = 'knowledge'
const FINANCIAL_CAPABILITY_KEYS = new Set(['budget-planning', 'finance-analysis'])
const FINANCIAL_CAPABILITY_ROLE_CODES = new Set(['budget_monitor', 'executive', 'admin'])
const FINANCIAL_CAPABILITY_ROLE_LABELS = new Set(['预算监控员', '高级财务人员', '管理员'])
const hasExpenseConversation = computed(() => const hasExpenseConversation = computed(() =>
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId) Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
@@ -456,28 +454,8 @@ const composerPendingLabel = computed(() => {
} }
return '' return ''
}) })
const currentRoleCodes = computed(() => { const visibleAssistantCapabilities = computed(() => filterAssistantCapabilitiesForUser(currentUser.value))
const user = currentUser.value || {} const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value))
const rawCodes = Array.isArray(user.roleCodes)
? user.roleCodes
: Array.isArray(user.role_codes)
? user.role_codes
: []
return new Set(rawCodes.map((code) => String(code || '').trim().toLowerCase()).filter(Boolean))
})
const canViewFinancialCapabilities = computed(() => {
const user = currentUser.value || {}
const roleLabel = String(user.role || '').trim()
return Boolean(user.isAdmin)
|| FINANCIAL_CAPABILITY_ROLE_LABELS.has(roleLabel)
|| Array.from(currentRoleCodes.value).some((code) => FINANCIAL_CAPABILITY_ROLE_CODES.has(code))
})
const visibleAssistantCapabilities = computed(() =>
assistantCapabilities.filter((item) => canViewFinancialCapabilities.value || !FINANCIAL_CAPABILITY_KEYS.has(item.key))
)
const capabilityGridClass = computed(() =>
canViewFinancialCapabilities.value ? 'capability-grid--privileged' : 'capability-grid--standard'
)
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary)) const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
const visibleExpenseStatItems = computed(() => { const visibleExpenseStatItems = computed(() => {
const preferredKeys = ['monthly-amount', 'monthly-count', 'in-review', 'pending-payment'] const preferredKeys = ['monthly-amount', 'monthly-count', 'in-review', 'pending-payment']
@@ -817,6 +795,7 @@ watch(currentUserProfileKey, (nextKey, previousKey) => {
</script> </script>
<style scoped src="../../assets/styles/components/personal-workbench.css"></style> <style scoped src="../../assets/styles/components/personal-workbench.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-glass.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-composer-date.css"></style> <style scoped src="../../assets/styles/components/personal-workbench-composer-date.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style> <style scoped src="../../assets/styles/components/personal-workbench-insights.css"></style>
<style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style> <style scoped src="../../assets/styles/components/personal-workbench-responsive.css"></style>

View File

@@ -28,7 +28,7 @@
<section class="budget-report-main"> <section class="budget-report-main">
<article class="budget-report-chart-panel"> <article class="budget-report-chart-panel">
<div class="budget-report-section-head"> <div class="budget-report-section-head">
<strong>上季度费用结构</strong> <strong>{{ expenseStructureTitle }}</strong>
<span>{{ report.centerLabel }}</span> <span>{{ report.centerLabel }}</span>
</div> </div>
<DonutChart <DonutChart
@@ -52,7 +52,7 @@
<section class="budget-report-detail-panel"> <section class="budget-report-detail-panel">
<div class="budget-report-section-head"> <div class="budget-report-section-head">
<strong>费用类型拆解</strong> <strong>费用类型拆解</strong>
<span>用于编制下一季度预算</span> <span>用于编制{{ report.periodType || '下一期预算' }}</span>
</div> </div>
<div class="budget-report-expense-list"> <div class="budget-report-expense-list">
<article <article
@@ -83,17 +83,21 @@
<section class="budget-report-editor-panel"> <section class="budget-report-editor-panel">
<div class="budget-report-section-head"> <div class="budget-report-section-head">
<strong>预算构成编辑</strong> <strong>预算构成编辑</strong>
<span>{{ report.periodType || '预算' }} · 可直接调整</span> <span>{{ editorSubtitle }}</span>
</div> </div>
<div class="budget-editor-table" role="table" aria-label="预算构成编辑表"> <div
class="budget-editor-table"
:class="{ 'is-review': isReviewMode }"
role="table"
aria-label="预算构成编辑表"
>
<div class="budget-editor-row head" role="row"> <div class="budget-editor-row head" role="row">
<span role="columnheader">费用类型</span> <span role="columnheader">费用类型</span>
<span role="columnheader">编制金额</span> <span role="columnheader">预算金额</span>
<span role="columnheader">提醒</span> <span v-if="isReviewMode" role="columnheader">建议预算</span>
<span role="columnheader">告警</span>
<span role="columnheader">风险</span>
<span role="columnheader">预算说明</span> <span role="columnheader">预算说明</span>
<span v-if="isReviewMode" role="columnheader">建议</span>
</div> </div>
<div <div
@@ -104,43 +108,50 @@
> >
<strong role="cell">{{ row.name }}</strong> <strong role="cell">{{ row.name }}</strong>
<label role="cell"> <label role="cell">
<span>金额</span> <span>预算金额</span>
<input v-model.number="row.budgetAmount" type="number" min="0" step="1000" /> <input
v-model.number="row.budgetAmount"
type="number"
min="0"
step="1000"
:readonly="isReviewMode"
/>
</label> </label>
<label role="cell"> <label v-if="isReviewMode" role="cell">
<span>提醒</span> <span>建议预算</span>
<input v-model.number="row.reminderThreshold" type="number" min="0" max="100" step="1" /> <input v-model.number="row.suggestedBudget" type="number" min="0" step="1000" />
</label> </label>
<label role="cell"> <label class="budget-editor-note-cell" role="cell">
<span>告警</span> <span>预算说明</span>
<input v-model.number="row.alertThreshold" type="number" min="0" max="100" step="1" /> <textarea v-model="row.submittedNote" :readonly="isReviewMode" rows="2" />
</label> </label>
<label role="cell"> <label v-if="isReviewMode" class="budget-editor-note-cell" role="cell">
<span>风险</span> <span>建议</span>
<input v-model.number="row.riskThreshold" type="number" min="0" max="100" step="1" /> <textarea v-model="row.financeSuggestion" rows="2" />
</label> </label>
<textarea v-model="row.note" role="cell" rows="2" />
</div> </div>
</div> </div>
<footer class="budget-editor-footer"> <footer class="budget-editor-footer">
<div> <div>
<span>当前编制总额</span> <span>{{ totalLabel }}</span>
<strong>{{ editableTotalDisplay }}</strong> <strong>{{ editableTotalDisplay }}</strong>
<small>{{ draftStatusText }}</small> <small>{{ draftStatusText }}</small>
</div> </div>
<button type="button" class="budget-editor-secondary" @click="applyRecommendedBudget"> <button
应用建议 type="button"
</button> class="budget-editor-primary"
<button type="button" class="budget-editor-primary" @click="generateBudgetDraft"> :class="{ danger: isReviewMode && hasReviewChanges }"
生成预算草案 @click="submitBudgetEditorAction"
>
{{ primaryActionLabel }}
</button> </button>
</footer> </footer>
</section> </section>
<section class="budget-report-action-panel"> <section class="budget-report-action-panel">
<div> <div>
<strong>编制建议</strong> <strong>{{ recommendationTitle }}</strong>
<p v-for="item in report.recommendations" :key="item">{{ item }}</p> <p v-for="item in report.recommendations" :key="item">{{ item }}</p>
</div> </div>
<span>{{ report.generatedAt }}</span> <span>{{ report.generatedAt }}</span>
@@ -162,6 +173,7 @@ const props = defineProps({
const draftRows = reactive([]) const draftRows = reactive([])
const draftStatus = ref('editing') const draftStatus = ref('editing')
const initialReviewSnapshot = ref('')
const formatAmount = (value) => const formatAmount = (value) =>
`¥${Number(value || 0).toLocaleString('zh-CN', { `¥${Number(value || 0).toLocaleString('zh-CN', {
@@ -177,44 +189,94 @@ function resetDraftRows() {
key: item.key, key: item.key,
name: item.name, name: item.name,
budgetAmount: Number(item.budgetAmount ?? item.recommendedBudget ?? 0), budgetAmount: Number(item.budgetAmount ?? item.recommendedBudget ?? 0),
reminderThreshold: Number(item.reminderThreshold ?? 70), suggestedBudget: Number(item.suggestedBudget ?? item.recommendedBudget ?? item.budgetAmount ?? 0),
alertThreshold: Number(item.alertThreshold ?? 80), submittedNote: String(item.submittedNote || item.note || item.suggestion || ''),
riskThreshold: Number(item.riskThreshold ?? 90), financeSuggestion: String(item.financeSuggestion || '')
note: String(item.note || item.suggestion || '')
}))) })))
) )
draftStatus.value = 'editing' draftStatus.value = 'editing'
initialReviewSnapshot.value = buildReviewSnapshot()
} }
watch(() => props.report, resetDraftRows, { immediate: true }) watch(() => props.report, resetDraftRows, { immediate: true })
const editableTotalDisplay = computed(() => const editableTotalDisplay = computed(() =>
formatAmount(draftRows.reduce((sum, item) => sum + Number(item.budgetAmount || 0), 0)) formatAmount(draftRows.reduce((sum, item) => {
const value = isReviewMode.value ? item.suggestedBudget : item.budgetAmount
return sum + Number(value || 0)
}, 0))
)
const isReviewMode = computed(() =>
props.report.mode === 'review' || props.report.editableDraft?.mode === 'review'
)
const editorSubtitle = computed(() =>
isReviewMode.value
? '高级财务审核 · 修改建议预算或建议后将回退预算'
: `${props.report.periodType || '预算'} · 仅编辑本部门预算`
)
const totalLabel = computed(() => isReviewMode.value ? '建议预算总额' : '当前编制总额')
function buildReviewSnapshot() {
return JSON.stringify(draftRows.map((row) => ({
key: row.key,
suggestedBudget: Number(row.suggestedBudget || 0),
financeSuggestion: String(row.financeSuggestion || '').trim()
})))
}
const hasReviewChanges = computed(() =>
isReviewMode.value && buildReviewSnapshot() !== initialReviewSnapshot.value
) )
const draftStatusText = computed(() => const draftStatusText = computed(() =>
draftStatus.value === 'generated' draftStatus.value === 'returned'
? '已生成本轮预算草案,后续可提交高级财务审核' ? '已标记回退预算,请预算管理者按建议调整后再次提交'
: '调整后可生成预算草案' : draftStatus.value === 'formed'
? '已形成预算,可进入预算中心正式生效'
: isReviewMode.value
? '未调整建议时可形成预算;调整后将回退预算'
: '保存后提交高级财务人员审核'
) )
function applyRecommendedBudget() { const baseBudgetLabel = computed(() =>
resetDraftRows() isReviewMode.value
} ? '提交预算'
: props.report.periodType === '年度预算' ? '去年预算' : '上季度预算'
)
function generateBudgetDraft() { const expenseStructureTitle = computed(() =>
draftStatus.value = 'generated' isReviewMode.value
? '提交预算费用结构'
: props.report.periodType === '年度预算' ? '去年费用结构' : '上季度费用结构'
)
const recommendationTitle = computed(() => isReviewMode.value ? '审核建议' : '编制建议')
const primaryActionLabel = computed(() => {
if (!isReviewMode.value) return '保存预算'
return hasReviewChanges.value ? '回退预算' : '形成预算'
})
function submitBudgetEditorAction() {
if (!isReviewMode.value) {
draftStatus.value = 'formed'
return
}
draftStatus.value = hasReviewChanges.value ? 'returned' : 'formed'
} }
const summaryCards = computed(() => [ const summaryCards = computed(() => [
{ {
label: '上季度预算', label: baseBudgetLabel.value,
value: props.report.summary?.totalBudget || '—', value: props.report.summary?.totalBudget || '—',
hint: '作为编制基准', hint: '作为编制基准',
color: 'var(--theme-primary)' color: 'var(--theme-primary)'
}, },
{ {
label: '上季度开销', label: props.report.centerLabel || '上季度开销',
value: props.report.summary?.totalSpend || '—', value: props.report.summary?.totalSpend || '—',
hint: '按四类预算口径汇总', hint: '按四类预算口径汇总',
color: 'var(--theme-secondary)' color: 'var(--theme-secondary)'
@@ -396,12 +458,21 @@ const summaryCards = computed(() => [
.budget-editor-row { .budget-editor-row {
display: grid; display: grid;
grid-template-columns: minmax(64px, .7fr) minmax(118px, .95fr) repeat(3, minmax(68px, .55fr)) minmax(220px, 1.6fr); grid-template-columns: minmax(82px, .7fr) minmax(128px, .9fr) minmax(280px, 2fr);
gap: 8px; gap: 8px;
align-items: center; align-items: center;
min-width: 0; min-width: 0;
} }
.budget-editor-table.is-review .budget-editor-row {
grid-template-columns:
minmax(82px, .65fr)
minmax(122px, .75fr)
minmax(122px, .8fr)
minmax(240px, 1.6fr)
minmax(240px, 1.6fr);
}
.budget-editor-row.head { .budget-editor-row.head {
min-height: 34px; min-height: 34px;
padding: 0 8px; padding: 0 8px;
@@ -465,6 +536,13 @@ const summaryCards = computed(() => [
outline: 3px solid var(--theme-focus-ring, rgba(58, 124, 165, .12)); outline: 3px solid var(--theme-focus-ring, rgba(58, 124, 165, .12));
} }
.budget-editor-row input[readonly],
.budget-editor-row textarea[readonly] {
background: #f8fafc;
color: #475569;
cursor: default;
}
.budget-editor-footer { .budget-editor-footer {
margin-top: 10px; margin-top: 10px;
padding-top: 10px; padding-top: 10px;
@@ -510,6 +588,10 @@ const summaryCards = computed(() => [
color: #fff; color: #fff;
} }
.budget-editor-primary.danger {
background: #7f1d1d;
}
.budget-editor-secondary { .budget-editor-secondary {
border: 1px solid #d7e0ea; border: 1px solid #d7e0ea;
background: #fff; background: #fff;

View File

@@ -17,13 +17,13 @@
<strong>{{ decisionTitle }}</strong> <strong>{{ decisionTitle }}</strong>
<p>{{ decisionDescription }}</p> <p>{{ decisionDescription }}</p>
</div> </div>
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list">
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p>
</div>
<div class="employee-risk-action"> <div class="employee-risk-action">
<span>建议动作</span> <span>建议动作</span>
<strong :class="decisionTone">{{ decisionAction }}</strong> <strong :class="decisionTone">{{ decisionAction }}</strong>
</div> </div>
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list">
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p>
</div>
</section> </section>
<section class="employee-risk-profile-section"> <section class="employee-risk-profile-section">
@@ -315,8 +315,20 @@ function normalizeBusinessStage(value) {
function resolveReimbursementMaterialIssues(items) { function resolveReimbursementMaterialIssues(items) {
return items return items
.filter((item) => !item?.isSystemGenerated && !String(item?.invoiceId || '').trim()) .filter((item) => !item?.isSystemGenerated && isRequiredMaterialItem(item) && !String(item?.invoiceId || '').trim())
.map((item) => `未上传票据${item.name || item.category || item.desc || '未命名明细'}`) .map((item) => `住宿材料待补充${item.name || item.category || item.desc || '住宿明细'}`)
}
function isRequiredMaterialItem(item) {
const text = [
item?.itemType,
item?.typeCode,
item?.name,
item?.category,
item?.desc,
item?.itemReason
].map((value) => String(value || '').trim()).join(' ')
return /hotel_ticket|hotel|住宿|酒店|水单/.test(text)
} }
function resolveSceneIssues(request, items, isApplicationDocument) { function resolveSceneIssues(request, items, isApplicationDocument) {
@@ -522,7 +534,7 @@ function uniqueTexts(values) {
.employee-risk-ai-note { .employee-risk-ai-note {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) minmax(220px, 38%); grid-template-columns: minmax(0, 1fr);
align-items: start; align-items: start;
gap: 10px; gap: 10px;
padding: 10px 12px; padding: 10px 12px;
@@ -568,14 +580,18 @@ function uniqueTexts(values) {
} }
.employee-risk-action { .employee-risk-action {
grid-column: 1 / -1;
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px; gap: 8px;
min-width: 0; min-width: 0;
padding: 8px 10px; padding: 8px 10px;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 4px; border-radius: 4px;
background: #fff; background: #fff;
text-align: center;
} }
.employee-risk-action span { .employee-risk-action span {
@@ -592,6 +608,7 @@ function uniqueTexts(values) {
font-size: 12px; font-size: 12px;
font-weight: 800; font-weight: 800;
line-height: 1.5; line-height: 1.5;
text-align: center;
} }
.employee-risk-action strong.medium { .employee-risk-action strong.medium {

View File

@@ -333,6 +333,66 @@
</p> </p>
</div> </div>
<div
v-if="message.role === 'assistant' && ui.shouldShowDraftSavedCard(message)"
class="draft-preview application-draft-preview"
:class="{ 'reimbursement-draft-preview': !ui.isApplicationDraftPayload(message.draftPayload) }"
>
<template v-if="ui.isApplicationDraftPayload(message.draftPayload)">
<header class="application-draft-head">
<span class="application-draft-icon" aria-hidden="true">
<i class="mdi mdi-file-document-check-outline"></i>
</span>
<span class="application-draft-title">
<strong>申请单据已生成</strong>
<small>已为本次业务生成申请单请按需查看完整详情</small>
</span>
<span class="application-draft-status">{{ ui.resolveApplicationDraftStatusLabel(message.draftPayload) }}</span>
</header>
<div class="application-draft-brief" role="group" aria-label="申请单据简要信息">
<div
v-for="item in ui.buildApplicationDraftSummaryItems(message.draftPayload)"
:key="`${message.id}-application-draft-${item.label}`"
class="application-draft-brief-item"
:class="{ 'is-primary': item.label === '单号' }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<footer class="application-draft-footer">
<p>
完整审批链附件和明细可在单据详情中
<button
type="button"
class="application-draft-detail-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看</button>
</p>
</footer>
</template>
<template v-else>
<div class="reimbursement-draft-card" role="group" aria-label="报销草稿已生成">
<span class="reimbursement-draft-icon" aria-hidden="true">
<i class="mdi mdi-file-document-edit-outline"></i>
</span>
<div class="reimbursement-draft-main">
<strong>报销草稿已生成</strong>
<p>
单号<span>{{ ui.resolveReimbursementDraftClaimNo(message.draftPayload) }}</span>
<button
type="button"
class="reimbursement-draft-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看详情</button>
</p>
</div>
</div>
</template>
</div>
<div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block"> <div v-if="message.role === 'assistant' && message.reviewPayload" class="message-detail-block review-message-block">
<div class="review-plain-followup"> <div class="review-plain-followup">
<template <template
@@ -405,54 +465,6 @@
</div> </div>
</div> </div>
<div
v-if="message.role === 'assistant' && !message.reviewPayload && message.draftPayload"
class="draft-preview"
:class="{ 'application-draft-preview': ui.isApplicationDraftPayload(message.draftPayload) }"
>
<template v-if="ui.isApplicationDraftPayload(message.draftPayload)">
<header class="application-draft-head">
<span class="application-draft-icon" aria-hidden="true">
<i class="mdi mdi-file-document-check-outline"></i>
</span>
<span class="application-draft-title">
<strong>申请单据已生成</strong>
<small>已为本次业务生成申请单请按需查看完整详情</small>
</span>
<span class="application-draft-status">{{ ui.resolveApplicationDraftStatusLabel(message.draftPayload) }}</span>
</header>
<div class="application-draft-brief" role="group" aria-label="申请单据简要信息">
<div
v-for="item in ui.buildApplicationDraftSummaryItems(message.draftPayload)"
:key="`${message.id}-application-draft-${item.label}`"
class="application-draft-brief-item"
:class="{ 'is-primary': item.label === '单号' }"
>
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</div>
</div>
<footer class="application-draft-footer">
<p>
完整审批链附件和明细可在单据详情中
<button
type="button"
class="application-draft-detail-link"
:disabled="ui.submitting || ui.reviewActionBusy || ui.sessionSwitchBusy"
@click="ui.openApplicationDraftDetail(message)"
>查看</button>
</p>
</footer>
</template>
<template v-else>
<header>
<strong>{{ message.draftPayload.title }}</strong>
<span>待人工确认</span>
</header>
<pre>{{ message.draftPayload.body }}</pre>
</template>
</div>
<div v-if="message.attachments?.length" class="message-files"> <div v-if="message.attachments?.length" class="message-files">
<span v-for="file in message.attachments" :key="file" class="file-chip"> <span v-for="file in message.attachments" :key="file" class="file-chip">
<i class="mdi mdi-paperclip"></i> <i class="mdi mdi-paperclip"></i>

View File

@@ -33,7 +33,8 @@ export function useAppShell() {
files: [], files: [],
conversation: null, conversation: null,
scope: null, scope: null,
sessionType: '' sessionType: '',
budgetContext: null
}) })
const smartEntrySessionId = ref(0) const smartEntrySessionId = ref(0)
const smartEntryRevealToken = ref(0) const smartEntryRevealToken = ref(0)
@@ -183,7 +184,8 @@ export function useAppShell() {
files: [], files: [],
conversation: null, conversation: null,
scope: null, scope: null,
sessionType: '' sessionType: '',
budgetContext: null
} }
smartEntrySessionId.value += 1 smartEntrySessionId.value += 1
} }
@@ -337,7 +339,10 @@ export function useAppShell() {
files: Array.isArray(payload.files) ? payload.files : [], files: Array.isArray(payload.files) ? payload.files : [],
conversation, conversation,
scope, scope,
sessionType sessionType,
budgetContext: payload.budgetContext && typeof payload.budgetContext === 'object'
? payload.budgetContext
: null
} }
smartEntrySessionId.value += 1 smartEntrySessionId.value += 1
} }
@@ -358,7 +363,7 @@ export function useAppShell() {
return return
} }
smartEntryOpen.value = false smartEntryOpen.value = false
toast(`${claimNo || '该'}单据已完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}`) toast(`${claimNo || '该'}单据已提交审批${approvalStage ? `,当前节点:${approvalStage}` : ''}`)
router.push({ name: 'app-documents' }) router.push({ name: 'app-documents' })
return return
} }

View File

@@ -5,26 +5,29 @@ export function useLoginView() {
const password = ref('') const password = ref('')
const tenant = ref('远光软件股份有限公司') const tenant = ref('远光软件股份有限公司')
const remember = ref(true) const remember = ref(true)
const showPassword = ref(false)
const tenantOptions = [
{
label: '远光软件股份有限公司',
value: '远光软件股份有限公司'
}
]
const features = [ const features = [
{ {
title: '智能审单', iconKey: 'recognition',
desc: 'AI 自动识别票据与规则,提升准确率与处理效率', title: '智能识别 自动归集',
icon: 'mdi mdi-file-document-outline', desc: '票据智能识别,自动归集费用,减少人工录入'
tone: 'green'
}, },
{ {
title: '异常预警', iconKey: 'workflow',
desc: '多维风险识别与预警,主动防控报销风险', title: '流程透明 合规可控',
icon: 'mdi mdi-bell-outline', desc: '内置审批规则引擎,流程透明,风险可控'
tone: 'red'
}, },
{ {
title: 'SLA 监控', iconKey: 'insight',
desc: '实时监控服务水位,保障审批和处理时效', title: '数据洞察 决策支持',
icon: 'mdi mdi-sync', desc: '多维度费用分析,洞察业务,驱动决策'
tone: 'blue'
} }
] ]
@@ -49,8 +52,8 @@ export function useLoginView() {
LogoMark, LogoMark,
password, password,
remember, remember,
showPassword,
tenant, tenant,
tenantOptions,
username username
} }
} }

View File

@@ -50,7 +50,6 @@ const ARCHIVED_STEP_LABEL = '已归档'
const REIMBURSEMENT_PROGRESS_LABELS = [ const REIMBURSEMENT_PROGRESS_LABELS = [
RELATED_APPLICATION_STEP_LABEL, RELATED_APPLICATION_STEP_LABEL,
'待提交', '待提交',
'AI预审',
'直属领导审批', '直属领导审批',
'财务审批', '财务审批',
'待付款', '待付款',
@@ -301,11 +300,11 @@ function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false)
const rawNode = String(claim?.approval_stage || '').trim() const rawNode = String(claim?.approval_stage || '').trim()
if (rawNode) { if (rawNode) {
if (rawNode === '审批流转') { if (rawNode === '审批流转' || rawNode.includes('AI预审') || rawNode.includes('AI验审')) {
return 'AI预审' return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? '待提交' : '直属领导审批'
} }
if (rawNode === '待补充') { if (rawNode === '待补充') {
return approvalMeta.key === 'draft' ? '待提交' : 'AI预审' return '待提交'
} }
return rawNode return rawNode
} }
@@ -323,7 +322,7 @@ function resolveWorkflowNode(claim, approvalMeta, isApplicationDocument = false)
return isApplicationDocument ? '审批完成' : normalizedStatus === 'paid' ? '已付款' : '归档入账' return isApplicationDocument ? '审批完成' : normalizedStatus === 'paid' ? '已付款' : '归档入账'
} }
return isApplicationDocument ? '直属领导审批' : 'AI预审' return '直属领导审批'
} }
function stringifyRiskFlag(value) { function stringifyRiskFlag(value) {
@@ -375,24 +374,24 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
const normalizedNode = String(workflowNode || '').trim() const normalizedNode = String(workflowNode || '').trim()
if (approvalMeta.key === 'completed') { if (approvalMeta.key === 'completed') {
return 7 return 6
} }
if (approvalMeta.key === 'pending_payment') { if (approvalMeta.key === 'pending_payment') {
return 5 return 4
} }
if (normalizedNode.includes('已付款')) { if (normalizedNode.includes('已付款')) {
return 6
}
if (normalizedNode.includes('待付款')) {
return 5 return 5
} }
if (normalizedNode.includes('待付款')) {
return 4
}
if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) { if (normalizedNode.includes('归档') || normalizedNode.includes('入账')) {
return 7 return 6
} }
if (normalizedNode.includes('财务')) { if (normalizedNode.includes('财务')) {
return 4 return 3
} }
if ( if (
normalizedNode.includes('直属领导') normalizedNode.includes('直属领导')
@@ -400,10 +399,10 @@ function resolveProgressCurrentIndex(approvalMeta, workflowNode) {
|| normalizedNode.includes('部门负责人') || normalizedNode.includes('部门负责人')
|| normalizedNode.includes('负责人审批') || normalizedNode.includes('负责人审批')
) { ) {
return 3 return 2
} }
if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) { if (normalizedNode.includes('AI预审') || normalizedNode.includes('AI验审') || normalizedNode.includes('审批流转')) {
return 2 return approvalMeta.key === 'draft' || approvalMeta.key === 'supplement' ? 1 : 2
} }
if (normalizedNode.includes('待提交')) { if (normalizedNode.includes('待提交')) {
return 1 return 1
@@ -839,11 +838,6 @@ function buildCompletedStepMeta(claim, label) {
return buildProgressStepMeta(`${employeeName}提交`, submittedAt) return buildProgressStepMeta(`${employeeName}提交`, submittedAt)
} }
if (stepLabel === 'AI预审') {
const reviewedAt = formatDateTime(claim?.submitted_at || claim?.updated_at)
return buildProgressStepMeta('AI预审通过', reviewedAt)
}
if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') { if (stepLabel === '直属领导审批' || stepLabel === '预算管理者审批' || stepLabel === '财务审批') {
const approvalEvent = findApprovalEventForStep(claim, stepLabel) const approvalEvent = findApprovalEventForStep(claim, stepLabel)
if (approvalEvent) { if (approvalEvent) {
@@ -925,9 +919,6 @@ function resolveCurrentStepStartedAt(claim, label) {
const returnEvent = findLatestReturnEvent(claim) const returnEvent = findLatestReturnEvent(claim)
return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at return returnEvent?.created_at || returnEvent?.createdAt || claim?.updated_at || claim?.created_at
} }
if (stepLabel === 'AI预审') {
return claim?.updated_at || claim?.submitted_at || claim?.created_at
}
if (stepLabel === '直属领导审批') { if (stepLabel === '直属领导审批') {
return claim?.submitted_at || claim?.updated_at || claim?.created_at return claim?.submitted_at || claim?.updated_at || claim?.created_at
} }

View File

@@ -62,6 +62,35 @@ export const assistantCapabilities = [
} }
] ]
const FINANCIAL_CAPABILITY_KEYS = new Set(['budget-planning', 'finance-analysis'])
const FINANCIAL_CAPABILITY_ROLE_CODES = new Set(['budget_monitor', 'executive', 'admin'])
const FINANCIAL_CAPABILITY_ROLE_LABELS = new Set(['预算监控员', '高级财务人员', '管理员'])
function normalizeRoleCodes(user = {}) {
const rawCodes = Array.isArray(user.roleCodes)
? user.roleCodes
: Array.isArray(user.role_codes)
? user.role_codes
: []
return rawCodes.map((code) => String(code || '').trim().toLowerCase()).filter(Boolean)
}
export function canViewFinancialWorkbenchCapabilities(user = {}) {
const roleLabel = String(user.role || '').trim()
return Boolean(user.isAdmin)
|| FINANCIAL_CAPABILITY_ROLE_LABELS.has(roleLabel)
|| normalizeRoleCodes(user).some((code) => FINANCIAL_CAPABILITY_ROLE_CODES.has(code))
}
export function filterAssistantCapabilitiesForUser(user = {}) {
const canViewFinancial = canViewFinancialWorkbenchCapabilities(user)
return assistantCapabilities.filter((item) => canViewFinancial || !FINANCIAL_CAPABILITY_KEYS.has(item.key))
}
export function resolveWorkbenchCapabilityGridClass(user = {}) {
return canViewFinancialWorkbenchCapabilities(user) ? 'capability-grid--privileged' : 'capability-grid--standard'
}
export const todoItems = [ export const todoItems = [
{ {
title: '待补材料', title: '待补材料',

View File

@@ -7,6 +7,7 @@ import 'element-plus/theme-chalk/el-dialog.css'
import 'element-plus/theme-chalk/el-dropdown.css' import 'element-plus/theme-chalk/el-dropdown.css'
import 'element-plus/theme-chalk/el-dropdown-item.css' import 'element-plus/theme-chalk/el-dropdown-item.css'
import 'element-plus/theme-chalk/el-dropdown-menu.css' import 'element-plus/theme-chalk/el-dropdown-menu.css'
import 'element-plus/theme-chalk/el-icon.css'
import 'element-plus/theme-chalk/el-input.css' import 'element-plus/theme-chalk/el-input.css'
import 'element-plus/theme-chalk/el-option.css' import 'element-plus/theme-chalk/el-option.css'
import 'element-plus/theme-chalk/el-option-group.css' import 'element-plus/theme-chalk/el-option-group.css'

View File

@@ -157,13 +157,6 @@ export function submitExpenseClaim(claimId) {
}) })
} }
export function preReviewExpenseClaim(claimId) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/pre-review`, {
method: 'POST',
body: JSON.stringify({})
})
}
export function returnExpenseClaim(claimId, payload = {}) { export function returnExpenseClaim(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/return`, { return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/return`, {
method: 'POST', method: 'POST',

View File

@@ -55,6 +55,9 @@ export function writeDocumentScope(scope, allowedScopes = [], storage = getStora
} }
export function isNewDocument(row, viewedKeys) { export function isNewDocument(row, viewedKeys) {
if (row?.isNewDocument === false || row?.archived === true || String(row?.source || '').trim() === 'archive') {
return false
}
const key = resolveDocumentNewKey(row) const key = resolveDocumentNewKey(row)
return Boolean(key) && !viewedKeys.has(key) return Boolean(key) && !viewedKeys.has(key)
} }

View File

@@ -16,7 +16,7 @@ export const APPLICATION_PREVIEW_FIELD_DEFINITIONS = [
{ key: 'department', label: '部门', editable: false, required: false }, { key: 'department', label: '部门', editable: false, required: false },
{ key: 'position', label: '岗位', editable: false, required: false }, { key: 'position', label: '岗位', editable: false, required: false },
{ key: 'managerName', label: '直属领导', editable: false, required: false }, { key: 'managerName', label: '直属领导', editable: false, required: false },
{ key: 'time', label: '发生时间' }, { key: 'time', label: '申请时间' },
{ key: 'location', label: '地点' }, { key: 'location', label: '地点' },
{ key: 'reason', label: '事由' }, { key: 'reason', label: '事由' },
{ key: 'days', label: '天数' }, { key: 'days', label: '天数' },
@@ -33,6 +33,20 @@ export const APPLICATION_TRANSPORT_MODE_OPTIONS = ['火车', '飞机', '轮船']
const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算' const APPLICATION_POLICY_PENDING_TEXT = '填写地点和天数后自动测算'
const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动生成交通参考票价,报销阶段按真实票据复核' const APPLICATION_TRANSPORT_REIMBURSEMENT_TEXT = '选择火车、飞机或轮船后自动生成交通参考票价,报销阶段按真实票据复核'
export function resolveApplicationTimeLabel(applicationType = '') {
const label = String(applicationType || '').trim()
if (/差旅|出差/.test(label)) return '行程时间'
if (/招待|宴请|餐饮/.test(label)) return '招待时间'
return '申请时间'
}
function resolveApplicationFieldLabel(item, fields = {}) {
if (item.key === 'time') {
return resolveApplicationTimeLabel(fields.applicationType)
}
return item.label
}
function compactText(value) { function compactText(value) {
return String(value || '').replace(/\s+/g, '') return String(value || '').replace(/\s+/g, '')
} }
@@ -374,7 +388,7 @@ function buildMissingFields(fields) {
return APPLICATION_PREVIEW_FIELD_DEFINITIONS return APPLICATION_PREVIEW_FIELD_DEFINITIONS
.filter((item) => item.key !== 'applicationType' && item.required !== false) .filter((item) => item.key !== 'applicationType' && item.required !== false)
.filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key])) .filter((item) => !isApplicationPreviewValueProvided(fields?.[item.key]))
.map((item) => item.label) .map((item) => resolveApplicationFieldLabel(item, fields))
} }
export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) { export function buildApplicationPolicyEstimateRequest(preview = {}, currentUser = {}) {
@@ -551,6 +565,38 @@ export function normalizeApplicationPreview(preview = {}) {
} }
} }
export function applyApplicationBusinessTimeContext(preview = {}, businessTimeContext = null) {
if (!businessTimeContext || typeof businessTimeContext !== 'object') {
return normalizeApplicationPreview(preview)
}
const startDate = String(businessTimeContext.start_date || '').trim()
const endDate = String(businessTimeContext.end_date || startDate).trim()
const displayValue = String(
businessTimeContext.business_time ||
businessTimeContext.time_range ||
businessTimeContext.display_value ||
''
).trim()
const time = startDate && endDate
? (startDate === endDate ? startDate : `${startDate}${endDate}`)
: displayValue
if (!time) {
return normalizeApplicationPreview(preview)
}
const normalized = normalizeApplicationPreview(preview)
const fields = normalized.fields || {}
return normalizeApplicationPreview({
...normalized,
fields: {
...fields,
time,
days: resolveDaysFromDateRange(time) || fields.days
}
})
}
export function buildModelRefinedApplicationPreview(localPreview = {}, ontology = {}, rawText = '', currentUser = {}) { export function buildModelRefinedApplicationPreview(localPreview = {}, ontology = {}, rawText = '', currentUser = {}) {
const currentFields = localPreview?.fields || {} const currentFields = localPreview?.fields || {}
const ontologyFields = buildApplicationFieldsFromOntology(ontology || {}, rawText, currentUser) const ontologyFields = buildApplicationFieldsFromOntology(ontology || {}, rawText, currentUser)
@@ -598,6 +644,7 @@ export function buildApplicationPreviewRows(preview = {}) {
const value = String(rawValue || '').trim() || '待补充' const value = String(rawValue || '').trim() || '待补充'
return { return {
...item, ...item,
label: resolveApplicationFieldLabel(item, fields),
value, value,
editable: item.editable !== false, editable: item.editable !== false,
highlight: Boolean(item.highlight), highlight: Boolean(item.highlight),

View File

@@ -8,6 +8,15 @@
}" }"
> >
<div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div> <div class="mobile-overlay" aria-hidden="true" @click="mobileSidebarOpen = false"></div>
<button
type="button"
class="mobile-hamburger-btn"
aria-label="打开移动端导航"
:aria-expanded="mobileSidebarOpen ? 'true' : 'false'"
@click="mobileSidebarOpen = true"
>
<i class="mdi mdi-menu" aria-hidden="true"></i>
</button>
<Transition name="login-entry-veil"> <Transition name="login-entry-veil">
<div v-if="loginEntryAnimating" class="login-entry-veil" aria-live="polite" aria-label="登录成功,正在进入工作台"> <div v-if="loginEntryAnimating" class="login-entry-veil" aria-live="polite" aria-label="登录成功,正在进入工作台">
<FloatingLightBandWindow <FloatingLightBandWindow
@@ -178,6 +187,7 @@
:initial-files="smartEntryContext.files" :initial-files="smartEntryContext.files"
:initial-conversation="smartEntryContext.conversation" :initial-conversation="smartEntryContext.conversation"
:initial-session-type="smartEntryContext.sessionType" :initial-session-type="smartEntryContext.sessionType"
:initial-budget-context="smartEntryContext.budgetContext"
:entry-source="smartEntryContext.source" :entry-source="smartEntryContext.source"
:request-context="smartEntryContext.request" :request-context="smartEntryContext.request"
:invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId" :invalidated-draft-claim-id="smartEntryInvalidatedDraftClaimId"

View File

@@ -150,46 +150,46 @@
<tbody> <tbody>
<tr v-for="row in visibleBudgetRows" :key="row.id" @click="handleRowAction(row)"> <tr v-for="row in visibleBudgetRows" :key="row.id" @click="handleRowAction(row)">
<template v-if="activeBudgetScope === BUDGET_SCOPE_ALL"> <template v-if="activeBudgetScope === BUDGET_SCOPE_ALL">
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td> <td data-label="预算编号"><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td>{{ row.departmentName }}</td> <td data-label="部门">{{ row.departmentName }}</td>
<td>{{ row.periodLabel }}</td> <td data-label="预算周期">{{ row.periodLabel }}</td>
<td>{{ row.annualAmountLabel }}</td> <td data-label="年度预算">{{ row.annualAmountLabel }}</td>
<td>{{ row.quarterAmountLabel }}</td> <td data-label="季度预算">{{ row.quarterAmountLabel }}</td>
<td>{{ row.monthAmountLabel }}</td> <td data-label="月度预算">{{ row.monthAmountLabel }}</td>
<td>{{ row.availableAmountLabel }}</td> <td data-label="剩余可用">{{ row.availableAmountLabel }}</td>
<td> <td data-label="使用率">
<div class="budget-rate"> <div class="budget-rate">
<div><em :class="row.riskTone" :style="{ width: `${Math.min(row.usageRate, 100)}%` }"></em></div> <div><em :class="row.riskTone" :style="{ width: `${Math.min(row.usageRate, 100)}%` }"></em></div>
<span>{{ row.usageRateLabel }}</span> <span>{{ row.usageRateLabel }}</span>
</div> </div>
</td> </td>
<td><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td> <td data-label="风险"><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
<td>{{ row.updatedAt }}</td> <td data-label="更新时间">{{ row.updatedAt }}</td>
</template> </template>
<template v-else-if="activeBudgetScope === BUDGET_SCOPE_REVIEW"> <template v-else-if="activeBudgetScope === BUDGET_SCOPE_REVIEW">
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td> <td data-label="草稿编号"><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td>{{ row.departmentName }}</td> <td data-label="提交部门">{{ row.departmentName }}</td>
<td>{{ row.compiler }}</td> <td data-label="编制人">{{ row.compiler }}</td>
<td>{{ row.submittedAt }}</td> <td data-label="提交时间">{{ row.submittedAt }}</td>
<td>{{ row.periodLabel }}</td> <td data-label="预算周期">{{ row.periodLabel }}</td>
<td>{{ row.requestedAmountLabel }}</td> <td data-label="申请预算">{{ row.requestedAmountLabel }}</td>
<td><span class="budget-change">{{ row.changeRateLabel }}</span></td> <td data-label="较上一版"><span class="budget-change">{{ row.changeRateLabel }}</span></td>
<td><span class="budget-score">{{ row.aiScore }}</span></td> <td data-label="AI 分析"><span class="budget-score">{{ row.aiScore }}</span></td>
<td><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td> <td data-label="风险"><span :class="['budget-status-tag', row.riskTone]">{{ row.riskLabel }}</span></td>
<td><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td> <td data-label="状态"><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
</template> </template>
<template v-else> <template v-else>
<td><strong class="budget-no">{{ row.budgetNo }}</strong></td> <td data-label="归档编号"><strong class="budget-no">{{ row.budgetNo }}</strong></td>
<td>{{ row.departmentName }}</td> <td data-label="部门">{{ row.departmentName }}</td>
<td>{{ row.periodLabel }}</td> <td data-label="预算周期">{{ row.periodLabel }}</td>
<td>{{ row.version }}</td> <td data-label="版本">{{ row.version }}</td>
<td>{{ row.archiveType }}</td> <td data-label="归档类型">{{ row.archiveType }}</td>
<td>{{ row.quarterAmountLabel }}</td> <td data-label="原预算额">{{ row.quarterAmountLabel }}</td>
<td>{{ row.reviewer }}</td> <td data-label="审核人">{{ row.reviewer }}</td>
<td>{{ row.archivedAt }}</td> <td data-label="归档时间">{{ row.archivedAt }}</td>
<td><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td> <td data-label="状态"><span :class="['budget-status-tag', row.statusTone]">{{ row.statusLabel }}</span></td>
</template> </template>
</tr> </tr>
</tbody> </tbody>
@@ -297,15 +297,15 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="item in selectedBudget.categoryRows" :key="item.code"> <tr v-for="item in selectedBudget.categoryRows" :key="item.code">
<td><strong>{{ item.name }}</strong></td> <td data-label="费用类型"><strong>{{ item.name }}</strong></td>
<td>{{ item.amountLabel }}</td> <td data-label="预算金额">{{ item.amountLabel }}</td>
<td>{{ item.usedLabel }}</td> <td data-label="已发生">{{ item.usedLabel }}</td>
<td>{{ item.occupiedLabel }}</td> <td data-label="已占用">{{ item.occupiedLabel }}</td>
<td>{{ item.availableLabel }}</td> <td data-label="剩余">{{ item.availableLabel }}</td>
<td>{{ item.usageRateLabel }}</td> <td data-label="使用率">{{ item.usageRateLabel }}</td>
<td><span class="budget-threshold reminder">{{ item.reminderLine }}</span></td> <td data-label="提醒"><span class="budget-threshold reminder">{{ item.reminderLine }}</span></td>
<td><span class="budget-threshold alert">{{ item.alertLine }}</span></td> <td data-label="告警"><span class="budget-threshold alert">{{ item.alertLine }}</span></td>
<td><span class="budget-threshold risk">{{ item.riskLine }}</span></td> <td data-label="风险"><span class="budget-threshold risk">{{ item.riskLine }}</span></td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -196,20 +196,20 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)"> <tr v-for="row in visibleRows" :key="row.documentKey" @click="openDocument(row)">
<td> <td data-label="单号">
<span v-if="row.isNewDocument" class="new-document-badge">NEW</span> <span v-if="row.isNewDocument" class="new-document-badge">NEW</span>
<strong class="doc-id">{{ row.documentNo }}</strong> <strong class="doc-id">{{ row.documentNo }}</strong>
</td> </td>
<td>{{ row.createdAtDisplay }}</td> <td data-label="创建时间">{{ row.createdAtDisplay }}</td>
<td v-if="showStayTimeColumn">{{ row.stayTimeDisplay }}</td> <td v-if="showStayTimeColumn" data-label="停留时间">{{ row.stayTimeDisplay }}</td>
<td><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td> <td data-label="单据类型"><span class="doc-kind-tag" :class="row.documentTypeCode">{{ row.documentTypeLabel }}</span></td>
<td><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td> <td data-label="费用场景"><span class="type-tag" :class="row.typeTone">{{ row.typeLabel }}</span></td>
<td>{{ row.initiatorName }}</td> <td data-label="发起人">{{ row.initiatorName }}</td>
<td>{{ row.reason }}</td> <td data-label="事项">{{ row.reason }}</td>
<td>{{ row.amountDisplay }}</td> <td data-label="金额">{{ row.amountDisplay }}</td>
<td>{{ row.node }}</td> <td data-label="当前环节">{{ row.node }}</td>
<td><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td> <td data-label="状态"><span class="status-tag" :class="row.statusTone">{{ row.statusLabel }}</span></td>
<td>{{ row.updatedAtDisplay }}</td> <td data-label="更新时间">{{ row.updatedAtDisplay }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
@@ -587,7 +587,9 @@ function buildDocumentRow(request, options = {}) {
archived, archived,
createdAtDisplay: formatDocumentListTime(createdAtSource), createdAtDisplay: formatDocumentListTime(createdAtSource),
stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized), stayTimeDisplay: resolveDocumentStayTimeDisplay(normalized),
isNewDocument: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value), isNewDocument: archived
? false
: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
updatedAtDisplay: formatDocumentListTime(updatedAtSource), updatedAtDisplay: formatDocumentListTime(updatedAtSource),
sortTime: resolveDocumentSortTime(updatedAtSource) sortTime: resolveDocumentSortTime(updatedAtSource)
} }

View File

@@ -621,7 +621,7 @@
:class="{ spotlight: employee.spotlight }" :class="{ spotlight: employee.spotlight }"
@click="openEmployeeDetail(employee)" @click="openEmployeeDetail(employee)"
> >
<td> <td data-label="员工">
<div class="employee-cell"> <div class="employee-cell">
<span class="employee-avatar">{{ employee.avatar }}</span> <span class="employee-avatar">{{ employee.avatar }}</span>
<div> <div>
@@ -630,11 +630,11 @@
</div> </div>
</div> </div>
</td> </td>
<td>{{ employee.employeeNo }}</td> <td data-label="工号">{{ employee.employeeNo }}</td>
<td>{{ employee.department }}</td> <td data-label="部门">{{ employee.department }}</td>
<td>{{ employee.position }}</td> <td data-label="岗位">{{ employee.position }}</td>
<td><span class="level-pill">{{ employee.grade }}</span></td> <td data-label="职级"><span class="level-pill">{{ employee.grade }}</span></td>
<td> <td data-label="系统角色">
<div class="role-stack"> <div class="role-stack">
<span <span
v-for="role in employee.roles.slice(0, 2)" v-for="role in employee.roles.slice(0, 2)"
@@ -648,10 +648,10 @@
</span> </span>
</div> </div>
</td> </td>
<td> <td data-label="状态">
<span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span> <span class="status-pill" :class="employee.statusTone">{{ employee.status }}</span>
</td> </td>
<td class="cell-updated">{{ employee.updatedAt }}</td> <td class="cell-updated" data-label="最近更新">{{ employee.updatedAt }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -28,6 +28,7 @@ const {
} = useSystemState() } = useSystemState()
const LOGIN_ENTRY_ROUTE_DELAY_MS = 140 const LOGIN_ENTRY_ROUTE_DELAY_MS = 140
const LOGIN_BRAND_NAME = '易财费控'
function waitForLoginEntryReady() { function waitForLoginEntryReady() {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -35,8 +36,6 @@ function waitForLoginEntryReady() {
}) })
} }
const LOGIN_BRAND_NAME = '易财费控'
async function submitLogin(credentials) { async function submitLogin(credentials) {
const passed = await handleLogin(credentials) const passed = await handleLogin(credentials)

View File

@@ -1,150 +1,152 @@
<template> <template>
<main class="login-page"> <main class="login-page">
<header class="page-brand"> <section class="login-visual" aria-label="智能费用管理运营能力">
<LogoMark /> <div class="visual-brand">
<strong>{{ displayCompanyName }}</strong> <LogoMark />
</header> <strong>{{ displayCompanyName }}</strong>
<section class="hero">
<p class="eyebrow-text">Smart Expense Operations</p>
<h1>企业报销智能运营台</h1>
<p class="hero-lead">让报销审批更智能更高效</p>
<p class="hero-sub">智能审单 · 自动化审批 · 风险预警 · SLA 监控 · 数据驱动决策</p>
<div class="hero-stage" aria-hidden="true">
<span class="flow-line flow-a"></span>
<span class="flow-line flow-b"></span>
<span class="flow-line flow-c"></span>
<div class="metric-card amount">
<span>报销金额趋势</span>
<strong>¥ 61,600</strong>
<small>较昨日 <b class="up">+8.3%</b></small>
<div class="mini-bars"><i></i><i></i><i></i><i></i></div>
</div>
<div class="document-card">
<span>报销单</span>
<i></i><i></i><i></i>
<b class="doc-check"><i class="mdi mdi-check"></i></b>
</div>
<img class="shield-art" src="../assets/security-shield.png" alt="" />
<div class="round-badge ai">AI</div>
<div class="metric-card risk">
<span>风险预警</span>
<strong><i class="mdi mdi-alert"></i> 14 </strong>
<small>较昨日 <b class="danger">+16.7%</b></small>
</div>
<div class="metric-card audit">
<span>审批效率</span>
<strong>78%</strong>
<small>较昨日 <b class="up">+6.2%</b></small>
</div>
<div class="metric-card sla">
<span>SLA 达成率</span>
<strong>96%</strong>
<small>较昨日 <b class="up">+3.1%</b></small>
</div>
</div> </div>
<div class="feature-strip" aria-label="核心能力"> <div class="visual-copy">
<p>智能费用管理</p>
<h1>让企业财务更高效更合规更可控</h1>
<span>以智能化流程驱动费用全生命周期管理助力企业降本增效稳健前行</span>
</div>
<div class="visual-feature-list" aria-label="核心能力">
<article v-for="item in features" :key="item.title"> <article v-for="item in features" :key="item.title">
<span :class="item.tone"><i :class="item.icon"></i></span> <span class="visual-feature-icon">
<ElIcon><component :is="item.icon" /></ElIcon>
</span>
<div> <div>
<strong>{{ item.title }}</strong> <strong>{{ item.title }}</strong>
<p>{{ item.desc }}</p> <p>{{ item.desc }}</p>
</div> </div>
</article> </article>
</div> </div>
<img class="visual-main-asset" :src="loginMainVisualImage" alt="" aria-hidden="true" />
<img class="visual-chart-asset" :src="loginChartPanelsImage" alt="" aria-hidden="true" />
<footer class="visual-footer">
<span>© 2024 智能费用管理平台</span>
<i></i>
<span>服务热线400-888-8888</span>
</footer>
</section> </section>
<section class="login-card" aria-label="登录表单"> <section class="login-panel" aria-label="登录表单">
<div class="card-brand"> <div class="login-card">
<LogoMark /> <div class="card-brand">
<strong>{{ displayCompanyName }}</strong> <LogoMark />
</div> <strong>{{ displayCompanyName }}</strong>
<header class="card-head">
<h2>欢迎登录</h2>
<p>使用员工邮箱或管理员账号进入系统</p>
</header>
<form class="login-form" @submit.prevent="emit('login', { username, password })">
<label class="field">
<span class="sr-only">账号</span>
<i class="mdi mdi-account-outline"></i>
<input v-model="username" type="text" placeholder="请输入员工邮箱 / 管理员账号" autocomplete="username" required />
</label>
<label class="field">
<span class="sr-only">密码</span>
<i class="mdi mdi-lock-outline"></i>
<input
v-model="password"
:type="showPassword ? 'text' : 'password'"
placeholder="请输入登录密码"
autocomplete="current-password"
required
/>
<button
class="field-icon-btn"
type="button"
:aria-label="showPassword ? '隐藏密码' : '显示密码'"
@click="showPassword = !showPassword"
>
<i :class="showPassword ? 'mdi mdi-eye' : 'mdi mdi-eye-off'"></i>
</button>
</label>
<label class="field">
<span class="sr-only">企业或租户</span>
<i class="mdi mdi-office-building"></i>
<select v-model="tenant" class="tenant-select" aria-label="请选择企业或租户">
<option value="远光软件股份有限公司">远光软件股份有限公司</option>
</select>
<span class="field-select-chevron" aria-hidden="true">
<i class="mdi mdi-chevron-down"></i>
</span>
</label>
<div class="form-meta">
<label class="remember">
<input v-model="remember" type="checkbox" />
<span>记住账号</span>
</label>
<button type="button" class="link-btn" @click="emit('recover-password')">忘记密码?</button>
</div> </div>
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p> <header class="card-head">
<h2>欢迎登录</h2>
<p>智能费用管理平台</p>
</header>
<button class="submit-btn" type="submit" :disabled="submitting"> <form class="login-form" @submit.prevent="submitLogin">
{{ submitting ? '登录中...' : '登录' }} <label class="form-field">
</button> <span class="sr-only">账号</span>
<ElInput
v-model="username"
class="login-input"
:prefix-icon="User"
autocomplete="username"
clearable
placeholder="请输入账号"
/>
</label>
<div class="divider"><span></span></div> <label class="form-field">
<span class="sr-only">密码</span>
<ElInput
v-model="password"
class="login-input"
:prefix-icon="Lock"
autocomplete="current-password"
placeholder="请输入密码"
show-password
type="password"
/>
</label>
<button class="sso-btn" type="button" :disabled="submitting" @click="emit('sso-login')"> <label class="form-field">
<i class="mdi mdi-shield-outline"></i> <span class="sr-only">所属企业</span>
<span>SSO 单点登录</span> <ElSelect
</button> v-model="tenant"
</form> class="login-select"
popper-class="login-tenant-popper"
placeholder="请选择所属企业"
:suffix-icon="OfficeBuilding"
>
<ElOption
v-for="option in tenantOptions"
:key="option.value"
:label="option.label"
:value="option.value"
/>
</ElSelect>
</label>
<footer class="security-note"> <div class="form-meta">
<i class="mdi mdi-lock-outline"></i> <ElCheckbox v-model="remember" class="login-checkbox">记住账号</ElCheckbox>
<span>安全登录 · 数据加密传输 · 如需帮助请联系系统管理员</span> <button type="button" class="link-button" @click="emit('recover-password')">忘记密码?</button>
</footer> </div>
<p v-if="errorMessage" class="login-error">{{ errorMessage }}</p>
<ElButton
class="login-submit"
type="primary"
native-type="submit"
:loading="submitting"
:disabled="submitting"
>
登录
</ElButton>
<ElButton
class="login-sso"
:icon="Grid"
:disabled="submitting"
@click="emit('sso-login')"
>
SSO 单点登录
</ElButton>
</form>
<footer class="security-note">
登录即表示您已阅读并同意
<button type="button">用户协议</button>
<button type="button">隐私政策</button>
</footer>
</div>
</section> </section>
</main> </main>
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElCheckbox } from 'element-plus/es/components/checkbox/index.mjs'
import { ElIcon } from 'element-plus/es/components/icon/index.mjs'
import { ElInput } from 'element-plus/es/components/input/index.mjs'
import { ElOption, ElSelect } from 'element-plus/es/components/select/index.mjs'
import {
Connection,
DataAnalysis,
DocumentChecked,
Grid,
Lock,
OfficeBuilding,
User
} from '@element-plus/icons-vue'
import loginChartPanelsImage from '../assets/login-reference-chart-panels.png'
import loginMainVisualImage from '../assets/login-reference-main-visual.png'
import { useLoginView } from '../composables/useLoginView.js' import { useLoginView } from '../composables/useLoginView.js'
const props = defineProps({ const props = defineProps({
@@ -166,7 +168,32 @@ const emit = defineEmits(['login', 'recover-password', 'sso-login'])
const displayCompanyName = computed(() => props.companyName || '易财费控') const displayCompanyName = computed(() => props.companyName || '易财费控')
const { features, LogoMark, password, remember, showPassword, tenant, username } = useLoginView() const {
features,
LogoMark,
password,
remember,
tenant,
tenantOptions,
username
} = useLoginView()
const featureIconMap = {
recognition: DocumentChecked,
workflow: Connection,
insight: DataAnalysis
}
features.forEach((item) => {
item.icon = featureIconMap[item.iconKey] || DocumentChecked
})
function submitLogin() {
emit('login', {
username: username.value,
password: password.value
})
}
</script> </script>
<style scoped src="../assets/styles/views/login-view.css"></style> <style scoped src="../assets/styles/views/login-view.css"></style>

View File

@@ -94,25 +94,25 @@
:class="{ selected: selectedDocument?.id === doc.id }" :class="{ selected: selectedDocument?.id === doc.id }"
@click="selectDocument(doc.id)" @click="selectDocument(doc.id)"
> >
<td> <td data-label="文件名称">
<span class="file-name"> <span class="file-name">
<i :class="doc.icon"></i> <i :class="doc.icon"></i>
{{ doc.name }} {{ doc.name }}
</span> </span>
</td> </td>
<td> <td data-label="标签">
<span class="doc-tag">{{ doc.tag }}</span> <span class="doc-tag">{{ doc.tag }}</span>
</td> </td>
<td>{{ doc.time }}</td> <td data-label="上传时间">{{ doc.time }}</td>
<td>{{ doc.version }}</td> <td data-label="版本">{{ doc.version }}</td>
<td> <td data-label="状态">
<div class="state-cell"> <div class="state-cell">
<span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span> <span class="state-tag" :class="doc.stateTone">{{ doc.state }}</span>
</div> </div>
</td> </td>
<td class="ingest-time-cell">{{ doc.ingestTime || '—' }}</td> <td class="ingest-time-cell" data-label="归纳时间">{{ doc.ingestTime || '—' }}</td>
<td>{{ doc.owner }}</td> <td data-label="上传人">{{ doc.owner }}</td>
<td> <td data-label="操作">
<div class="row-actions" @click.stop> <div class="row-actions" @click.stop>
<button <button
class="more-btn" class="more-btn"

View File

@@ -92,21 +92,21 @@
</thead> </thead>
<tbody> <tbody>
<tr v-for="row in visibleRows" :key="row.id" @click="openDetail(row)"> <tr v-for="row in visibleRows" :key="row.id" @click="openDetail(row)">
<td> <td data-label="票据文件">
<strong class="doc-id">{{ row.file_name }}</strong> <strong class="doc-id">{{ row.file_name }}</strong>
<small>{{ row.summary || '暂无摘要' }}</small> <small>{{ row.summary || '暂无摘要' }}</small>
</td> </td>
<td><span class="doc-kind-tag reimbursement">{{ row.document_type_label }}</span></td> <td data-label="识别类型"><span class="doc-kind-tag reimbursement">{{ row.document_type_label }}</span></td>
<td><span class="type-tag neutral">{{ row.scene_label }}</span></td> <td data-label="费用场景"><span class="type-tag neutral">{{ row.scene_label }}</span></td>
<td>{{ row.amount || '待补充' }}</td> <td data-label="金额">{{ row.amount || '待补充' }}</td>
<td>{{ row.document_date || '待补充' }}</td> <td data-label="票据日期">{{ row.document_date || '待补充' }}</td>
<td>{{ formatScore(row.avg_score) }}</td> <td data-label="置信度">{{ formatScore(row.avg_score) }}</td>
<td v-if="showStatusColumn"> <td v-if="showStatusColumn" data-label="关联状态">
<span class="status-tag" :class="row.status === 'linked' ? 'completed' : 'warning'"> <span class="status-tag" :class="row.status === 'linked' ? 'completed' : 'warning'">
{{ row.status_label }} {{ row.status_label }}
</span> </span>
</td> </td>
<td>{{ formatDateTime(row.uploaded_at) }}</td> <td data-label="上传时间">{{ formatDateTime(row.uploaded_at) }}</td>
</tr> </tr>
</tbody> </tbody>
</table> </table>

View File

@@ -162,7 +162,12 @@
<span><i class="mdi mdi-account-tie-outline"></i>领导意见</span> <span><i class="mdi mdi-account-tie-outline"></i>领导意见</span>
<strong v-if="leaderApprovalReadonlyMeta">{{ leaderApprovalReadonlyMeta }}</strong> <strong v-if="leaderApprovalReadonlyMeta">{{ leaderApprovalReadonlyMeta }}</strong>
</div> </div>
<div v-if="hasLeaderApprovalEvents" class="application-leader-opinion-timeline" aria-label="领导批复事件流"> <div
v-if="hasLeaderApprovalEvents"
class="application-leader-opinion-timeline"
:class="{ 'is-single': hasSingleLeaderApprovalEvent }"
aria-label="领导批复事件流"
>
<article <article
v-for="event in leaderApprovalEvents" v-for="event in leaderApprovalEvents"
:key="event.id" :key="event.id"
@@ -414,7 +419,7 @@
<div class="validation-head"> <div class="validation-head">
<div> <div>
<h3>{{ aiAdviceTitle }}</h3> <h3>{{ aiAdviceTitle }}</h3>
<p>{{ aiAdviceHint }}</p> <p v-if="aiAdviceHint">{{ aiAdviceHint }}</p>
</div> </div>
<span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span> <span :class="['validation-pill', aiAdvice.tone]">{{ aiAdvice.badge }}</span>
</div> </div>
@@ -426,7 +431,7 @@
:class="['validation-section', `validation-section--${section.kind}`]" :class="['validation-section', `validation-section--${section.kind}`]"
> >
<h4 class="validation-section-title">{{ section.title }}</h4> <h4 class="validation-section-title">{{ section.title }}</h4>
<ul v-if="section.kind === 'completion'" class="validation-list"> <ul v-if="section.kind !== 'risk'" class="validation-list">
<li v-for="item in section.items" :key="item">{{ item }}</li> <li v-for="item in section.items" :key="item">{{ item }}</li>
</ul> </ul>
<div v-else class="risk-advice-list"> <div v-else class="risk-advice-list">
@@ -451,10 +456,6 @@
</section> </section>
</div> </div>
</article> </article>
<RiskObservationEvidenceCard
v-if="request.claimId"
:claim-id="request.claimId"
/>
<StageRiskAdviceCard <StageRiskAdviceCard
v-if="showStageRiskAdvice" v-if="showStageRiskAdvice"
:request="request" :request="request"
@@ -687,7 +688,7 @@
badge="重大风险" badge="重大风险"
badge-tone="danger" badge-tone="danger"
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`" :title="`当前存在 ${submitRiskWarnings.length} 条重大风险`"
description="如仍需进入下一步,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。" description="如仍需提交审批,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。"
cancel-text="返回整改" cancel-text="返回整改"
confirm-text="保存原因并继续" confirm-text="保存原因并继续"
busy-text="保存中..." busy-text="保存中..."

View File

@@ -120,7 +120,9 @@ export default {
status: '全部' status: '全部'
}) })
const canEditBudget = computed(() => canEditBudgetCenter(props.currentUser)) const canEditBudget = computed(() =>
canEditBudgetCenter(props.currentUser) || isBudgetMonitorUser(props.currentUser)
)
const canAuditBudgetDrafts = computed(() => canEditBudgetCenter(props.currentUser)) const canAuditBudgetDrafts = computed(() => canEditBudgetCenter(props.currentUser))
const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser)) const canSwitchDepartments = computed(() => canSwitchBudgetDepartments(props.currentUser))
const isDepartmentBudgetMonitor = computed( const isDepartmentBudgetMonitor = computed(
@@ -145,7 +147,10 @@ export default {
}) })
) )
const budgetScopeTabs = computed(() => buildBudgetScopeTabs(budgetRowsByScope.value)) const budgetScopeTabs = computed(() =>
buildBudgetScopeTabs(budgetRowsByScope.value)
.filter((tab) => canAuditBudgetDrafts.value || tab.value !== BUDGET_SCOPE_REVIEW)
)
const activeScopeRows = computed(() => budgetRowsByScope.value[activeBudgetScope.value] || []) const activeScopeRows = computed(() => budgetRowsByScope.value[activeBudgetScope.value] || [])
const activeScopeLabel = computed( const activeScopeLabel = computed(
() => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算' () => budgetScopeTabs.value.find((item) => item.value === activeBudgetScope.value)?.label || '预算'
@@ -224,14 +229,59 @@ export default {
})) }))
const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value}`) const pageSummary = computed(() => `${totalBudgetRows.value} 条,目前第 ${currentBudgetPage.value}`)
function openBudgetAssistant(prompt = '') { function buildBudgetAssistantContext(row, mode = 'edit') {
if (!row) return null
return {
mode,
budgetNo: row.budgetNo,
departmentCode: row.departmentCode,
departmentName: row.departmentName,
costCenter: row.costCenter,
periodLabel: row.periodLabel,
periodType: row.periodType,
budgetYear: row.budgetYear,
budgetQuarter: row.budgetQuarter,
version: row.version,
compiler: row.compiler || row.owner,
reviewer: row.reviewer,
submittedAt: row.submittedAt,
requestedAmount: row.requestedAmount || row.quarterAmount,
previousAmount: row.quarterAmount,
categoryRows: Array.isArray(row.categoryRows)
? row.categoryRows.map((item) => ({ ...item }))
: []
}
}
function resolveEditableBudgetRow() {
const allRows = budgetRowsByScope.value[BUDGET_SCOPE_ALL] || []
if (isDepartmentBudgetMonitor.value) {
return allRows.find((row) => (
row.scope === BUDGET_SCOPE_ALL &&
(
(currentUserCostCenter.value && row.costCenter === currentUserCostCenter.value) ||
(currentUserDepartmentName.value && row.departmentName === currentUserDepartmentName.value)
)
)) || allRows[0] || null
}
return allRows.find((row) => row.scope === BUDGET_SCOPE_ALL) || allRows[0] || null
}
function openBudgetAssistant(prompt = '', budgetContext = null) {
if (!canEditBudget.value) return if (!canEditBudget.value) return
const context = budgetContext || buildBudgetAssistantContext(resolveEditableBudgetRow(), 'edit')
emit('openAssistant', { emit('openAssistant', {
source: 'budget', source: 'budget',
sessionType: 'budget', sessionType: 'budget',
prompt, prompt: prompt || (
context?.departmentName
? `编辑${context.departmentName}${context.periodLabel || ''}预算`
: '编辑本部门预算'
),
files: [], files: [],
conversation: null conversation: null,
budgetContext: context
}) })
} }
@@ -242,7 +292,8 @@ export default {
} }
openBudgetAssistant( openBudgetAssistant(
`请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。` `请进入预算审核模式,审核${row.departmentName}${row.periodLabel}预算草案,重点看差旅、通信、招待费和办公用品的合理性、风险点和是否可以通过。`,
buildBudgetAssistantContext(row, 'review')
) )
} }
@@ -332,6 +383,12 @@ export default {
} }
) )
watch(canAuditBudgetDrafts, (allowed) => {
if (!allowed && activeBudgetScope.value === BUDGET_SCOPE_REVIEW) {
activeBudgetScope.value = BUDGET_SCOPE_ALL
}
}, { immediate: true })
watch( watch(
[ [
budgetPageSize, budgetPageSize,

View File

@@ -532,6 +532,10 @@ export default {
type: Object, type: Object,
default: null default: null
}, },
initialBudgetContext: {
type: Object,
default: null
},
initialSessionType: { initialSessionType: {
type: String, type: String,
default: '' default: ''
@@ -1109,6 +1113,7 @@ export default {
submitting, submitting,
syncComposerFilesToDraft, syncComposerFilesToDraft,
emitOperationCompleted, emitOperationCompleted,
emitDraftSaved: (payload) => emit('draft-saved', payload),
emitRequestUpdated: (payload) => emit('request-updated', payload), emitRequestUpdated: (payload) => emit('request-updated', payload),
toast toast
}) })
@@ -1881,6 +1886,29 @@ export default {
].filter((item) => String(item.value || '').trim() && item.value !== '待补充') ].filter((item) => String(item.value || '').trim() && item.value !== '待补充')
} }
function shouldShowDraftSavedCard(message) {
const draftPayload = message?.draftPayload || null
return Boolean(
draftPayload
&& (
String(draftPayload.claim_id || draftPayload.claimId || '').trim()
|| String(draftPayload.claim_no || draftPayload.claimNo || '').trim()
|| String(draftPayload.title || '').trim()
|| String(draftPayload.body || '').trim()
)
)
}
function resolveReimbursementDraftClaimNo(draftPayload) {
return String(
draftPayload?.claim_no
|| draftPayload?.claimNo
|| draftPayload?.claim_id
|| draftPayload?.claimId
|| ''
).trim() || '待生成'
}
function updateMessageOperationFeedback(message, patch = {}) { function updateMessageOperationFeedback(message, patch = {}) {
if (!message?.id) { if (!message?.id) {
return return
@@ -1957,7 +1985,7 @@ export default {
const draftPayload = message?.draftPayload || {} const draftPayload = message?.draftPayload || {}
const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim() const claimId = String(draftPayload.claim_id || draftPayload.claimId || '').trim()
if (!claimId) { if (!claimId) {
toast('暂未获取到申请单据 ID稍后可在单据中心查看。') toast('暂未获取到单据 ID稍后可在单据中心查看。')
return return
} }
await router.push({ await router.push({
@@ -2403,6 +2431,8 @@ export default {
isApplicationDraftPayload, isApplicationDraftPayload,
resolveApplicationDraftStatusLabel, resolveApplicationDraftStatusLabel,
buildApplicationDraftSummaryItems, buildApplicationDraftSummaryItems,
shouldShowDraftSavedCard,
resolveReimbursementDraftClaimNo,
openApplicationDraftDetail, openApplicationDraftDetail,
isOperationFeedbackVisible, isOperationFeedbackVisible,
dismissOperationFeedbackForMessage, dismissOperationFeedbackForMessage,
@@ -2519,7 +2549,7 @@ export default {
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, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft queryDraftByClaimNo, appendReviewRiskBriefToConversation, appendExpenseQueryRiskToConversation, goReviewDocument, openActiveReviewDocumentPreview, closeDocumentPreview, saveInlineReviewChanges, submitComposer, handleAssistantMarkdownClick, handleReviewAction, handleSaveDraftDirectly, resolveApplicationPreviewRows, resolveApplicationPreviewEditorControl, resolveApplicationPreviewEditorOptions, isApplicationPreviewEditing, isApplicationPreviewDateEditorOpen, openApplicationPreviewEditor: openApplicationPreviewEditorFromUi, commitApplicationPreviewEditor, commitApplicationPreviewDateEditor, cancelApplicationPreviewEditor, setApplicationPreviewDateMode, canApplyApplicationPreviewDateSelection, handleApplicationPreviewEditorKeydown, buildApplicationPreviewFooterText, isApplicationDraftPayload, resolveApplicationDraftStatusLabel, buildApplicationDraftSummaryItems, shouldShowDraftSavedCard, resolveReimbursementDraftClaimNo, openApplicationDraftDetail, isOperationFeedbackVisible, dismissOperationFeedbackForMessage, submitOperationFeedbackForMessage, closeApplicationSubmitConfirm, confirmApplicationSubmit, closeReviewNextStepConfirm, confirmReviewNextStepSubmit, isDraftSavedReviewMessage, canUseInlineSaveDraft, handleInlineSaveDraft
} }
} }
} }

View File

@@ -8,7 +8,6 @@ import TravelRequestApprovalDialog from '../../components/travel/TravelRequestAp
import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue' import TravelRequestBudgetAnalysis from '../../components/travel/TravelRequestBudgetAnalysis.vue'
import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue' import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDeleteDialog.vue'
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue' import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
import RiskObservationEvidenceCard from '../../components/travel/RiskObservationEvidenceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue' import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import { import {
approveExpenseClaim, approveExpenseClaim,
@@ -16,9 +15,9 @@ import {
deleteExpenseClaimItem, deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment, deleteExpenseClaimItemAttachment,
deleteExpenseClaim, deleteExpenseClaim,
fetchEmployeeLatestProfile,
fetchExpenseClaimItemAttachmentMeta, fetchExpenseClaimItemAttachmentMeta,
fetchExpenseClaimItemAttachmentPreview, fetchExpenseClaimItemAttachmentPreview,
preReviewExpenseClaim,
returnExpenseClaim, returnExpenseClaim,
submitExpenseClaim, submitExpenseClaim,
uploadExpenseClaimItemAttachment, uploadExpenseClaimItemAttachment,
@@ -33,7 +32,8 @@ import {
canReturnExpenseClaims, canReturnExpenseClaims,
isCurrentDirectManagerForRequest, isCurrentDirectManagerForRequest,
isCurrentRequestApplicant, isCurrentRequestApplicant,
isFinanceUser isFinanceUser,
isPlatformAdminUser
} from '../../utils/accessControl.js' } from '../../utils/accessControl.js'
import { import {
buildRiskViewerContext, buildRiskViewerContext,
@@ -67,7 +67,6 @@ import {
buildExpenseItemViewModel, buildExpenseItemViewModel,
buildFallbackExpenseItems, buildFallbackExpenseItems,
buildFallbackProgressSteps, buildFallbackProgressSteps,
buildOptionalTravelReceiptRiskCards,
formatCurrency, formatCurrency,
isPlaceholderValue, isPlaceholderValue,
isApplicationDocumentRequest, isApplicationDocumentRequest,
@@ -84,16 +83,15 @@ import {
resolveExpenseUploadHint resolveExpenseUploadHint
} from './travelRequestDetailExpenseModel.js' } from './travelRequestDetailExpenseModel.js'
import { import {
buildAiPreReviewSnapshot,
findLatestAiPreReviewEvent,
isAiPreReviewFlag,
isAiPreReviewPassed,
resolveAiPreReviewToast,
resolveSubmitActionIcon, resolveSubmitActionIcon,
resolveSubmitActionLabel, resolveSubmitActionLabel,
resolveSubmitConfirmDescription, resolveSubmitConfirmDescription,
resolveSubmitConfirmText resolveSubmitConfirmText
} from './travelRequestDetailPreReviewModel.js' } from './travelRequestDetailSubmitModel.js'
import {
buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts
} from './travelRequestDetailAdviceModel.js'
import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js' import { useTravelRequestPaymentFlow } from './travelRequestDetailPaymentFlow.js'
/* /*
@@ -229,50 +227,6 @@ function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelM
|| source?.created_at || source?.created_at
attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传', attachmentStatus: isSystemGenerated ? '无需附件' : attachments.length ? '已关联票据' : '未上传',
function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (isApplicationDocumentRequest(requestModel)) {
return []
}
const normalizedItems = Array.isArray(items) ? items : []
const isTravelContext =
requestModel?.detailVariant === 'travel' ||
requestModel?.typeCode === 'travel' ||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
if (!isTravelContext) {
return []
}
const hasUploadedType = (itemType) =>
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
const cards = []
if (!hasUploadedType('hotel_ticket')) {
cards.push({
id: 'travel-optional-hotel-ticket',
tone: 'low',
label: '低风险',
title: '住宿票据提醒',
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
})
}
if (!hasUploadedType('ride_ticket')) {
cards.push({
id: 'travel-optional-ride-ticket',
tone: 'low',
label: '低风险',
title: '乘车票据提醒',
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
})
}
return cards
}
function buildExpenseDraftIssues(item) { function buildExpenseDraftIssues(item) {
const issues = [] const issues = []
if (item.isSystemGenerated) { if (item.isSystemGenerated) {
@@ -394,7 +348,6 @@ export default {
ConfirmDialog, ConfirmDialog,
EnterpriseSelect, EnterpriseSelect,
StageRiskAdviceCard, StageRiskAdviceCard,
RiskObservationEvidenceCard,
TravelRequestApprovalDialog, TravelRequestApprovalDialog,
TravelRequestBudgetAnalysis, TravelRequestBudgetAnalysis,
TravelRequestDeleteDialog, TravelRequestDeleteDialog,
@@ -426,8 +379,11 @@ export default {
const deletingExpenseId = ref('') const deletingExpenseId = ref('')
const pendingUploadExpenseId = ref('') const pendingUploadExpenseId = ref('')
const submitBusy = ref(false) const submitBusy = ref(false)
const aiPreReviewSnapshot = ref(null)
const riskFlagPreviewSnapshot = ref(null) const riskFlagPreviewSnapshot = ref(null)
const employeeRiskProfile = ref(null)
const employeeRiskProfileLoading = ref(false)
const employeeRiskProfileError = ref('')
let employeeRiskProfileLoadSeq = 0
const submitConfirmDialogOpen = ref(false) const submitConfirmDialogOpen = ref(false)
const riskOverrideDialogOpen = ref(false) const riskOverrideDialogOpen = ref(false)
const riskOverrideBusy = ref(false) const riskOverrideBusy = ref(false)
@@ -507,6 +463,9 @@ export default {
const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value)) const canManageCurrentClaim = computed(() => canManageExpenseClaims(currentUser.value))
const isArchivedRequest = computed(() => isArchivedRequestView(request.value)) const isArchivedRequest = computed(() => isArchivedRequestView(request.value))
const canDeleteRequest = computed(() => { const canDeleteRequest = computed(() => {
if (isApplicationDocument.value) {
return isPlatformAdminUser(currentUser.value)
}
if (isArchivedRequest.value) { if (isArchivedRequest.value) {
return canDeleteArchivedExpenseClaims(currentUser.value) return canDeleteArchivedExpenseClaims(currentUser.value)
} }
@@ -612,6 +571,7 @@ export default {
const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value)) const leaderApprovalInfo = computed(() => buildLeaderApprovalInfo(request.value))
const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value)) const leaderApprovalEvents = computed(() => buildLeaderApprovalEvents(request.value))
const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0) const hasLeaderApprovalEvents = computed(() => leaderApprovalEvents.value.length > 0)
const hasSingleLeaderApprovalEvent = computed(() => leaderApprovalEvents.value.length === 1)
const leaderApprovalReadonlyMeta = computed(() => { const leaderApprovalReadonlyMeta = computed(() => {
const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : [] const pieces = hasLeaderApprovalEvents.value ? [`${leaderApprovalEvents.value.length} 条批复记录`] : []
if (leaderApprovalInfo.value.generatedDraftClaimNo) { if (leaderApprovalInfo.value.generatedDraftClaimNo) {
@@ -682,7 +642,12 @@ export default {
: `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。` : `${request.value.id} 已确认审核,系统已按预算与风险结果更新流程。`
: `${request.value.id} 已审批通过,流转至财务审批。` : `${request.value.id} 已审批通过,流转至财务审批。`
}) })
const deleteActionLabel = computed(() => (isDraftRequest.value ? '删除草稿' : '删除单据')) const deleteActionLabel = computed(() => {
if (isApplicationDocument.value) {
return '删除申请'
}
return isDraftRequest.value ? '删除草稿' : '删除单据'
})
const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`) const deleteDialogTitle = computed(() => `确认${deleteActionLabel.value} ${request.value.id} 吗?`)
const deleteDialogDescription = computed(() => const deleteDialogDescription = computed(() =>
isDraftRequest.value isDraftRequest.value
@@ -726,7 +691,6 @@ export default {
Object.keys(expenseAttachmentMeta).forEach((key) => { Object.keys(expenseAttachmentMeta).forEach((key) => {
delete expenseAttachmentMeta[key] delete expenseAttachmentMeta[key]
}) })
aiPreReviewSnapshot.value = null
closeAttachmentPreview() closeAttachmentPreview()
} }
pendingUploadExpenseId.value = '' pendingUploadExpenseId.value = ''
@@ -923,15 +887,6 @@ export default {
) { ) {
requestFlags = previewSnapshot.riskFlags requestFlags = previewSnapshot.riskFlags
} }
const snapshot = aiPreReviewSnapshot.value
if (
snapshot
&& snapshot.claimId === request.value?.claimId
&& Array.isArray(snapshot.riskFlags)
&& !requestFlags.some(isAiPreReviewFlag)
) {
return snapshot.riskFlags
}
return requestFlags return requestFlags
} }
@@ -1093,10 +1048,6 @@ export default {
return summary ? `重大风险警示:${summary}` : '重大风险警示' return summary ? `重大风险警示:${summary}` : '重大风险警示'
} }
function applyAiPreReviewPayload(payload) {
aiPreReviewSnapshot.value = buildAiPreReviewSnapshot(payload, request.value.claimId)
}
function applyClaimRiskFlagsPayload(payload) { function applyClaimRiskFlagsPayload(payload) {
const flags = Array.isArray(payload?.claim_risk_flags) const flags = Array.isArray(payload?.claim_risk_flags)
? payload.claim_risk_flags ? payload.claim_risk_flags
@@ -1112,11 +1063,69 @@ export default {
} }
} }
const requiresAiPreReview = computed(() => isEditableRequest.value && !isApplicationDocument.value) function resolveProfileLookupId() {
const aiPreReviewEvent = computed(() => findLatestAiPreReviewEvent(resolveClaimRiskFlags())) return String(
const hasAiPreReviewResult = computed(() => !requiresAiPreReview.value || Boolean(aiPreReviewEvent.value)) request.value?.profileEmployeeId
const aiPreReviewPassed = computed(() => || request.value?.employeeId
isAiPreReviewPassed(aiPreReviewEvent.value, requiresAiPreReview.value) || request.value?.employee_id
|| request.value?.profileName
|| ''
).trim()
}
function resolveProfileExpenseScope() {
const typeCode = String(request.value?.typeCode || '').trim()
return typeCode && !typeCode.endsWith('_application') ? typeCode : 'overall'
}
async function loadEmployeeRiskProfile() {
const employeeId = resolveProfileLookupId()
if (!employeeId || isApplicationDocument.value) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = ''
employeeRiskProfileLoading.value = false
return
}
const sequence = ++employeeRiskProfileLoadSeq
employeeRiskProfileLoading.value = true
employeeRiskProfileError.value = ''
try {
const payload = await fetchEmployeeLatestProfile(employeeId, {
scene: 'approval',
claim_id: request.value?.claimId || '',
window_days: 90,
expense_type_scope: resolveProfileExpenseScope()
})
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = payload || null
}
} catch (error) {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfile.value = null
employeeRiskProfileError.value = error?.message || '用户画像读取失败'
}
} finally {
if (sequence === employeeRiskProfileLoadSeq) {
employeeRiskProfileLoading.value = false
}
}
}
watch(
() => [
request.value?.claimId,
request.value?.profileEmployeeId,
request.value?.employeeId,
request.value?.employee_id,
request.value?.profileName,
request.value?.typeCode,
isApplicationDocument.value
].join('|'),
() => {
void loadEmployeeRiskProfile()
},
{ immediate: true }
) )
const aiAdvice = computed(() => { const aiAdvice = computed(() => {
@@ -1143,19 +1152,22 @@ export default {
}), }),
currentBusinessStage currentBusinessStage
) )
const optionalRiskCards = filterRiskCardsByBusinessStage( const materialPrompts = currentBusinessStage === 'reimbursement'
buildOptionalTravelReceiptRiskCards(request.value, expenseItems.value), ? buildTravelReceiptMaterialPrompts(request.value, expenseItems.value)
currentBusinessStage : []
) const profileAdviceItems = currentBusinessStage === 'reimbursement'
? buildEmployeeProfileAdviceItems(employeeRiskProfile.value)
: []
const scopedRiskCards = [ const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards), ...(hasActionableRiskCards ? [] : summaryRiskCards),
...directRiskCards, ...directRiskCards
...optionalRiskCards
] ]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value) const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
return buildAiAdviceViewModel({ return buildAiAdviceViewModel({
completionItems, completionItems,
materialPrompts,
profileAdviceItems,
riskCards riskCards
}) })
}) })
@@ -1164,12 +1176,17 @@ export default {
aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone))) aiAdvice.value.riskCards.some((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
) )
const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0) const hasAdviceSections = computed(() => aiAdvice.value.sections.length > 0)
const showCompactSafeAdvice = computed(() =>
isEditableRequest.value
&& !isApplicationDocument.value
&& !draftBlockingIssues.value.length
)
const showAiAdvicePanel = computed(() => ( const showAiAdvicePanel = computed(() => (
( (
isEditableRequest.value isEditableRequest.value
&& ( && (
(requiresAiPreReview.value && hasAiPreReviewResult.value) hasAdviceSections.value
|| hasAdviceSections.value || showCompactSafeAdvice.value
) )
) )
|| (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0) || (!isEditableRequest.value && canViewApprovalRiskAdvice.value && aiAdvice.value.riskCards.length > 0)
@@ -1188,24 +1205,22 @@ export default {
!isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value !isEditableRequest.value && isCurrentApplicant.value && !isApplicationDocument.value
? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。' ? '展示票据、行程、金额等可自行修正的风险点,便于提交人先整改,减少后续退单。'
: isEditableRequest.value : isEditableRequest.value
? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : 'AI预审已完成请按风险提示补充原因或进入下一步。') ? (isApplicationDocument.value ? '仅提示申请表单本身需要补充的内容,不展示预算治理细节。' : '系统会在草稿保存和附件识别后自动更新检测结果。')
: '展示系统已识别的风险点,便于审批和后续整改。' : '展示系统已识别的风险点,便于审批和后续整改。'
)) ))
const submitActionLabel = computed(() => { const submitActionLabel = computed(() => {
return resolveSubmitActionLabel({ return resolveSubmitActionLabel({
isApplicationDocument: isApplicationDocument.value, isApplicationDocument: isApplicationDocument.value,
hasAiPreReviewResult: hasAiPreReviewResult.value,
submitBusy: submitBusy.value submitBusy: submitBusy.value
}) })
}) })
const submitActionIcon = computed(() => resolveSubmitActionIcon({ const submitActionIcon = computed(() => resolveSubmitActionIcon({
isApplicationDocument: isApplicationDocument.value, isApplicationDocument: isApplicationDocument.value
hasAiPreReviewResult: hasAiPreReviewResult.value
})) }))
const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({ const submitConfirmDescription = computed(() => resolveSubmitConfirmDescription({
isApplicationDocument: isApplicationDocument.value, isApplicationDocument: isApplicationDocument.value,
aiPreReviewPassed: aiPreReviewPassed.value hasHighRiskWarnings: submitRiskWarnings.value.length > 0
})) }))
const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value)) const submitConfirmText = computed(() => resolveSubmitConfirmText(isApplicationDocument.value))
@@ -1751,21 +1766,6 @@ export default {
} }
} }
async function runAiPreReview() {
submitBusy.value = true
try {
const payload = await preReviewExpenseClaim(request.value.claimId)
applyAiPreReviewPayload(payload)
const event = findLatestAiPreReviewEvent(payload?.risk_flags_json || [])
toast(resolveAiPreReviewToast(event))
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || 'AI预审失败请稍后重试。')
} finally {
submitBusy.value = false
}
}
async function handleSubmit() { async function handleSubmit() {
if (!request.value.claimId) { if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法提交。') toast('当前草稿缺少 claimId暂时无法提交。')
@@ -1782,11 +1782,6 @@ export default {
return return
} }
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) { if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
openRiskOverrideDialog() openRiskOverrideDialog()
return return
@@ -1822,12 +1817,6 @@ export default {
return return
} }
if (requiresAiPreReview.value && !hasAiPreReviewResult.value) {
submitConfirmDialogOpen.value = false
await runAiPreReview()
return
}
if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) { if (submitRiskWarnings.value.length && !hasRiskOverrideExplanation.value) {
submitConfirmDialogOpen.value = false submitConfirmDialogOpen.value = false
openRiskOverrideDialog() openRiskOverrideDialog()
@@ -1843,10 +1832,10 @@ export default {
toast( toast(
isApplicationDocument.value isApplicationDocument.value
? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}` ? `${request.value.id} 申请单已提交${approvalStage ? `,当前节点:${approvalStage}` : ',等待直属领导审批'}`
: `${request.value.id}完成 AI预审${approvalStage ? `,当前节点:${approvalStage}` : ',并已提交审批'}` : `${request.value.id}提交审批${approvalStage ? `,当前节点:${approvalStage}` : ''}`
) )
} else if (claimStatus === 'supplement') { } else if (claimStatus === 'supplement') {
toast(`${request.value.id} AI预审未通过,已转待补充。`) toast(`${request.value.id} 自动检测未通过,已转待补充。`)
} else { } else {
toast(`${request.value.id} 提交结果已更新。`) toast(`${request.value.id} 提交结果已更新。`)
} }
@@ -2062,7 +2051,7 @@ export default {
isMajorExpenseRisk, isMajorExpenseRisk,
openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview, openAiEntry, openAttachmentPreview, goToNextAttachmentPreview, goToPreviousAttachmentPreview,
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem, payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, leaderApprovalEvents, leaderApprovalReadonlyMeta, hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle, resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,

View File

@@ -116,21 +116,105 @@ function resolvePreviousPeriod(year, quarter) {
return { year: year - 1, quarter: 4 } return { year: year - 1, quarter: 4 }
} }
export function shouldUseBudgetCompileReport(rawText, options = {}) { function resolveDepartmentNameFromText(rawText) {
if (String(options.sessionType || '').trim() !== 'budget') { const text = String(rawText || '')
return false const match = text.match(/(市场部|财务部|技术部|人力资源部|生产部|总裁办)/)
return match ? match[1] : ''
}
function normalizeBudgetContext(context) {
return context && typeof context === 'object' ? context : null
}
function resolveContextMode(context) {
return String(context?.mode || '').trim() === 'review' ? 'review' : 'edit'
}
function buildFinanceSuggestion(item, mode) {
if (mode !== 'review') {
return ''
} }
if (item.riskTone === 'risk') {
return `${item.name}增幅较高,建议压降到可归控额度,并要求预算管理者补充业务依据。`
}
if (item.riskTone === 'alert') {
return `${item.name}建议结合上一周期实际发生额复核,避免预算冗余。`
}
return `${item.name}预算结构基本合理,建议按提交金额形成预算。`
}
function resolveSuggestedBudgetAmount(row) {
const amount = Number(row.amount || row.budgetAmount || row.recommendedBudget || 0)
const tone = String(row.riskTone || '').trim()
if (tone === 'risk') return Math.round(amount * 0.92)
if (tone === 'alert') return Math.round(amount * 0.96)
return amount
}
function buildItemsFromBudgetContext(context, fallbackItems) {
const rows = Array.isArray(context?.categoryRows) ? context.categoryRows : []
const mode = resolveContextMode(context)
if (!rows.length) return fallbackItems
return rows.map((row, index) => {
const fallback = fallbackItems[index] || PREVIOUS_QUARTER_SPEND[index] || {}
const amount = Number(row.amount || fallback.recommendedBudget || 0)
const used = Number(row.used || 0)
const occupied = Number(row.occupied || 0)
const value = used + occupied
const suggestedBudget = resolveSuggestedBudgetAmount(row)
const item = {
key: row.code || fallback.key || `budget-${index}`,
name: row.name || fallback.name || '预算科目',
value,
previousValue: Number(fallback.previousValue || 0),
recommendedBudget: suggestedBudget,
color: BUDGET_REPORT_COLORS[row.code] || fallback.color || BUDGET_REPORT_COLORS.travel,
drivers: Array.isArray(fallback.drivers) ? fallback.drivers : [],
risk: row.note || `${row.name || '该费用类型'}预算提交金额为 ${compactCurrency(amount)},已发生与已占用合计 ${compactCurrency(value)}`,
suggestion: buildFinanceSuggestion({ ...row, name: row.name || fallback.name, riskTone: row.riskTone }, mode),
amountDisplay: compactCurrency(value),
display: row.usageRateLabel || '0.0%',
share: row.usageRateLabel || '0.0%',
trend: row.usageRateLabel || '0.0%',
trendTone: row.riskTone === 'risk' ? 'risk' : row.riskTone === 'alert' ? 'warn' : 'stable',
recommendedDisplay: compactCurrency(suggestedBudget),
editableBudget: amount,
suggestedBudget,
submittedNote: row.note || '',
financeSuggestion: buildFinanceSuggestion({ ...row, name: row.name || fallback.name, riskTone: row.riskTone }, mode)
}
return item
})
}
export function shouldUseBudgetCompileReport(rawText, options = {}) {
const sessionType = String(options.sessionType || '').trim()
const entrySource = String(options.entrySource || '').trim()
const budgetContext = normalizeBudgetContext(options.budgetContext)
const text = normalizeBudgetText(rawText) const text = normalizeBudgetText(rawText)
const hasTargetPeriod = parseQuarter(rawText) || hasExplicitYear(rawText) const hasTargetPeriod = parseQuarter(rawText) || hasExplicitYear(rawText)
const hasBudgetKeyword = /(预算|budget)/.test(text)
const hasCompileKeyword = /(编制|制定|测算|生成|规划|预算一下|编辑|修改|调整|compile|create|plan|edit)/.test(text)
const hasReviewKeyword = /(审核|复核|审预算|形成预算|回退预算|review|audit)/.test(text)
const isBudgetContext = sessionType === 'budget' || entrySource === 'budget'
const isWholeBudgetCompileIntent = hasBudgetKeyword && hasCompileKeyword && hasTargetPeriod
const isBudgetContextPeriodIntent = isBudgetContext && hasBudgetKeyword && (hasTargetPeriod || hasReviewKeyword)
const isBudgetContextEditIntent = isBudgetContext && hasBudgetKeyword && hasCompileKeyword
return Boolean( return Boolean(
text && budgetContext ||
/(预算|budget)/.test(text) && (
/(编制|制定|测算|生成|规划|预算一下|compile|create|plan)/.test(text) && text &&
hasTargetPeriod (isWholeBudgetCompileIntent || isBudgetContextPeriodIntent || isBudgetContextEditIntent)
)
) )
} }
export function buildBudgetCompileReport(rawText, user = {}) { export function buildBudgetCompileReport(rawText, user = {}, budgetContext = null) {
const context = normalizeBudgetContext(budgetContext)
const contextMode = resolveContextMode(context)
const isReviewMode = contextMode === 'review'
const targetYear = parseYear(rawText) const targetYear = parseYear(rawText)
const parsedQuarter = parseQuarter(rawText) const parsedQuarter = parseQuarter(rawText)
const isAnnualBudget = !parsedQuarter const isAnnualBudget = !parsedQuarter
@@ -142,12 +226,34 @@ export function buildBudgetCompileReport(rawText, user = {}) {
const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value * periodMultiplier, 0) const totalSpend = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.value * periodMultiplier, 0)
const totalBudget = 1320000 * periodMultiplier const totalBudget = 1320000 * periodMultiplier
const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget * periodMultiplier, 0) const recommendedTotal = PREVIOUS_QUARTER_SPEND.reduce((sum, item) => sum + item.recommendedBudget * periodMultiplier, 0)
const departmentName = String(user.departmentName || user.department || '').trim() || '当前部门' const departmentName = String(
context?.departmentName ||
resolveDepartmentNameFromText(rawText) ||
user.departmentName ||
user.department ||
user.department_name ||
''
).trim() || '当前部门'
const items = PREVIOUS_QUARTER_SPEND.map((item) => { const simulatedItems = PREVIOUS_QUARTER_SPEND.map((item) => {
const value = item.value * periodMultiplier const value = item.value * periodMultiplier
const previousValue = item.previousValue * periodMultiplier const previousValue = item.previousValue * periodMultiplier
const recommendedBudget = item.recommendedBudget * periodMultiplier const recommendedBudget = item.recommendedBudget * periodMultiplier
const risk = isAnnualBudget
? item.risk
.replace(/Q2/g, `${previous.year}年度`)
.replace(/Q3/g, `${targetYear}年度`)
.replace(/季度/g, '年度')
: item.risk
const suggestion = isAnnualBudget
? item.suggestion
.replace(/Q3/g, `${targetYear}年度`)
.replace(/季度/g, '年度')
.replace(/52-56 万/g, '208-224 万')
.replace(/30-32 万/g, '120-128 万')
.replace(/19-20 万/g, '76-80 万')
.replace(/10-11 万/g, '40-44 万')
: item.suggestion
const trendValue = item.previousValue const trendValue = item.previousValue
? ((value - previousValue) / previousValue) * 100 ? ((value - previousValue) / previousValue) * 100
: 0 : 0
@@ -166,9 +272,15 @@ export function buildBudgetCompileReport(rawText, user = {}) {
reminderThreshold: item.key === 'communication' || item.key === 'office' ? 60 : 70, reminderThreshold: item.key === 'communication' || item.key === 'office' ? 60 : 70,
alertThreshold: item.key === 'communication' || item.key === 'office' ? 70 : 80, alertThreshold: item.key === 'communication' || item.key === 'office' ? 70 : 80,
riskThreshold: item.key === 'communication' || item.key === 'office' ? 80 : 90, riskThreshold: item.key === 'communication' || item.key === 'office' ? 80 : 90,
editNote: item.suggestion risk,
suggestion,
editNote: suggestion
} }
}) })
const items = buildItemsFromBudgetContext(context, simulatedItems)
const reportSpend = isReviewMode
? items.reduce((sum, item) => sum + Number(item.value || 0), 0)
: totalSpend
const topItem = [...items].sort((a, b) => b.value - a.value)[0] const topItem = [...items].sort((a, b) => b.value - a.value)[0]
const growthItem = [...items].sort((a, b) => { const growthItem = [...items].sort((a, b) => {
@@ -177,49 +289,69 @@ export function buildBudgetCompileReport(rawText, user = {}) {
return bGrowth - aGrowth return bGrowth - aGrowth
})[0] })[0]
const submittedBudgetTotal = items.reduce((sum, item) => sum + Number(item.editableBudget || item.recommendedBudget || 0), 0)
const financeSuggestedTotal = items.reduce((sum, item) => sum + Number(item.suggestedBudget || item.recommendedBudget || 0), 0)
return { return {
type: 'budget_compile_analysis', type: 'budget_compile_analysis',
title: isAnnualBudget mode: contextMode,
? `${targetYear}年度预算编制前置分析报告` title: isReviewMode
: `${targetYear}${targetQuarter}季度预算编制前置分析报告`, ? `${departmentName}${context?.periodLabel || ''}预算审核分析报告`
subtitle: isAnnualBudget : isAnnualBudget
? `基于${previous.year}年度预算执行模拟数据` ? `${targetYear}年度预算编制前置分析报告`
: `基于${previous.year}${previous.quarter}季度预算执行模拟数据`, : `${targetYear}${targetQuarter}季度预算编制前置分析报告`,
subtitle: isReviewMode
? `${context?.budgetNo || '部门提交预算'} / ${context?.version || '待审核版本'}`
: isAnnualBudget
? `基于${previous.year}年度预算执行模拟数据`
: `基于${previous.year}${previous.quarter}季度预算执行模拟数据`,
departmentName, departmentName,
targetPeriod: isAnnualBudget ? `${targetYear}年度` : `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`, targetPeriod: context?.periodLabel || (isAnnualBudget ? `${targetYear}年度` : `${targetYear}${QUARTER_NAME_MAP[targetQuarter]}`),
basePeriod: isAnnualBudget ? `${previous.year}年度` : `${previous.year}${QUARTER_NAME_MAP[previous.quarter]}`, basePeriod: isAnnualBudget ? `${previous.year}年度` : `${previous.year}${QUARTER_NAME_MAP[previous.quarter]}`,
periodType: isAnnualBudget ? '年度预算' : '季度预算', periodType: isAnnualBudget ? '年度预算' : '季度预算',
centerValue: compactCurrency(totalSpend), centerValue: compactCurrency(reportSpend),
centerLabel: isAnnualBudget ? '去年开销' : '上季度开销', centerLabel: isAnnualBudget ? '去年开销' : '上季度开销',
summary: { summary: {
totalBudget: compactCurrency(totalBudget), totalBudget: compactCurrency(isReviewMode ? submittedBudgetTotal : totalBudget),
totalSpend: compactCurrency(totalSpend), totalSpend: compactCurrency(reportSpend),
usageRate: percent(totalSpend, totalBudget), usageRate: percent(reportSpend, isReviewMode ? submittedBudgetTotal : totalBudget),
recommendedTotal: compactCurrency(recommendedTotal) recommendedTotal: compactCurrency(isReviewMode ? financeSuggestedTotal : recommendedTotal)
}, },
macroInsights: [ macroInsights: [
`${isAnnualBudget ? `${previous.year}年度` : `${previous.year}${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)},预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间。`, isReviewMode
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}预算编制的第一优先级`, ? `${departmentName}本次提交预算 ${compactCurrency(submittedBudgetTotal)}AI 建议可归控预算 ${compactCurrency(financeSuggestedTotal)},请高级财务人员确认是否需要回退调整`
`${growthItem.name}环比增长 ${growthItem.trend}需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料` : `${isAnnualBudget ? `${previous.year}年度` : `${previous.year}${previous.quarter}季度`}实际开销 ${compactCurrency(totalSpend)}预算使用率 ${percent(totalSpend, totalBudget)},整体仍在可控区间`,
`${topItem.name}是最大开销项,占 ${topItem.share},建议作为${isReviewMode ? '审核重点' : `${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}预算编制的第一优先级`}`,
isReviewMode
? `${growthItem.name}需要重点核对预算说明、业务依据和可归控空间;如果建议预算低于提交预算,应写明回退理由。`
: `${growthItem.name}环比增长 ${growthItem.trend},需要在预算说明中提前解释业务驱动,避免后续报销阶段反复补充材料。`
], ],
items, items,
editableDraft: { editableDraft: {
status: 'editing', status: 'editing',
mode: contextMode,
departmentName,
rows: items.map((item) => ({ rows: items.map((item) => ({
key: item.key, key: item.key,
name: item.name, name: item.name,
departmentName,
budgetAmount: item.editableBudget, budgetAmount: item.editableBudget,
reminderThreshold: item.reminderThreshold, suggestedBudget: item.suggestedBudget || item.recommendedBudget || item.editableBudget,
alertThreshold: item.alertThreshold, submittedNote: item.submittedNote || item.editNote,
riskThreshold: item.riskThreshold, financeSuggestion: item.financeSuggestion || ''
note: item.editNote
})) }))
}, },
recommendations: [ recommendations: isReviewMode
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`, ? [
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。', '审核时先看预算管理者提交说明是否覆盖业务增长、已占用事项和可归控边界。',
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。' '建议预算低于提交预算时,应在“建议”列写明压降原因,并回退预算给预算管理者再次编辑。',
], '如果建议预算与提交预算一致且说明充分,可以直接形成正式预算。'
]
: [
`建议${isAnnualBudget ? `${targetYear}年度` : `${targetYear}${targetQuarter}季度`}总预算先按 ${compactCurrency(recommendedTotal)} 编制,再预留 5%-8% 部门机动池。`,
'差旅和招待费采用更早的提醒阈值,通信和办公用品保持稳定额度,避免把预算过度分散到低波动项目。',
'正式编制时建议把重点项目、客户活动和集中采购计划写入预算说明,后续费用控制会更容易解释。'
],
generatedAt: '模拟数据 · 用于 Demo 预览' generatedAt: '模拟数据 · 用于 Demo 预览'
} }
} }
@@ -243,20 +375,31 @@ export async function handleBudgetCompileReportSubmit(runtime) {
rawText, rawText,
replaceMessage, replaceMessage,
resetFlowRun, resetFlowRun,
refreshCurrentUserFromBackend,
budgetContext,
scrollToBottom, scrollToBottom,
startFlowStep, startFlowStep,
submitting, submitting,
userText userText
} = runtime } = runtime
const analysisStartedAt = Date.now() const analysisStartedAt = Date.now()
const context = normalizeBudgetContext(budgetContext)
const isReviewRequest = resolveContextMode(context) === 'review'
const isAnnualRequest = hasExplicitYear(rawText) && !parseQuarter(rawText)
const basePeriodLabel = isReviewRequest ? '部门提交预算分析' : isAnnualRequest ? '去年预算开销分析' : '上季度预算开销分析'
const recommendationLabel = isReviewRequest ? '高级财务审核建议生成' : isAnnualRequest ? '年度预算编制建议生成' : '预算编制建议生成'
resetFlowRun() resetFlowRun()
startFlowStep('budget-prior-quarter-analysis', { startFlowStep('budget-prior-quarter-analysis', {
title: '上季度预算开销分析', title: basePeriodLabel,
tool: 'budget.analysis.previous_quarter', tool: 'budget.analysis.previous_quarter',
detail: '正在汇总上季度费用占比、增长点和下一季度编制建议...' detail: isReviewRequest
? '正在读取部门提交预算表,分析费用结构、历史消耗和可归控空间...'
: isAnnualRequest
? '正在汇总去年费用占比、增长点和年度预算编制建议...'
: '正在汇总上季度费用占比、增长点和下一季度编制建议...'
}) })
startFlowStep('budget-compile-guidance', { startFlowStep('budget-compile-guidance', {
title: '预算编制建议生成', title: recommendationLabel,
tool: 'budget.compile.recommendation', tool: 'budget.compile.recommendation',
detail: '正在生成预算编制前置分析报告...' detail: '正在生成预算编制前置分析报告...'
}) })
@@ -265,7 +408,11 @@ export async function handleBudgetCompileReportSubmit(runtime) {
} }
const pendingMessage = createMessage( const pendingMessage = createMessage(
'assistant', 'assistant',
'我先不直接进入预算表单,先执行上季度预算开销结构分析,再给您一版下一季度预算编制建议。', isReviewRequest
? '我先加载部门提交的预算表,结合费用结构和预算说明生成高级财务审核建议。'
: isAnnualRequest
? '我先不直接进入预算表单,先执行去年预算开销结构分析,再给您一版年度预算编制建议。'
: '我先不直接进入预算表单,先执行上季度预算开销结构分析,再给您一版下一季度预算编制建议。',
[], [],
{ meta: ['预算分析中'] } { meta: ['预算分析中'] }
) )
@@ -286,20 +433,36 @@ export async function handleBudgetCompileReportSubmit(runtime) {
try { try {
await new Promise((resolve) => setTimeout(resolve, 360)) await new Promise((resolve) => setTimeout(resolve, 360))
const budgetReport = buildBudgetCompileReport(rawText, currentUser.value || {}) let reportUser = currentUser.value || {}
const hasUserDepartment = String(
reportUser.departmentName || reportUser.department || reportUser.department_name || ''
).trim()
if (!hasUserDepartment && typeof refreshCurrentUserFromBackend === 'function') {
await refreshCurrentUserFromBackend({ silent: true })
reportUser = currentUser.value || reportUser
}
const budgetReport = buildBudgetCompileReport(rawText, reportUser, context)
completeFlowStep( completeFlowStep(
'budget-prior-quarter-analysis', 'budget-prior-quarter-analysis',
'已完成上季度费用占比、增长点和风险点分析', isReviewRequest
? '已完成部门提交预算、费用结构和风险点分析'
: isAnnualRequest
? '已完成去年费用占比、增长点和风险点分析'
: '已完成上季度费用占比、增长点和风险点分析',
Date.now() - analysisStartedAt Date.now() - analysisStartedAt
) )
completeFlowStep( completeFlowStep(
'budget-compile-guidance', 'budget-compile-guidance',
'已生成下一季度预算编制建议', isReviewRequest ? '已生成高级财务审核建议' : isAnnualRequest ? '已生成年度预算编制建议' : '已生成下一季度预算编制建议',
Date.now() - analysisStartedAt Date.now() - analysisStartedAt
) )
replaceMessage(pendingMessage.id, createMessage( replaceMessage(pendingMessage.id, createMessage(
'assistant', 'assistant',
'下面先按上季度模拟数据做一版预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。', isReviewRequest
? '下面先按部门提交的预算草案做一版审核分析。正式接入预算池后,这里会替换成真实提交记录、历史消耗和归控建议。'
: isAnnualRequest
? '下面先按去年模拟数据做一版年度预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。'
: '下面先按上季度模拟数据做一版预算编制前置分析。正式接入预算池后,这里会替换成真实部门预算和报销消耗数据。',
[], [],
{ {
meta: ['预算分析报告', '模拟数据'], meta: ['预算分析报告', '模拟数据'],

View File

@@ -73,6 +73,34 @@ function normalizeApplicationDate(claim) {
) )
} }
function normalizeApplicationDateText(value) {
const text = normalizeText(value)
if (!text) {
return ''
}
const matched = text.match(/^(\d{4}-\d{2}-\d{2})/)
return matched?.[1] || text
}
function normalizeApplicationBusinessTime(claim) {
const start = normalizeApplicationDateText(claim?.start_date || claim?.startDate || claim?.begin_date || claim?.beginDate)
const end = normalizeApplicationDateText(claim?.end_date || claim?.endDate || claim?.finish_date || claim?.finishDate)
if (start && end && start !== end) {
return `${start}${end}`
}
return normalizeApplicationDateText(
start
|| claim?.business_time
|| claim?.businessTime
|| claim?.time_range
|| claim?.timeRange
|| claim?.occurred_at
|| claim?.occurredAt
|| claim?.occurred_date
|| claim?.occurredDate
)
}
function toTimestamp(value) { function toTimestamp(value) {
const date = new Date(value) const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime() return Number.isNaN(date.getTime()) ? 0 : date.getTime()
@@ -216,6 +244,7 @@ export function normalizeRequiredApplicationCandidate(claim) {
location, location,
amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount), amount: normalizeText(claim?.amount || claim?.budget_amount || claim?.budgetAmount),
amount_label: amountText, amount_label: amountText,
business_time: normalizeApplicationBusinessTime(claim),
status, status,
status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status), status_label: STATUS_LABELS[status] || normalizeText(claim?.approval_stage || claim?.approvalStage || status),
application_date: normalizeApplicationDate(claim) application_date: normalizeApplicationDate(claim)
@@ -247,6 +276,7 @@ export function buildRequiredApplicationActions(applications, actionType) {
const claimNo = normalizeText(application.claim_no) || '未编号申请单' const claimNo = normalizeText(application.claim_no) || '未编号申请单'
const description = [ const description = [
application.status_label, application.status_label,
application.business_time && `时间:${application.business_time}`,
application.location && `地点:${application.location}`, application.location && `地点:${application.location}`,
application.amount_label && `预算:${application.amount_label}`, application.amount_label && `预算:${application.amount_label}`,
application.reason && `事由:${application.reason}` application.reason && `事由:${application.reason}`
@@ -265,6 +295,7 @@ export function buildRequiredApplicationActions(applications, actionType) {
application_location: application.location, application_location: application.location,
application_amount: application.amount, application_amount: application.amount,
application_amount_label: application.amount_label, application_amount_label: application.amount_label,
application_business_time: application.business_time,
application_status: application.status, application_status: application.status,
application_status_label: application.status_label, application_status_label: application.status_label,
application_date: application.application_date application_date: application.application_date

View File

@@ -160,6 +160,12 @@ export const FLOW_STEP_FALLBACKS = {
runningText: '正在把已确认信息保存为草稿...', runningText: '正在把已确认信息保存为草稿...',
completedText: '草稿已保存' completedText: '草稿已保存'
}, },
'draft-risk-review': {
title: '草稿风险识别',
tool: 'RuleEngine',
runningText: '正在对草稿执行规则校验...',
completedText: '已完成草稿风险识别'
},
'application-submit-success': { 'application-submit-success': {
title: '申请单提交成功', title: '申请单提交成功',
tool: 'ApplicationSubmit', tool: 'ApplicationSubmit',

View File

@@ -110,6 +110,20 @@ function normalizeValues(values) {
}, {}) }, {})
} }
function hasLinkedApplication(values) {
return Boolean(normalizeText(values?.application_claim_id) || normalizeText(values?.application_claim_no))
}
function buildApplicationSummaryParts(values) {
return [
normalizeText(values?.application_claim_no),
normalizeText(values?.application_reason),
normalizeText(values?.application_business_time),
normalizeText(values?.application_location),
normalizeText(values?.application_amount_label || values?.application_amount)
].filter(Boolean)
}
function normalizeApplicationCandidates(applications) { function normalizeApplicationCandidates(applications) {
if (!Array.isArray(applications)) { if (!Array.isArray(applications)) {
return [] return []
@@ -125,6 +139,7 @@ function normalizeApplicationCandidates(applications) {
location: normalizeText(item.location || item.application_location), location: normalizeText(item.location || item.application_location),
amount: normalizeText(item.amount || item.application_amount), amount: normalizeText(item.amount || item.application_amount),
amount_label: normalizeText(item.amount_label || item.application_amount_label), amount_label: normalizeText(item.amount_label || item.application_amount_label),
business_time: normalizeText(item.business_time || item.application_business_time),
status: normalizeText(item.status || item.application_status), status: normalizeText(item.status || item.application_status),
status_label: normalizeText(item.status_label || item.application_status_label), status_label: normalizeText(item.status_label || item.application_status_label),
application_date: normalizeText(item.application_date) application_date: normalizeText(item.application_date)
@@ -238,7 +253,6 @@ export function waitForGuidedApplicationSelection(state, expenseType, applicatio
export function selectGuidedRequiredApplication(state, application = {}) { export function selectGuidedRequiredApplication(state, application = {}) {
const current = normalizeGuidedFlowState(state) const current = normalizeGuidedFlowState(state)
const steps = getGuidedReimbursementSteps(current.expenseType)
return { return {
...current, ...current,
values: normalizeValues({ values: normalizeValues({
@@ -249,9 +263,11 @@ export function selectGuidedRequiredApplication(state, application = {}) {
application_location: application.application_location || application.location || '', application_location: application.application_location || application.location || '',
application_amount: application.application_amount || application.amount || '', application_amount: application.application_amount || application.amount || '',
application_amount_label: application.application_amount_label || application.amount_label || '', application_amount_label: application.application_amount_label || application.amount_label || '',
application_status_label: application.application_status_label || application.status_label || '' application_business_time: application.application_business_time || application.business_time || '',
application_status_label: application.application_status_label || application.status_label || '',
application_date: application.application_date || ''
}), }),
stepKey: steps[0]?.key || 'summary', stepKey: 'summary',
pendingInterruptionText: '', pendingInterruptionText: '',
applicationCandidates: [] applicationCandidates: []
} }
@@ -346,40 +362,41 @@ export function buildGuidedReimbursementSummaryText(state) {
const current = normalizeGuidedFlowState(state) const current = normalizeGuidedFlowState(state)
const typeLabel = getGuidedExpenseTypeLabel(current.expenseType) || '报销' const typeLabel = getGuidedExpenseTypeLabel(current.expenseType) || '报销'
const steps = getGuidedReimbursementSteps(current.expenseType) const steps = getGuidedReimbursementSteps(current.expenseType)
const linkedApplication = hasLinkedApplication(current.values)
const lines = [ const lines = [
`已完成“${typeLabel}”的引导填写。`, `已完成“${typeLabel}”的引导填写。`,
'', '',
'请核查下面的关键信息:' '请核查下面的关键信息:'
] ]
if (current.values.application_claim_no) { if (linkedApplication) {
const applicationParts = [ const applicationParts = buildApplicationSummaryParts(current.values)
current.values.application_claim_no,
current.values.application_reason,
current.values.application_location,
current.values.application_amount_label
].filter(Boolean)
lines.push(`- 关联申请单:${applicationParts.join(' / ')}`) lines.push(`- 关联申请单:${applicationParts.join(' / ')}`)
lines.push('- 报销票据:可先生成草稿,随后在草稿详情中上传对应票据。')
} else {
steps.forEach((step) => {
const value = step.key === 'attachments'
? (current.values.attachment_names?.length
? current.values.attachment_names.join('、')
: current.values.attachments || '稍后上传')
: current.values[step.key]
lines.push(`- ${step.summaryLabel}${value || '待补充'}`)
})
} }
steps.forEach((step) => {
const value = step.key === 'attachments'
? (current.values.attachment_names?.length
? current.values.attachment_names.join('、')
: current.values.attachments || '稍后上传')
: current.values[step.key]
lines.push(`- ${step.summaryLabel}${value || '待补充'}`)
})
lines.push('') lines.push('')
lines.push('如果这些信息无误,我可以继续生成右侧报销核对信息;生成核对信息后,再由你决定保存草稿或继续下一步。') lines.push(
linkedApplication
? '如果关联信息无误,我可以直接生成报销草稿;后续由你在草稿详情中上传和归集票据。'
: '如果这些信息无误,我可以继续生成报销草稿;草稿生成后可继续上传票据或补充信息。'
)
return lines.join('\n') return lines.join('\n')
} }
export function buildGuidedReviewConfirmationActions() { export function buildGuidedReviewConfirmationActions() {
return [{ return [{
label: '生成报销核对信息', label: '生成报销草稿',
description: '进入现有报销核对流程,不会直接保存草稿', description: '使用当前信息生成草稿,票据可在草稿详情继续上传',
icon: 'mdi mdi-clipboard-check-outline', icon: 'mdi mdi-clipboard-check-outline',
action_type: GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW action_type: GUIDED_ACTION_CONFIRM_REIMBURSEMENT_REVIEW
}] }]
@@ -390,14 +407,23 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
const type = getGuidedExpenseType(current.expenseType) const type = getGuidedExpenseType(current.expenseType)
const values = current.values || {} const values = current.values || {}
const typeLabel = type?.label || '其他费用' const typeLabel = type?.label || '其他费用'
const fieldLines = getGuidedReimbursementSteps(current.expenseType).map((step) => { const linkedApplication = hasLinkedApplication(values)
const value = step.key === 'attachments' const applicationReason = values.application_reason || ''
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传') const applicationLocation = values.application_location || ''
: values[step.key] const applicationAmount = values.application_amount || values.application_amount_label || ''
return `${step.summaryLabel}${value || '待补充'}` const applicationBusinessTime = values.application_business_time || ''
}) const fieldLines = []
if (values.application_claim_no) { if (linkedApplication) {
fieldLines.unshift(`关联申请单:${values.application_claim_no}`) const applicationParts = buildApplicationSummaryParts(values)
fieldLines.push(`关联申请单:${applicationParts.join(' / ')}`)
fieldLines.push('报销票据:草稿生成后在详情中上传')
} else {
getGuidedReimbursementSteps(current.expenseType).forEach((step) => {
const value = step.key === 'attachments'
? (values.attachment_names?.length ? values.attachment_names.join('、') : values.attachments || '稍后上传')
: values[step.key]
fieldLines.push(`${step.summaryLabel}${value || '待补充'}`)
})
} }
const rawText = [ const rawText = [
`报销类型:${typeLabel}`, `报销类型:${typeLabel}`,
@@ -406,31 +432,35 @@ export function buildGuidedReviewSubmitOptions(state, files = []) {
const reviewFormValues = { const reviewFormValues = {
expense_type: typeLabel, expense_type: typeLabel,
reimbursement_type: typeLabel, reimbursement_type: typeLabel,
reason: values.reason || values.customer_name || '', reason: values.reason || applicationReason || values.customer_name || '',
reason_value: values.reason || '', reason_value: values.reason || applicationReason || '',
customer_name: values.customer_name || '', customer_name: values.customer_name || '',
participants: values.participants || '', participants: values.participants || '',
location: values.location || '', location: values.location || applicationLocation || '',
business_location: values.location || '', business_location: values.location || applicationLocation || '',
time_range: values.time_range || '', time_range: values.time_range || applicationBusinessTime || '',
business_time: values.time_range || '', business_time: values.time_range || applicationBusinessTime || '',
amount: values.amount || '', amount: linkedApplication ? (values.amount || '') : (values.amount || applicationAmount || ''),
attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [], attachment_names: Array.isArray(values.attachment_names) ? values.attachment_names : [],
application_claim_id: values.application_claim_id || '', application_claim_id: values.application_claim_id || '',
application_claim_no: values.application_claim_no || '', application_claim_no: values.application_claim_no || '',
application_reason: values.application_reason || '', application_reason: values.application_reason || '',
application_location: values.application_location || '', application_location: values.application_location || '',
application_amount: values.application_amount || '' application_amount: values.application_amount || '',
application_amount_label: values.application_amount_label || '',
application_business_time: values.application_business_time || '',
application_date: values.application_date || ''
} }
return { return {
rawText, rawText,
userText: '生成报销核对信息', userText: '生成报销草稿',
pendingText: '正在生成右侧报销核对信息...', pendingText: '正在生成报销草稿...',
systemGenerated: true, systemGenerated: true,
files, files,
extraContext: { extraContext: {
draft_claim_id: '', draft_claim_id: '',
review_action: 'save_draft',
user_input_text: rawText, user_input_text: rawText,
expense_scene_selection: { expense_scene_selection: {
expense_type: type?.key || current.expenseType || 'other', expense_type: type?.key || current.expenseType || 'other',

View File

@@ -1306,6 +1306,25 @@ export function buildReviewPlainFollowupCopy(reviewPayload, options = {}) {
const riskBriefs = resolvePresentationRiskBriefs(reviewPayload) const riskBriefs = resolvePresentationRiskBriefs(reviewPayload)
const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length const extraMissingCount = resolveReviewExtraMissingLabels(reviewPayload).length
if (savedDraft) {
const issueParts = []
if (riskBriefs.length) {
issueParts.push(`${riskBriefs.length} 条风险/异常提醒`)
}
if (pendingCount || extraMissingCount) {
issueParts.push(`${pendingCount || extraMissingCount} 项待补充信息`)
}
return {
lead: '后续处理:',
tone: riskBriefs.length || pendingCount || extraMissingCount ? 'danger' : 'neutral',
summary: issueParts.length
? `自动检测识别到 ${issueParts.join('、')},请进入详情核对;如还有票据可继续上传。`
: '自动检测暂未发现明确风险;如还有票据可继续上传。',
items: [],
notes: []
}
}
if (pendingCount || extraMissingCount) { if (pendingCount || extraMissingCount) {
const summarySignature = [ const summarySignature = [
pendingCount || extraMissingCount, pendingCount || extraMissingCount,

View File

@@ -0,0 +1,119 @@
function normalizeText(value) {
return String(value || '').trim()
}
function uniqueTexts(values) {
return [...new Set(values.map((item) => normalizeText(item)).filter(Boolean))]
}
function isPlaceholderValue(value) {
const text = normalizeText(value)
if (!text) {
return true
}
return ['待补充', '暂无', '无', '未知', '处理中'].includes(text.replace(/\s+/g, ''))
}
function isApplicationDocumentRequest(requestModel) {
const documentType = normalizeText(
requestModel?.documentTypeCode
|| requestModel?.document_type_code
|| requestModel?.documentType
|| requestModel?.document_type
).toLowerCase()
const claimNo = normalizeText(requestModel?.claimNo || requestModel?.claim_no || requestModel?.documentNo).toUpperCase()
return documentType === 'application' || claimNo.startsWith('AP-') || claimNo.startsWith('APP-')
}
function isHotelExpenseItem(item) {
const text = [
item?.itemType,
item?.typeCode,
item?.name,
item?.category,
item?.desc,
item?.itemReason
].map((value) => normalizeText(value)).join(' ')
return /hotel_ticket|hotel|住宿|酒店|水单/.test(text)
}
export function buildTravelReceiptMaterialPrompts(requestModel, items) {
if (isApplicationDocumentRequest(requestModel)) {
return []
}
const normalizedItems = Array.isArray(items) ? items : []
const missingHotelItems = normalizedItems.filter(
(item) => !item?.isSystemGenerated && isHotelExpenseItem(item) && isPlaceholderValue(item.invoiceId)
)
if (!missingHotelItems.length) {
return []
}
return [
`当前包含 ${missingHotelItems.length} 条住宿费用明细,但暂未关联住宿发票或酒店水单。请补充住宿材料,避免后续被退回补件。`
]
}
function profileMetric(profile, key) {
const profiles = Array.isArray(profile?.profiles) ? profile.profiles : []
for (const item of profiles) {
const metrics = item?.metrics && typeof item.metrics === 'object' ? item.metrics : {}
const value = Number(metrics[key])
if (Number.isFinite(value) && value > 0) {
return value
}
}
return 0
}
function profileReviewSuggestionTexts(profile) {
const suggestions = Array.isArray(profile?.review_suggestions)
? profile.review_suggestions
: Array.isArray(profile?.reviewSuggestions)
? profile.reviewSuggestions
: []
return suggestions
.map((item) => normalizeText(item?.message || item?.title || item?.label))
.filter(Boolean)
}
function profileRiskTagTexts(profile) {
const tags = Array.isArray(profile?.profile_tags)
? profile.profile_tags
: Array.isArray(profile?.profileTags)
? profile.profileTags
: []
return tags
.filter((tag) => normalizeText(tag?.polarity) === 'risk')
.map((tag) => normalizeText(tag?.reason || tag?.display_label || tag?.label))
.filter(Boolean)
}
export function buildEmployeeProfileAdviceItems(profile) {
if (!profile || typeof profile !== 'object') {
return []
}
const returnCount = profileMetric(profile, 'return_count')
const missingAttachmentCount = profileMetric(profile, 'missing_attachment_count')
const invoiceMismatchCount = profileMetric(profile, 'invoice_mismatch_count')
const missingContextCount = profileMetric(profile, 'missing_business_context_count')
const items = []
if (returnCount > 0) {
items.push(`历史退单建议:近 90 天存在 ${returnCount} 次退单或退回记录,提交前重点复核退回原因对应的票据、事由和说明,避免重复被退。`)
}
if (missingAttachmentCount > 0 || missingContextCount > 0) {
items.push(`材料完整性建议:历史材料或业务上下文缺失累计 ${missingAttachmentCount + missingContextCount} 项,本次提交前请重点核对附件、事由、地点和补充说明。`)
}
if (invoiceMismatchCount > 0) {
items.push(`票据一致性建议:历史存在 ${invoiceMismatchCount} 次票据不一致记录,本次请重点核对票据日期、城市、金额和费用明细。`)
}
return uniqueTexts([
...items,
...profileReviewSuggestionTexts(profile),
...profileRiskTagTexts(profile)
]).slice(0, 4)
}

View File

@@ -27,6 +27,7 @@ export const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
]) ])
export const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) export const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
export const OPTIONAL_ATTACHMENT_EXPENSE_TYPES = new Set(['ride_ticket', 'travel_allowance'])
export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket']) export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket']) export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket']) export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
@@ -88,6 +89,11 @@ export function isSystemGeneratedExpenseItemSource(source) {
return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType)) return Boolean(source?.isSystemGenerated || source?.is_system_generated || SYSTEM_GENERATED_EXPENSE_TYPES.has(itemType))
} }
export function isAttachmentRequiredExpenseItem(source) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type)
return !isSystemGeneratedExpenseItemSource({ ...source, itemType }) && !OPTIONAL_ATTACHMENT_EXPENSE_TYPES.has(itemType)
}
export function isLocationRequiredExpenseType(value) { export function isLocationRequiredExpenseType(value) {
return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value)) return LOCATION_REQUIRED_EXPENSE_TYPES.has(normalizeExpenseType(value))
} }
@@ -200,12 +206,11 @@ export function buildFallbackProgressSteps(requestModel = {}) {
return [ return [
{ index: 1, label: '关联单据', time: hasRelatedApplication ? '已关联' : '待核对', done: hasRelatedApplication, active: true, current: !hasRelatedApplication }, { index: 1, label: '关联单据', time: hasRelatedApplication ? '已关联' : '待核对', done: hasRelatedApplication, active: true, current: !hasRelatedApplication },
{ index: 2, label: '待提交', time: hasRelatedApplication ? '进行中' : '待处理', active: hasRelatedApplication, current: hasRelatedApplication }, { index: 2, label: '待提交', time: hasRelatedApplication ? '进行中' : '待处理', active: hasRelatedApplication, current: hasRelatedApplication },
{ index: 3, label: 'AI预审', time: '待处理' }, { index: 3, label: '直属领导审批', time: '待处理' },
{ index: 4, label: '直属领导审批', time: '待处理' }, { index: 4, label: '财务审批', time: '待处理' },
{ index: 5, label: '财务审批', time: '待处理' }, { index: 5, label: '待付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment },
{ index: 6, label: '付款', time: pendingPayment ? '进行中' : completed ? '已完成' : '待处理', done: completed, active: pendingPayment || completed, current: pendingPayment }, { index: 6, label: '付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false },
{ index: 7, label: '已付款', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }, { index: 7, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
{ index: 8, label: '已归档', time: paid || completed ? '已完成' : '待处理', done: paid || completed, active: paid || completed, current: false }
] ]
} }
@@ -476,59 +481,13 @@ export function buildExpenseDraftIssues(item) {
if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) { if (!Number.isFinite(Number(item.itemAmount)) || Number(item.itemAmount) <= 0) {
issues.push('缺少金额') issues.push('缺少金额')
} }
if (isPlaceholderValue(item.invoiceId)) { if (isAttachmentRequiredExpenseItem(item) && isPlaceholderValue(item.invoiceId)) {
issues.push('缺少票据标识') issues.push('缺少票据标识')
} }
return issues return issues
} }
export function buildOptionalTravelReceiptRiskCards(requestModel, items) {
if (isApplicationDocumentRequest(requestModel)) {
return []
}
const normalizedItems = Array.isArray(items) ? items : []
const isTravelContext =
requestModel?.detailVariant === 'travel' ||
requestModel?.typeCode === 'travel' ||
normalizedItems.some((item) => ['train_ticket', 'flight_ticket', 'hotel_ticket', 'ride_ticket', 'travel_allowance'].includes(item.itemType))
if (!isTravelContext) {
return []
}
const hasUploadedType = (itemType) =>
normalizedItems.some((item) => item.itemType === itemType && !item.isSystemGenerated && !isPlaceholderValue(item.invoiceId))
const cards = []
if (!hasUploadedType('hotel_ticket')) {
cards.push({
id: 'travel-optional-hotel-ticket',
businessStage: 'reimbursement',
tone: 'low',
label: '低风险',
title: '住宿票据提醒',
risk: '当前差旅单暂未上传住宿票据;如果本次出差发生住宿费用,请不要忘记补充酒店住宿票据。',
summary: '住宿票据缺失不阻断当前提交,但会影响住宿费用报销完整性。',
ruleBasis: ['差旅费可以包含交通、住宿和补贴等明细;住宿费用需要住宿票据支撑。'],
suggestion: '如有住宿费用,请新增住宿票明细并上传酒店发票或住宿清单;如未住宿,可忽略该提醒。'
})
}
if (!hasUploadedType('ride_ticket')) {
cards.push({
id: 'travel-optional-ride-ticket',
businessStage: 'reimbursement',
tone: 'low',
label: '低风险',
title: '乘车票据提醒',
risk: '当前差旅单暂未上传市内乘车票据;如果发生打车或市内交通费用,可以继续补充票据报销。',
summary: '市内交通票据缺失不阻断当前提交,但可能遗漏可报销费用。',
ruleBasis: ['差旅费可以补充市内交通/乘车票据;该类票据通常作为差旅费用的可选补充材料。'],
suggestion: '如有打车、网约车或市内交通费用,请新增乘车明细并上传对应票据。'
})
}
return cards
}
export function buildDraftBlockingIssues(request, expenseItems) { export function buildDraftBlockingIssues(request, expenseItems) {
const issues = [] const issues = []
const locationRequired = isLocationRequiredExpenseType(request.typeCode) const locationRequired = isLocationRequiredExpenseType(request.typeCode)

View File

@@ -700,24 +700,30 @@ export function buildClaimSummaryRiskCards(request = {}) {
})] })]
} }
export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] } = {}) { export function buildAiAdviceViewModel({
completionItems = [],
materialPrompts = [],
profileAdviceItems = [],
riskCards = []
} = {}) {
const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean) const normalizedCompletionItems = completionItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedMaterialPrompts = materialPrompts.map((item) => normalizeText(item)).filter(Boolean)
const normalizedProfileAdviceItems = profileAdviceItems.map((item) => normalizeText(item)).filter(Boolean)
const normalizedRiskCards = riskCards.filter(Boolean) const normalizedRiskCards = riskCards.filter(Boolean)
const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high') const hasHighRisk = normalizedRiskCards.some((card) => card.tone === 'high')
const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards) const sortedRiskCards = sortRiskCardsByTone(normalizedRiskCards)
if (!normalizedCompletionItems.length && !normalizedRiskCards.length) { if (
const items = [ !normalizedCompletionItems.length
'点击右下角“提交审批”进入流程。', && !normalizedMaterialPrompts.length
'提交前再核对一次合计金额与各条费用明细金额是否一致。', && !normalizedProfileAdviceItems.length
'如有特殊业务背景或例外情况,可在下方附加说明中补充。' && !normalizedRiskCards.length
] ) {
return { return {
tone: 'ready', tone: 'ready',
badge: '可直接提交', badge: '可提交',
summary: 'AI判断当前草稿已具备提交条件可以直接发起审批。', summary: '自动检测未发现票据、金额、行程或历史画像异常,可以提交审批。',
items, items: [],
riskCards: [], riskCards: [],
sections: [] sections: []
} }
@@ -731,6 +737,20 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
items: normalizedCompletionItems items: normalizedCompletionItems
}) })
} }
if (normalizedMaterialPrompts.length) {
sections.push({
kind: 'material',
title: '材料补充提示',
items: normalizedMaterialPrompts
})
}
if (normalizedProfileAdviceItems.length) {
sections.push({
kind: 'profile',
title: '历史操作建议',
items: normalizedProfileAdviceItems
})
}
if (normalizedRiskCards.length) { if (normalizedRiskCards.length) {
sections.push({ sections.push({
kind: 'risk', kind: 'risk',
@@ -742,10 +762,12 @@ export function buildAiAdviceViewModel({ completionItems = [], riskCards = [] }
return { return {
tone: hasHighRisk ? 'warning' : 'pending', tone: hasHighRisk ? 'warning' : 'pending',
badge: hasHighRisk ? '优先整改' : '待核对', badge: hasHighRisk ? '优先整改' : normalizedRiskCards.length ? '待核对' : '建议关注',
summary: normalizedRiskCards.length summary: normalizedRiskCards.length
? `AI已整理出 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。` ? `自动检测发现 ${normalizedRiskCards.length} 个风险点,已按风险等级排序全部展示。`
: '建议先补齐必填信息,完成后即可提交审批。', : normalizedMaterialPrompts.length
? `自动检测发现 ${normalizedMaterialPrompts.length} 条材料补充提示,不作为风险计数。`
: '结合历史操作记录生成提交建议,请按提示核对后提交审批。',
items: normalizedCompletionItems, items: normalizedCompletionItems,
riskCards: normalizedRiskCards, riskCards: normalizedRiskCards,
sections sections

View File

@@ -1,74 +0,0 @@
export function isAiPreReviewFlag(flag) {
if (!flag || typeof flag !== 'object') {
return false
}
const source = String(flag.source || '').trim()
const eventType = String(flag.event_type || flag.eventType || '').trim()
return source === 'ai_pre_review' || eventType === 'expense_claim_ai_pre_review'
}
export function findLatestAiPreReviewEvent(flags = []) {
return flags
.filter(isAiPreReviewFlag)
.map((flag) => ({
...flag,
eventTime: new Date(flag.created_at || flag.createdAt || 0).getTime()
}))
.sort((left, right) => (left.eventTime || 0) - (right.eventTime || 0))
.pop() || null
}
export function buildAiPreReviewSnapshot(payload, fallbackClaimId = '') {
return {
claimId: String(payload?.id || fallbackClaimId || '').trim(),
riskFlags: Array.isArray(payload?.risk_flags_json) ? payload.risk_flags_json : []
}
}
export function isAiPreReviewPassed(event, requiresAiPreReview) {
if (!requiresAiPreReview) {
return true
}
return Boolean(event?.passed) || String(event?.status || '').trim() === 'passed'
}
export function resolveSubmitActionLabel({
isApplicationDocument,
hasAiPreReviewResult,
submitBusy
}) {
if (isApplicationDocument) {
return submitBusy ? '提交中' : '提交审批'
}
if (!hasAiPreReviewResult) {
return submitBusy ? '审核中' : 'AI审核'
}
return submitBusy ? '提交中' : '下一步'
}
export function resolveSubmitActionIcon({ isApplicationDocument, hasAiPreReviewResult }) {
if (isApplicationDocument) {
return 'mdi mdi-send-circle-outline'
}
return hasAiPreReviewResult ? 'mdi mdi-arrow-right-circle-outline' : 'mdi mdi-shield-check-outline'
}
export function resolveSubmitConfirmDescription({ isApplicationDocument, aiPreReviewPassed }) {
if (isApplicationDocument) {
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
}
if (!aiPreReviewPassed) {
return 'AI预审存在重大风险请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
}
return 'AI预审已完成请确认费用明细、附件材料和风险说明均已核对无误。确认后将进入审批流程。'
}
export function resolveSubmitConfirmText(isApplicationDocument) {
return isApplicationDocument ? '确认提交' : '确认下一步'
}
export function resolveAiPreReviewToast(event) {
return event && (event.passed || event.status === 'passed')
? 'AI预审通过请点击下一步提交审批。'
: 'AI预审发现重大风险请核对 AI建议 后再点击下一步。'
}

View File

@@ -0,0 +1,30 @@
export function resolveSubmitActionLabel({
isApplicationDocument,
submitBusy
}) {
if (isApplicationDocument) {
return submitBusy ? '提交中' : '提交审批'
}
return submitBusy ? '提交中' : '提交审批'
}
export function resolveSubmitActionIcon({ isApplicationDocument }) {
if (isApplicationDocument) {
return 'mdi mdi-send-circle-outline'
}
return 'mdi mdi-send-circle-outline'
}
export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHighRiskWarnings }) {
if (isApplicationDocument) {
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
}
if (hasHighRiskWarnings) {
return '系统自动检测存在重大风险,请确认已逐条填写风险原因。确认后将带着风险说明进入审批流程。'
}
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
}
export function resolveSubmitConfirmText() {
return '确认提交'
}

View File

@@ -44,6 +44,7 @@ const FLOW_DURATION_SECOND_FIELDS = [
const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time'] const FLOW_DURATION_AUTO_FIELDS = ['duration', 'elapsed', 'latency', 'execution_time']
const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at'] const FLOW_STARTED_AT_FIELDS = ['started_at', 'start_time', 'created_at', 'queued_at']
const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at'] const FLOW_FINISHED_AT_FIELDS = ['finished_at', 'completed_at', 'ended_at', 'end_time', 'updated_at']
const FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS = 3000
function normalizeDurationValue(value, unit = 'ms') { function normalizeDurationValue(value, unit = 'ms') {
if (value === null || value === undefined || value === '') { if (value === null || value === undefined || value === '') {
@@ -598,7 +599,7 @@ export function useTravelReimbursementFlow({
} }
startFlowStep('pre-submit-review', { startFlowStep('pre-submit-review', {
title: 'AI预审与风险识别', title: '自动检测与风险识别',
tool: 'ExpenseClaimService.submit_claim', tool: 'ExpenseClaimService.submit_claim',
detail: '正在校验财务规则、风险规则和审批路径...' detail: '正在校验财务规则、风险规则和审批路径...'
}) })
@@ -665,6 +666,14 @@ export function useTravelReimbursementFlow({
tool: config.tool, tool: config.tool,
detail: config.detail detail: config.detail
}) })
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewAction)) {
startFlowStep('draft-risk-review', {
title: '草稿风险识别',
tool: 'RuleEngine',
detail: '正在校验申请单关联、票据完整性、金额口径和行程一致性...'
})
}
} }
function isApplicationSessionActive() { function isApplicationSessionActive() {
@@ -685,6 +694,15 @@ export function useTravelReimbursementFlow({
) )
} }
function isDuplicateApplicationPayload(payload) {
if (!isApplicationSessionActive()) {
return false
}
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim()
return answer.includes('已存在申请单') && answer.includes('系统没有重复创建')
}
function buildApplicationSubmitSuccessDetail(payload) { function buildApplicationSubmitSuccessDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {} const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object' const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
@@ -697,6 +715,55 @@ export function useTravelReimbursementFlow({
: `申请单提交成功,当前节点:${approvalStage}` : `申请单提交成功,当前节点:${approvalStage}`
} }
function buildApplicationDuplicateDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const answer = String(result.answer || result.message || '').trim()
const claimNo = answer.match(/AP-\d{14}-[A-HJ-NP-Z2-9]{8}/)?.[0] || ''
return claimNo
? `已拦截重复申请,已有申请单:${claimNo}`
: '已拦截重复申请,未创建新申请单'
}
function isSavedReimbursementDraftPayload(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const draftPayload = result.draft_payload && typeof result.draft_payload === 'object'
? result.draft_payload
: payload?.draft_payload && typeof payload.draft_payload === 'object'
? payload.draft_payload
: null
return Boolean(
draftPayload
&& String(draftPayload.status || '').trim() === 'draft'
&& String(draftPayload.draft_type || '').trim() !== 'expense_application'
)
}
function summarizeDraftRiskReviewDetail(payload) {
const result = payload?.result && typeof payload.result === 'object' ? payload.result : {}
const reviewPayload = result.review_payload && typeof result.review_payload === 'object'
? result.review_payload
: {}
const riskCount = Array.isArray(reviewPayload.risk_briefs)
? reviewPayload.risk_briefs.length
: Array.isArray(result.risk_flags)
? result.risk_flags.length
: 0
const missingCount = Array.isArray(reviewPayload.missing_slots)
? reviewPayload.missing_slots.length
: 0
const issueParts = []
if (riskCount) {
issueParts.push(`${riskCount} 条风险/异常提醒`)
}
if (missingCount) {
issueParts.push(`${missingCount} 项待补充信息`)
}
if (issueParts.length) {
return `已完成草稿规则校验,识别到 ${issueParts.join('、')},可进入详情核对后继续提交。`
}
return '已完成草稿规则校验,暂未发现明确风险;可继续上传票据或进入详情核对。'
}
function shouldHideToolCall(toolCall) { function shouldHideToolCall(toolCall) {
const toolType = String(toolCall?.tool_type || '').toLowerCase() const toolType = String(toolCall?.tool_type || '').toLowerCase()
const toolName = String(toolCall?.tool_name || '').toLowerCase() const toolName = String(toolCall?.tool_name || '').toLowerCase()
@@ -750,9 +817,10 @@ export function useTravelReimbursementFlow({
response.submission_blocked || response.submission_blocked ||
String(response.status || '').trim() === 'submitted' || String(response.status || '').trim() === 'submitted' ||
responseMessage.includes('AI预审') || responseMessage.includes('AI预审') ||
responseMessage.includes('自动检测') ||
responseMessage.includes('审批') responseMessage.includes('审批')
) { ) {
return { key: 'pre-submit-review', title: 'AI预审与风险识别', tool: 'ExpenseClaimService.submit_claim' } return { key: 'pre-submit-review', title: '自动检测与风险识别', tool: 'ExpenseClaimService.submit_claim' }
} }
if (responseMessage.includes('关联')) { if (responseMessage.includes('关联')) {
return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' } return { key: 'attachment-association', title: '票据关联草稿', tool: toolCall?.tool_name || 'ExpenseClaimService' }
@@ -782,7 +850,7 @@ export function useTravelReimbursementFlow({
: `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}` : `已完成财务规则、风险规则和审批路径校验,提交至${response.approval_stage || '审批环节'}`
} }
if (response.submission_blocked) { if (response.submission_blocked) {
return summarizeVisibleToolText(response.message) || 'AI预审发现待补充项,暂未提交审批' return summarizeVisibleToolText(response.message) || '自动检测发现待补充项,暂未提交审批'
} }
return ( return (
summarizeVisibleToolText(response.message || response.summary || response.result_summary) summarizeVisibleToolText(response.message || response.summary || response.result_summary)
@@ -861,6 +929,30 @@ export function useTravelReimbursementFlow({
if (!answer && !payload?.result) { if (!answer && !payload?.result) {
return return
} }
if (isSubmittedApplicationPayload(payload)) {
completePendingFlowStep(
'application-submit-success',
buildApplicationSubmitSuccessDetail(payload),
null,
{ title: '申请单提交成功', tool: 'ApplicationSubmit' }
)
}
if (isDuplicateApplicationPayload(payload)) {
completePendingFlowStep(
'application-submit-success',
buildApplicationDuplicateDetail(payload),
null,
{ title: '重复申请已拦截', tool: 'ApplicationSubmit' }
)
}
if (isSavedReimbursementDraftPayload(payload)) {
completePendingFlowStep(
'draft-risk-review',
summarizeDraftRiskReviewDetail(payload),
null,
{ title: '草稿风险识别', tool: 'RuleEngine' }
)
}
const sceneSelectionPending = isExpenseSceneSelectionResult(payload) const sceneSelectionPending = isExpenseSceneSelectionResult(payload)
flowSteps.value flowSteps.value
.filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)) .filter((step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status))
@@ -871,14 +963,6 @@ export function useTravelReimbursementFlow({
: resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED }) : resolveFlowStepDetail({ ...step, status: FLOW_STEP_STATUS_COMPLETED })
completeFlowStep(step.key, detail) completeFlowStep(step.key, detail)
}) })
if (isSubmittedApplicationPayload(payload)) {
completePendingFlowStep(
'application-submit-success',
buildApplicationSubmitSuccessDetail(payload),
null,
{ title: '申请单提交成功', tool: 'ApplicationSubmit' }
)
}
const runFinishedAt = resolveFinishedTimestamp(run) const runFinishedAt = resolveFinishedTimestamp(run)
flowFinishedAt.value = flowSteps.value.some( flowFinishedAt.value = flowSteps.value.some(
(step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status) (step) => ![FLOW_STEP_STATUS_COMPLETED, FLOW_STEP_STATUS_FAILED].includes(step.status)
@@ -893,7 +977,15 @@ export function useTravelReimbursementFlow({
} }
flowRefreshBusy.value = true flowRefreshBusy.value = true
try { try {
const run = await fetchAgentRunDetail(flowRunId.value) const run = await Promise.race([
fetchAgentRunDetail(flowRunId.value),
new Promise((resolve) => {
globalThis.setTimeout(() => resolve(null), FLOW_RUN_DETAIL_REFRESH_TIMEOUT_MS)
})
])
if (!run) {
return null
}
mergeFlowRunDetail(run) mergeFlowRunDetail(run)
return run return run
} catch (error) { } catch (error) {

View File

@@ -228,7 +228,7 @@ export function useTravelReimbursementGuidedFlow({
function pushReimbursementSummary() { function pushReimbursementSummary() {
pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), { pushAssistant(buildGuidedReimbursementSummaryText(guidedFlowState.value), {
meta: ['待生成核对信息'], meta: ['待生成报销草稿'],
suggestedActions: buildGuidedReviewConfirmationActions() suggestedActions: buildGuidedReviewConfirmationActions()
}) })
} }
@@ -286,6 +286,10 @@ export function useTravelReimbursementGuidedFlow({
const expenseTypeLabel = normalizeText(current.values.pending_scene_expense_type_label) const expenseTypeLabel = normalizeText(current.values.pending_scene_expense_type_label)
const applicationNo = normalizeText(current.values.application_claim_no) const applicationNo = normalizeText(current.values.application_claim_no)
const applicationId = normalizeText(current.values.application_claim_id) const applicationId = normalizeText(current.values.application_claim_id)
const applicationReason = normalizeText(current.values.application_reason)
const applicationLocation = normalizeText(current.values.application_location)
const applicationAmount = normalizeText(current.values.application_amount || current.values.application_amount_label)
const applicationBusinessTime = normalizeText(current.values.application_business_time)
if (!originalMessage || !expenseTypeLabel || !applicationNo) { if (!originalMessage || !expenseTypeLabel || !applicationNo) {
return null return null
} }
@@ -299,11 +303,12 @@ export function useTravelReimbursementGuidedFlow({
return { return {
rawText, rawText,
userText: `关联申请单 ${applicationNo}`, userText: `关联申请单 ${applicationNo}`,
pendingText: `已关联申请单,正在${expenseTypeLabel}识别...`, pendingText: `已关联申请单,正在生成${expenseTypeLabel}草稿...`,
systemGenerated: true, systemGenerated: true,
skipUserMessage: true, skipUserMessage: true,
extraContext: { extraContext: {
draft_claim_id: '', draft_claim_id: '',
review_action: 'save_draft',
user_input_text: originalMessage, user_input_text: originalMessage,
expense_scene_selection: { expense_scene_selection: {
expense_type: current.expenseType || 'other', expense_type: current.expenseType || 'other',
@@ -314,11 +319,21 @@ export function useTravelReimbursementGuidedFlow({
}, },
review_form_values: { review_form_values: {
expense_type: expenseTypeLabel, expense_type: expenseTypeLabel,
reimbursement_type: expenseTypeLabel,
reason: applicationReason,
reason_value: applicationReason,
location: applicationLocation,
business_location: applicationLocation,
time_range: applicationBusinessTime,
business_time: applicationBusinessTime,
amount: applicationAmount,
application_claim_id: applicationId, application_claim_id: applicationId,
application_claim_no: applicationNo, application_claim_no: applicationNo,
application_reason: current.values.application_reason || '', application_reason: applicationReason,
application_location: current.values.application_location || '', application_location: applicationLocation,
application_amount: current.values.application_amount || '' application_amount: current.values.application_amount || '',
application_amount_label: current.values.application_amount_label || '',
application_business_time: applicationBusinessTime
} }
} }
} }
@@ -329,6 +344,21 @@ export function useTravelReimbursementGuidedFlow({
const currentStep = getCurrentGuidedStep(currentState) const currentStep = getCurrentGuidedStep(currentState)
const fileNames = buildFileNames(files) const fileNames = buildFileNames(files)
if (isGuidedReimbursementReadyForReview(currentState) && fileNames.length) {
const mergedFiles = mergePendingFiles(guidedPendingFiles.value, files)
guidedPendingFiles.value = mergedFiles
const submitOptions = {
...buildGuidedReviewSubmitOptions(currentState, mergedFiles),
skipDraftAssociationPrompt: true,
skipUserMessage: true,
pendingText: '已关联申请单,正在识别票据并生成报销草稿...'
}
resetGuidedFlowState()
persistAndScroll()
await submitExistingComposer(submitOptions)
return
}
if (currentState.stepKey === 'expense_type') { if (currentState.stepKey === 'expense_type') {
const expenseType = resolveGuidedExpenseTypeFromText(answerText) const expenseType = resolveGuidedExpenseTypeFromText(answerText)
if (!expenseType) { if (!expenseType) {
@@ -343,7 +373,7 @@ export function useTravelReimbursementGuidedFlow({
} }
if (currentState.stepKey === 'application_selection') { if (currentState.stepKey === 'application_selection') {
pushAssistant('请先点击上方列出的申请单完成关联。关联后,我再继续询问报销依据。', { pushAssistant('请先点击上方列出的申请单完成关联。关联后,我会直接进入生成报销草稿。', {
meta: ['等待关联申请单'], meta: ['等待关联申请单'],
suggestedActions: buildRequiredApplicationActions( suggestedActions: buildRequiredApplicationActions(
currentState.applicationCandidates, currentState.applicationCandidates,
@@ -521,6 +551,11 @@ export function useTravelReimbursementGuidedFlow({
await submitExistingComposer(pendingSceneSubmitOptions) await submitExistingComposer(pendingSceneSubmitOptions)
return true return true
} }
if (isGuidedReimbursementReadyForReview(guidedFlowState.value)) {
pushReimbursementSummary()
persistAndScroll()
return true
}
pushNextReimbursementPrompt() pushNextReimbursementPrompt()
persistAndScroll() persistAndScroll()
return true return true

View File

@@ -5,6 +5,7 @@ import {
} from './travelReimbursementAttachmentModel.js' } from './travelReimbursementAttachmentModel.js'
import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js' import { resolveAssistantScopeGuard } from '../../utils/assistantSessionScope.js'
import { import {
applyApplicationBusinessTimeContext,
applyApplicationPolicyEstimateError, applyApplicationPolicyEstimateError,
applyApplicationPolicyEstimateResult, applyApplicationPolicyEstimateResult,
buildApplicationPolicyEstimateRequest, buildApplicationPolicyEstimateRequest,
@@ -58,6 +59,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
currentInsight, currentInsight,
currentUser, currentUser,
draftClaimId, draftClaimId,
emitDraftSaved,
emitOperationCompleted, emitOperationCompleted,
emitRequestUpdated, emitRequestUpdated,
extractReviewAttachmentNames, extractReviewAttachmentNames,
@@ -139,6 +141,20 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}` return `attachment-association-${Date.now()}-${Math.random().toString(16).slice(2)}`
} }
function emitSavedDraftRefresh(draftPayload) {
if (!emitDraftSaved || isKnowledgeSession.value || !draftPayload?.claim_no) {
return
}
const draftType = String(draftPayload.draft_type || '').trim()
emitDraftSaved({
claimId: String(draftPayload.claim_id || draftPayload.claimId || '').trim(),
claimNo: String(draftPayload.claim_no || draftPayload.claimNo || '').trim(),
status: String(draftPayload.status || '').trim(),
approvalStage: String(draftPayload.approval_stage || draftPayload.approvalStage || '').trim(),
documentType: draftType === 'expense_application' ? 'application' : 'reimbursement'
})
}
function normalizeRecognizedAttachmentData(data) { function normalizeRecognizedAttachmentData(data) {
if (!data || typeof data !== 'object') { if (!data || typeof data !== 'object') {
return null return null
@@ -351,9 +367,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
return currentUser.value || user return currentUser.value || user
} }
async function buildApplicationPreviewWithModelReview(rawText) { async function buildApplicationPreviewWithModelReview(rawText, businessTimeContext = null) {
const user = await resolveApplicationPreviewUser() const user = await resolveApplicationPreviewUser()
const localPreview = buildLocalApplicationPreview(rawText, user) const localPreview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(rawText, user),
businessTimeContext
)
const enrichWithPolicyEstimate = async (preview) => { const enrichWithPolicyEstimate = async (preview) => {
const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user) const estimateRequest = buildApplicationPolicyEstimateRequest(preview, user)
@@ -393,11 +412,14 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
) )
const refinedPreview = buildModelRefinedApplicationPreview( const refinedPreview = applyApplicationBusinessTimeContext(
localPreview, buildModelRefinedApplicationPreview(
ontology, localPreview,
rawText, ontology,
user rawText,
user
),
businessTimeContext
) )
return { return {
applicationPreview: await enrichWithPolicyEstimate(refinedPreview), applicationPreview: await enrichWithPolicyEstimate(refinedPreview),
@@ -462,6 +484,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
: mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext) : mergeBusinessTimeIntoExtraContext(initialExtraContext, selectedBusinessTimeContext)
const reviewAction = String(extraContext.review_action || '').trim() const reviewAction = String(extraContext.review_action || '').trim()
const feedbackOperationType = String(options.feedbackOperationType || '').trim() const feedbackOperationType = String(options.feedbackOperationType || '').trim()
const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'
const attachmentAssociationConfirmed = Boolean( const attachmentAssociationConfirmed = Boolean(
options.associationConfirmed || options.associationConfirmed ||
extraContext.attachment_association_confirmed || extraContext.attachment_association_confirmed ||
@@ -499,7 +522,11 @@ export function useTravelReimbursementSubmitComposer(ctx) {
? `新上传 ${fileNames.length} 份票据,请单独建立报销单。` ? `新上传 ${fileNames.length} 份票据,请单独建立报销单。`
: `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`) : `我上传了 ${fileNames.length} 份票据,请帮我识别并整理报销建议。`)
if (shouldUseBudgetCompileReport(rawText, { sessionType: activeSessionType.value }) && !reviewAction) { if (shouldUseBudgetCompileReport(rawText, {
sessionType: activeSessionType.value,
entrySource: props.entrySource,
budgetContext: props.initialBudgetContext
}) && !reviewAction) {
return handleBudgetCompileReportSubmit({ return handleBudgetCompileReportSubmit({
adjustComposerTextareaHeight, adjustComposerTextareaHeight,
clearAttachedFiles, clearAttachedFiles,
@@ -518,6 +545,8 @@ export function useTravelReimbursementSubmitComposer(ctx) {
rawText, rawText,
replaceMessage, replaceMessage,
resetFlowRun, resetFlowRun,
refreshCurrentUserFromBackend,
budgetContext: props.initialBudgetContext,
scrollToBottom, scrollToBottom,
startFlowStep, startFlowStep,
submitting, submitting,
@@ -595,7 +624,7 @@ export function useTravelReimbursementSubmitComposer(ctx) {
submitting.value = true submitting.value = true
try { try {
const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText) const { applicationPreview, meta } = await buildApplicationPreviewWithModelReview(rawText, selectedBusinessTimeContext)
const reviewStatus = String(meta?.[1] || '').trim() const reviewStatus = String(meta?.[1] || '').trim()
completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt) completeFlowStep('intent', '已识别为费用申请事项', Date.now() - intentStartedAt)
completeFlowStep( completeFlowStep(
@@ -725,7 +754,13 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} else { } else {
clearFlowSimulationTimers() clearFlowSimulationTimers()
} }
if (rawText && !reviewAction) { if (isApplicationSubmitOperation) {
startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
} else if (rawText && !reviewAction) {
startFlowStep('intent', '正在识别业务意图...') startFlowStep('intent', '正在识别业务意图...')
if (waitForExpenseIntentConfirmation) { if (waitForExpenseIntentConfirmation) {
startExpenseIntentConfirmationFlowPreview(rawText) startExpenseIntentConfirmationFlowPreview(rawText)
@@ -947,10 +982,12 @@ export function useTravelReimbursementSubmitComposer(ctx) {
extraContext.review_action = 'create_new_claim_from_documents' extraContext.review_action = 'create_new_claim_from_documents'
} }
startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), { if (!isApplicationSubmitOperation) {
attachmentCount: effectiveFileNames.length, startExpenseClaimDraftFlowStep(String(extraContext.review_action || '').trim(), {
waitForSceneSelection: waitForExpenseSceneSelection attachmentCount: effectiveFileNames.length,
}) waitForSceneSelection: waitForExpenseSceneSelection
})
}
const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary) const backendMessage = buildBackendMessage(rawText, effectiveFileNames, effectiveOcrSummary)
const orchestratorOptions = isKnowledgeSession.value const orchestratorOptions = isKnowledgeSession.value
@@ -977,9 +1014,17 @@ export function useTravelReimbursementSubmitComposer(ctx) {
department: user.department || user.departmentName || '', department: user.department || user.departmentName || '',
department_name: user.department || user.departmentName || '', department_name: user.department || user.departmentName || '',
position: user.position || '', position: user.position || '',
grade: user.grade || '', employee_position: user.position || user.employeePosition || user.employee_position || '',
employeePosition: user.position || user.employeePosition || user.employee_position || '',
grade: user.grade || user.employeeGrade || user.employee_grade || '',
employee_grade: user.grade || user.employeeGrade || user.employee_grade || '',
employeeGrade: user.grade || user.employeeGrade || user.employee_grade || '',
employee_no: user.employeeNo || user.employee_no || '', employee_no: user.employeeNo || user.employee_no || '',
employeeNo: user.employeeNo || user.employee_no || '',
manager_name: user.managerName || user.manager_name || '', manager_name: user.managerName || user.manager_name || '',
managerName: user.managerName || user.manager_name || '',
direct_manager_name: user.managerName || user.manager_name || user.directManagerName || user.direct_manager_name || '',
directManagerName: user.managerName || user.manager_name || user.directManagerName || user.direct_manager_name || '',
employee_location: user.location || '', employee_location: user.location || '',
cost_center: user.costCenter || user.cost_center || '', cost_center: user.costCenter || user.cost_center || '',
finance_owner_name: user.financeOwnerName || user.finance_owner_name || '', finance_owner_name: user.financeOwnerName || user.finance_owner_name || '',
@@ -1051,6 +1096,9 @@ export function useTravelReimbursementSubmitComposer(ctx) {
} }
currentInsight.value = nextInsight currentInsight.value = nextInsight
completeFlowResult(payload, flowRunDetail) completeFlowResult(payload, flowRunDetail)
if (['save_draft', 'link_to_existing_draft', 'create_new_claim_from_documents'].includes(reviewActionResult)) {
emitSavedDraftRefresh(payload?.result?.draft_payload || null)
}
persistSessionState() persistSessionState()
nextTick(scrollToBottom) nextTick(scrollToBottom)
const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim() const resolvedDraftClaimId = String(payload?.result?.draft_payload?.claim_id || draftClaimId.value || '').trim()

View File

@@ -127,7 +127,7 @@ test('application detail topbar does not ask for receipt attachments', () => {
test('detail topbar surfaces stored medium and high risk flags first', () => { test('detail topbar surfaces stored medium and high risk flags first', () => {
const highAlerts = buildDetailAlerts({ const highAlerts = buildDetailAlerts({
node: 'AI预审', node: '待提交',
approvalKey: 'draft', approvalKey: 'draft',
riskFlags: [ riskFlags: [
{ {
@@ -146,7 +146,7 @@ test('detail topbar surfaces stored medium and high risk flags first', () => {
expenseItems: [] expenseItems: []
}) })
const mediumAlerts = buildDetailAlerts({ const mediumAlerts = buildDetailAlerts({
node: 'AI预审', node: '待提交',
approvalKey: 'draft', approvalKey: 'draft',
riskFlags: [ riskFlags: [
{ {

View File

@@ -0,0 +1,42 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const appShellView = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const appCss = readFileSync(
fileURLToPath(new URL('../src/assets/styles/app.css', import.meta.url)),
'utf8'
)
const assistantResponsiveCss = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/travel-reimbursement-create-view-part4.css', import.meta.url)),
'utf8'
)
test('手机浏览器存在应用导航入口', () => {
assert.match(appShellView, /class="mobile-hamburger-btn"/)
assert.match(appShellView, /aria-label="打开移动端导航"/)
assert.match(appShellView, /:aria-expanded="mobileSidebarOpen \? 'true' : 'false'"/)
assert.match(appShellView, /@click="mobileSidebarOpen = true"/)
assert.match(appCss, /\.mobile-hamburger-btn\s*{\s*display:\s*none;/s)
assert.match(appCss, /@media \(max-width:\s*760px\)[\s\S]*\.mobile-hamburger-btn\s*{[\s\S]*display:\s*flex;/)
})
test('报销智能体在手机浏览器下使用全屏工作台和稳定输入区', () => {
const mobileBlockStart = assistantResponsiveCss.indexOf('@media (max-width: 760px)')
assert.notEqual(mobileBlockStart, -1)
const mobileBlock = assistantResponsiveCss.slice(mobileBlockStart)
assert.match(mobileBlock, /:global\(\.assistant-el-overlay \.el-overlay-dialog\)[\s\S]*padding:\s*0;/)
assert.match(mobileBlock, /\.assistant-modal-stage\s*{[\s\S]*height:\s*100dvh;[\s\S]*border:\s*0;/)
assert.match(mobileBlock, /\.assistant-layout\s*{[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\);/)
assert.match(mobileBlock, /\.dialog-panel\s*{[\s\S]*border:\s*0;[\s\S]*border-radius:\s*0;/)
assert.match(mobileBlock, /\.insight-panel-shell\s*{[\s\S]*position:\s*absolute;[\s\S]*transform:\s*translateX\(100%\);/)
assert.match(mobileBlock, /\.assistant-layout\.has-insight \.insight-panel-shell\s*{[\s\S]*transform:\s*translateX\(0\);/)
assert.match(mobileBlock, /\.composer-row\s*{[\s\S]*display:\s*grid;[\s\S]*grid-template-columns:\s*minmax\(0,\s*1fr\) var\(--composer-control-size,\s*40px\);/)
assert.match(mobileBlock, /\.composer-leading-actions\s*{[\s\S]*grid-column:\s*1 \/ -1;[\s\S]*grid-template-columns:\s*repeat\(3,\s*minmax\(0,\s*1fr\)\);/)
})

View File

@@ -130,7 +130,10 @@ test('attachment upload association uses conversation selection instead of legac
assert.match(submitComposerSource, /if \(!appendToCurrentFlow\) \{\s*resetFlowRun\(\)\s*\} else \{\s*clearFlowSimulationTimers\(\)/) 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, /link_to_existing_draft:\s*\{[\s\S]*key:\s*'attachment-association'/)
assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/) assert.match(flowSource, /responseMessage\.includes\('关联'\)[\s\S]*key:\s*'attachment-association'/)
assert.match(flowSource, /'draft-risk-review'/)
assert.match(flowSource, /草稿风险识别/)
assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/) assert.match(conversationSource, /'attachment-association':\s*\{[\s\S]*title:\s*'票据关联草稿'/)
assert.match(conversationSource, /'draft-risk-review':\s*\{[\s\S]*title:\s*'草稿风险识别'/)
}) })
test('unsaved review attachment prompt asks for explicit rich-text confirmation', () => { test('unsaved review attachment prompt asks for explicit rich-text confirmation', () => {

View File

@@ -32,8 +32,8 @@ test('document center new state resolves source scoped document keys', () => {
test('document center new state counts unseen documents and persists viewed rows', () => { test('document center new state counts unseen documents and persists viewed rows', () => {
const storage = createMemoryStorage() const storage = createMemoryStorage()
const rows = [ const rows = [
{ source: 'archive', claimId: 'claim-1' }, { source: 'owned', claimId: 'claim-1' },
{ source: 'archive', claimId: 'claim-2' } { source: 'approval', claimId: 'claim-2' }
] ]
let viewedKeys = readViewedDocumentKeys(storage) let viewedKeys = readViewedDocumentKeys(storage)
@@ -44,7 +44,21 @@ test('document center new state counts unseen documents and persists viewed rows
assert.equal(countNewDocuments(rows, viewedKeys), 1) assert.equal(countNewDocuments(rows, viewedKeys), 1)
assert.equal(isNewDocument(rows[0], viewedKeys), false) assert.equal(isNewDocument(rows[0], viewedKeys), false)
assert.deepEqual([...readViewedDocumentKeys(storage)], ['archive:claim-1']) assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1'])
})
test('document center archive rows are never marked as new', () => {
const viewedKeys = readViewedDocumentKeys(createMemoryStorage())
const rows = [
{ source: 'archive', claimId: 'archived-1' },
{ archived: true, source: 'owned', claimId: 'archived-2' },
{ isNewDocument: false, source: 'owned', claimId: 'archived-3' }
]
assert.equal(countNewDocuments(rows, viewedKeys), 0)
assert.equal(isNewDocument(rows[0], viewedKeys), false)
assert.equal(isNewDocument(rows[1], viewedKeys), false)
assert.equal(isNewDocument(rows[2], viewedKeys), false)
}) })
test('document center sidebar inbox shares source scoped document keys', () => { test('document center sidebar inbox shares source scoped document keys', () => {

View File

@@ -12,6 +12,10 @@ const documentsCenterStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)), fileURLToPath(new URL('../src/assets/styles/views/documents-center-view.css', import.meta.url)),
'utf8' 'utf8'
) )
const documentListSharedStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/document-list-shared.css', import.meta.url)),
'utf8'
)
test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => { test('documents center keeps only the top scope tabs and renders status as a dropdown filter', () => {
assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/) assert.match(documentsCenterView, /<nav class="status-tabs document-scope-tabs"/)
@@ -88,9 +92,9 @@ test('documents center list shows created time and conditional stay time columns
assert.match(documentsCenterView, /<col class="col-initiator">/) assert.match(documentsCenterView, /<col class="col-initiator">/)
assert.match(documentsCenterView, /<th>单号<\/th>[\s\S]*<th>创建时间<\/th>[\s\S]*<th v-if="showStayTimeColumn">停留时间<\/th>/) assert.match(documentsCenterView, /<th>单号<\/th>[\s\S]*<th>创建时间<\/th>[\s\S]*<th v-if="showStayTimeColumn">停留时间<\/th>/)
assert.match(documentsCenterView, /<th>费用场景<\/th>[\s\S]*<th>发起人<\/th>[\s\S]*<th>事项<\/th>/) assert.match(documentsCenterView, /<th>费用场景<\/th>[\s\S]*<th>发起人<\/th>[\s\S]*<th>事项<\/th>/)
assert.match(documentsCenterView, /<td>\{\{ row\.createdAtDisplay \}\}<\/td>/) assert.match(documentsCenterView, /<td data-label="创建时间">\{\{ row\.createdAtDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td v-if="showStayTimeColumn">\{\{ row\.stayTimeDisplay \}\}<\/td>/) assert.match(documentsCenterView, /<td v-if="showStayTimeColumn" data-label="停留时间">\{\{ row\.stayTimeDisplay \}\}<\/td>/)
assert.match(documentsCenterView, /<td>\{\{ row\.initiatorName \}\}<\/td>/) assert.match(documentsCenterView, /<td data-label="发起人">\{\{ row\.initiatorName \}\}<\/td>/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/ /const showStayTimeColumn = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REVIEW/
@@ -147,7 +151,7 @@ test('documents center category tabs render bubble counts for new documents', ()
test('documents center rows show NEW marker until the row is opened', () => { test('documents center rows show NEW marker until the row is opened', () => {
assert.match(documentsCenterView, /<span v-if="row\.isNewDocument" class="new-document-badge">NEW<\/span>/) assert.match(documentsCenterView, /<span v-if="row\.isNewDocument" class="new-document-badge">NEW<\/span>/)
assert.match(documentsCenterView, /isNewDocument: isNewDocument\(/) assert.match(documentsCenterView, /isNewDocument: archived\s*\?\s*false\s*:\s*isNewDocument\(/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/ /function openDocument\(row\) \{[\s\S]*writeDocumentScope\(activeScopeTab\.value, scopeTabs\)[\s\S]*viewedDocumentKeys\.value = markDocumentViewed\(row, viewedDocumentKeys\.value\)[\s\S]*emit\('open-document', row\.rawRequest \|\| row\)/
@@ -228,9 +232,9 @@ test('documents center status dropdown derives labels and closes after selection
test('documents center status dropdown uses compact filter styling', () => { test('documents center status dropdown uses compact filter styling', () => {
assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/) assert.match(documentsCenterStyles, /\.documents-list\s*\{[\s\S]*grid-template-rows:\s*auto auto minmax\(0,\s*1fr\) auto;/)
assert.match(documentsCenterStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/) assert.match(documentListSharedStyles, /\.status-tabs button\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(documentsCenterStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/) assert.match(documentListSharedStyles, /\.scope-tab-badge\s*\{[\s\S]*border-radius:\s*999px;/)
assert.match(documentsCenterStyles, /min-width:\s*1420px;/) assert.match(documentListSharedStyles, /min-width:\s*1420px;/)
assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/) assert.match(documentsCenterStyles, /\.col-created\s*\{\s*width:\s*10%;\s*\}/)
assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/) assert.match(documentsCenterStyles, /\.col-stay\s*\{\s*width:\s*9%;\s*\}/)
assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/) assert.match(documentsCenterStyles, /\.col-initiator\s*\{\s*width:\s*8%;\s*\}/)

View File

@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url'
import { ref } from 'vue' import { ref } from 'vue'
import { import {
applyApplicationBusinessTimeContext,
buildApplicationPreviewFooterMessage, buildApplicationPreviewFooterMessage,
buildApplicationPreviewRows, buildApplicationPreviewRows,
buildApplicationPreviewSubmitText, buildApplicationPreviewSubmitText,
@@ -15,6 +16,7 @@ import {
buildLocalApplicationPreviewMessage, buildLocalApplicationPreviewMessage,
buildModelRefinedApplicationPreview, buildModelRefinedApplicationPreview,
normalizeApplicationPreview, normalizeApplicationPreview,
resolveApplicationTimeLabel,
shouldUseLocalApplicationPreview shouldUseLocalApplicationPreview
} from '../src/utils/expenseApplicationPreview.js' } from '../src/utils/expenseApplicationPreview.js'
import { import {
@@ -162,8 +164,10 @@ test('application preview renders ordered editable rows and submit text uses edi
const rows = buildApplicationPreviewRows(editedPreview) const rows = buildApplicationPreviewRows(editedPreview)
assert.deepEqual( assert.deepEqual(
rows.map((row) => row.label), rows.map((row) => row.label),
['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '发生时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用'] ['申请类型', '姓名', '职级', '部门', '岗位', '直属领导', '行程时间', '地点', '事由', '天数', '出行方式', '住宿上限/天', '补贴标准/天', '交通费用口径', '规则测算参考', '系统预估费用']
) )
assert.match(buildApplicationPreviewSubmitText(editedPreview), /行程时间2026-05-25 至 2026-05-28/)
assert.doesNotMatch(buildApplicationPreviewSubmitText(editedPreview), /发生时间:/)
assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元') assert.equal(rows.find((row) => row.key === 'amount')?.value, '1900元')
assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true) assert.equal(rows.find((row) => row.key === 'amount')?.highlight, true)
assert.equal(rows.find((row) => row.key === 'amount')?.editable, false) assert.equal(rows.find((row) => row.key === 'amount')?.editable, false)
@@ -220,6 +224,39 @@ test('application estimate builds deterministic mock transport amount and total'
assert.equal(datedTotalEstimate.totalAmountDisplay, '3,260') assert.equal(datedTotalEstimate.totalAmountDisplay, '3,260')
}) })
test('application preview uses selected date range and business-specific time label', () => {
const preview = applyApplicationBusinessTimeContext(
buildLocalApplicationPreview(
'去上海出差4天支撑国网仿生产环境部署飞机',
{
name: '曹笑竹',
departmentName: '技术部',
position: '财务智能化产品经理',
managerName: '向万红',
grade: 'P5'
},
{ today: '2026-06-02' }
),
{
mode: 'range',
start_date: '2026-02-20',
end_date: '2026-02-23',
business_time: '2026-02-20 至 2026-02-23'
}
)
const rows = buildApplicationPreviewRows(preview)
const submitText = buildApplicationPreviewSubmitText(preview)
assert.equal(resolveApplicationTimeLabel(preview.fields.applicationType), '行程时间')
assert.equal(preview.fields.time, '2026-02-20 至 2026-02-23')
assert.equal(preview.fields.days, '4天')
assert.equal(preview.fields.reason, '支撑国网仿生产环境部署')
assert.equal(rows.find((row) => row.key === 'time')?.label, '行程时间')
assert.match(submitText, /行程时间2026-02-20 至 2026-02-23/)
assert.match(submitText, /事由:支撑国网仿生产环境部署/)
assert.doesNotMatch(submitText, /发生时间:/)
})
test('application preview cleans empty time labels and keeps only business reason', () => { test('application preview cleans empty time labels and keeps only business reason', () => {
const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', { const preview = buildLocalApplicationPreview('发生时间去九江出差3天服务美团业务部署预计费用1800元火车', {
name: '李文静', name: '李文静',
@@ -407,8 +444,20 @@ test('application session shows intent flow, persists preview, and supports inli
assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/) assert.doesNotMatch(messageItemTemplate, /ui\.commitApplicationPreviewDateEditor\(message\)/)
assert.match(messageItemTemplate, /application-preview-date-chip/) assert.match(messageItemTemplate, /application-preview-date-chip/)
assert.match(messageItemTemplate, /申请单据已生成/) assert.match(messageItemTemplate, /申请单据已生成/)
assert.match(messageItemTemplate, /ui\.shouldShowDraftSavedCard\(message\)/)
assert.match(messageItemTemplate, /报销草稿已生成/)
assert.match(messageItemTemplate, /ui\.resolveReimbursementDraftClaimNo\(message\.draftPayload\)/)
assert.match(messageItemTemplate, /class="reimbursement-draft-link"/)
assert.match(messageItemTemplate, /查看详情/)
assert.doesNotMatch(messageItemTemplate, /ui\.buildReimbursementDraftSummaryItems\(message\.draftPayload\)/)
assert.doesNotMatch(messageItemTemplate, /可以继续上传票据,我会归集到这张草稿。/)
assert.ok(
messageItemTemplate.indexOf('class="draft-preview application-draft-preview"')
< messageItemTemplate.indexOf('class="message-detail-block review-message-block"')
)
assert.match(messageItemTemplate, /application-draft-head/) assert.match(messageItemTemplate, /application-draft-head/)
assert.match(messageItemTemplate, /mdi mdi-file-document-check-outline/) assert.match(messageItemTemplate, /mdi mdi-file-document-check-outline/)
assert.match(messageItemTemplate, /mdi mdi-file-document-edit-outline/)
assert.match(messageItemTemplate, /'is-primary': item\.label === '单号'/) assert.match(messageItemTemplate, /'is-primary': item\.label === '单号'/)
assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/) assert.match(messageItemTemplate, /完整审批链、附件和明细可在单据详情中[\s\S]*application-draft-detail-link[\s\S]*>查看<\/button>/)
assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/) assert.doesNotMatch(messageItemTemplate, /application-draft-detail-btn/)
@@ -416,6 +465,8 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/) assert.match(messageItemTemplate, /<OperationFeedbackInlineCard/)
assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/) assert.match(messageItemTemplate, /ui\.isOperationFeedbackVisible\(message\)/)
assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/) assert.match(messageItemTemplate, /ui\.submitOperationFeedbackForMessage\(message, \$event\)/)
assert.match(submitComposerScript, /employee_grade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
assert.match(submitComposerScript, /employeeGrade:\s*user\.grade \|\| user\.employeeGrade \|\| user\.employee_grade/)
assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/) assert.match(createViewTemplate, /'has-insight': hasInsightPanelContent && showInsightPanel/)
assert.match(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.draftValue"/) assert.match(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.draftValue"/)
assert.doesNotMatch(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.singleDate"/) assert.doesNotMatch(messageItemTemplate, /v-model="ui\.applicationPreviewEditor\.singleDate"/)
@@ -464,12 +515,19 @@ test('application session shows intent flow, persists preview, and supports inli
assert.match(applicationMessageStyles, /\.application-draft-brief-item \{[\s\S]*border: 0;[\s\S]*background: #ffffff;/) assert.match(applicationMessageStyles, /\.application-draft-brief-item \{[\s\S]*border: 0;[\s\S]*background: #ffffff;/)
assert.doesNotMatch(applicationMessageStyles, /\.application-draft-brief-item:nth-child\(even\)/) assert.doesNotMatch(applicationMessageStyles, /\.application-draft-brief-item:nth-child\(even\)/)
assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/) assert.match(applicationMessageStyles, /\.application-draft-brief-item\.is-primary \{[\s\S]*grid-column: 1 \/ -1;/)
assert.match(applicationMessageStyles, /\.application-draft-preview\.reimbursement-draft-preview \{[\s\S]*max-width: 520px;/)
assert.match(applicationMessageStyles, /\.reimbursement-draft-card \{[\s\S]*grid-template-columns: 30px minmax\(0, 1fr\);/)
assert.match(applicationMessageStyles, /\.reimbursement-draft-link \{[\s\S]*text-decoration: underline;/)
assert.match(flowScript, /application-submit-success/) assert.match(flowScript, /application-submit-success/)
assert.match(flowScript, /function shouldHideToolCall/) assert.match(flowScript, /function shouldHideToolCall/)
assert.match(flowScript, /semantic_ontology/) assert.match(flowScript, /semantic_ontology/)
assert.match(flowScript, /return null/) assert.match(flowScript, /return null/)
assert.match(flowScript, /申请单提交成功/) assert.match(flowScript, /申请单提交成功/)
assert.match(submitComposerScript, /const isApplicationSubmitOperation = feedbackOperationType === 'submit_application'/)
assert.match(submitComposerScript, /if \(isApplicationSubmitOperation\) \{[\s\S]*startFlowStep\('application-submit-success'/)
assert.match(submitComposerScript, /else if \(rawText && !reviewAction\) \{[\s\S]*startFlowStep\('intent'/)
assert.match(submitComposerScript, /if \(!isApplicationSubmitOperation\) \{[\s\S]*startExpenseClaimDraftFlowStep/)
assert.match(flowScript, /function resolveDurationFromFields/) assert.match(flowScript, /function resolveDurationFromFields/)
assert.match(flowScript, /function resolveStartedTimestamp/) assert.match(flowScript, /function resolveStartedTimestamp/)
assert.match(flowScript, /function resolveFinishedTimestamp/) assert.match(flowScript, /function resolveFinishedTimestamp/)
@@ -521,6 +579,64 @@ test('flow panel durations use backend timing instead of local preview delay', (
assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: null }), '--') assert.equal(flow.formatFlowStepDuration({ status: 'completed', durationMs: null }), '--')
}) })
test('application submit confirmation flow only shows submit success step', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
flow.startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
flow.completeFlowResult({
status: 'succeeded',
result: {
answer: '申请单据已生成,并已进入审批流程。',
draft_payload: {
draft_type: 'expense_application',
status: 'submitted',
claim_no: 'AP-20260602010101-ABCDEFGH',
approval_stage: '直属领导审批'
}
}
})
assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success'])
assert.deepEqual(flow.visibleFlowSteps.value.map((step) => step.key), ['application-submit-success'])
const submitStep = flow.flowSteps.value[0]
assert.equal(submitStep.status, 'completed')
assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/)
assert.doesNotMatch(flow.flowSteps.value.map((step) => step.key).join(','), /intent|extraction/)
})
test('application duplicate confirmation flow marks submit step as blocked duplicate', () => {
const flow = createFlowHarness()
flow.resetFlowRun({ startedAt: Date.parse('2026-05-29T00:00:00.000Z') })
flow.startFlowStep('application-submit-success', {
title: '申请单提交成功',
tool: 'ApplicationSubmit',
detail: '正在提交费用申请...'
})
flow.completeFlowResult({
status: 'succeeded',
result: {
answer: [
'检测到同一申请人、同一申请类型、同一行程时间已存在申请单,系统没有重复创建。',
'已有申请单号AP-20260602010101-ABCDEFGH',
'当前节点:直属领导审批'
].join('\n')
}
})
assert.deepEqual(flow.flowSteps.value.map((step) => step.key), ['application-submit-success'])
const submitStep = flow.flowSteps.value[0]
assert.equal(submitStep.status, 'completed')
assert.equal(submitStep.title, '重复申请已拦截')
assert.match(submitStep.detail, /AP-20260602010101-ABCDEFGH/)
assert.doesNotMatch(submitStep.detail, /提交成功/)
})
test('assistant markdown tables render with component-scoped table styling', () => { test('assistant markdown tables render with component-scoped table styling', () => {
const rendered = renderMarkdown([ const rendered = renderMarkdown([
'| 项目 | 标准口径 | 天数 | 小计 |', '| 项目 | 标准口径 | 天数 | 小计 |',

Some files were not shown because too many files have changed in this diff Show More