feat: 优化差旅报销预审流程与个人工作台 UI 体系
- 完善 user_agent_application 申请差旅报销预审槽位与消息组装 - 增强预算助理报告与风险建议卡片交互 - 重构登录页视觉样式与移动端响应式适配 - 优化个人工作台、文档中心、政策中心、员工管理等页面布局 - 拆分 travelRequestDetailPreReviewModel 为 advice/submit 模型 - 补充报销草稿、风险复核、Item Sync 与模板执行器测试覆盖
137
document/development/移动端适配/CONCEPT.md
Normal 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 构建通过。
|
||||||
|
|
||||||
|
## 风险与开放问题
|
||||||
|
|
||||||
|
- 其他业务页面仍可能存在表格密度过高的问题,需要按页面继续做字段折叠或卡片化。
|
||||||
|
- 一些二级确认弹窗、票据预览和日期控件需要后续逐页检查。
|
||||||
|
- 手机浏览器地址栏收起/展开会改变视口高度,后续应继续用真实设备补充验证。
|
||||||
27
document/development/移动端适配/TODO.md
Normal 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: 风险与开放问题]
|
||||||
@@ -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",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
"处理建议:当前信息已较完整,确认无误后可以继续下一步;"
|
"处理建议:当前信息已较完整,确认无误后可以继续下一步;"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 = (
|
||||||
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
|
"差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
web/src/assets/login-left-enterprise-visual-v1.webp
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
web/src/assets/login-operations-illustration.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
BIN
web/src/assets/login-reference-chart-panels.png
Normal file
|
After Width: | Height: | Size: 110 KiB |
BIN
web/src/assets/login-reference-left-panel.webp
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
web/src/assets/login-reference-main-visual-preview.png
Normal file
|
After Width: | Height: | Size: 315 KiB |
BIN
web/src/assets/login-reference-main-visual.png
Normal file
|
After Width: | Height: | Size: 348 KiB |
BIN
web/src/assets/personal-workbench-card-glass-capability.webp
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
web/src/assets/personal-workbench-card-glass-panel.webp
Normal file
|
After Width: | Height: | Size: 8.0 KiB |
BIN
web/src/assets/personal-workbench-hero-bg-concept.png
Normal file
|
After Width: | Height: | Size: 490 KiB |
BIN
web/src/assets/personal-workbench-hero-bg-theme-base.png
Normal file
|
After Width: | Height: | Size: 625 KiB |
BIN
web/src/assets/personal-workbench-hero-bg-theme-base.webp
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
web/src/assets/personal-workbench-hero-bg-theme-preview.png
Normal file
|
After Width: | Height: | Size: 384 KiB |
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
162
web/src/assets/styles/components/personal-workbench-glass.css
Normal 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);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: '待补材料',
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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="保存中..."
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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: ['预算分析报告', '模拟数据'],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
119
web/src/views/scripts/travelRequestDetailAdviceModel.js
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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建议 后再点击下一步。'
|
|
||||||
}
|
|
||||||
30
web/src/views/scripts/travelRequestDetailSubmitModel.js
Normal 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 '确认提交'
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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: [
|
||||||
{
|
{
|
||||||
|
|||||||
42
web/tests/app-shell-mobile-browser.test.mjs
Normal 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\)\);/)
|
||||||
|
})
|
||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
|
|||||||
@@ -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*\}/)
|
||||||
|
|||||||
@@ -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([
|
||||||
'| 项目 | 标准口径 | 天数 | 小计 |',
|
'| 项目 | 标准口径 | 天数 | 小计 |',
|
||||||
|
|||||||