diff --git a/document/development/移动端适配/CONCEPT.md b/document/development/移动端适配/CONCEPT.md new file mode 100644 index 0000000..de25f22 --- /dev/null +++ b/document/development/移动端适配/CONCEPT.md @@ -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 构建通过。 + +## 风险与开放问题 + +- 其他业务页面仍可能存在表格密度过高的问题,需要按页面继续做字段折叠或卡片化。 +- 一些二级确认弹窗、票据预览和日期控件需要后续逐页检查。 +- 手机浏览器地址栏收起/展开会改变视口高度,后续应继续用真实设备补充验证。 diff --git a/document/development/移动端适配/TODO.md b/document/development/移动端适配/TODO.md new file mode 100644 index 0000000..280e060 --- /dev/null +++ b/document/development/移动端适配/TODO.md @@ -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: 风险与开放问题] diff --git a/server/src/app/api/v1/endpoints/reimbursements.py b/server/src/app/api/v1/endpoints/reimbursements.py index a243f49..239a260 100644 --- a/server/src/app/api/v1/endpoints/reimbursements.py +++ b/server/src/app/api/v1/endpoints/reimbursements.py @@ -703,7 +703,7 @@ def pay_expense_claim( "/claims/{claim_id}", response_model=ExpenseClaimActionResponse, summary="删除报销单", - description="申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档单据;已归档单据仅高级管理员可删除,财务人员没有删除权限。", + description="申请单仅系统管理员可删除;报销单申请人仅可删除自己的草稿、待补充或退回单据;高级财务人员可删除可见的非归档报销单;已归档单据仅高级管理员可删除,财务人员没有删除权限。", responses={ status.HTTP_404_NOT_FOUND: { "model": ErrorResponse, @@ -725,8 +725,11 @@ def delete_expense_claim(claim_id: str, db: DbSession, current_user: CurrentUser if claim is None: 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( - message=f"{claim.claim_no} 报销单已删除。", + message=f"{claim.claim_no} {document_label}已删除。", claim_id=claim.id, status="deleted", ) diff --git a/server/src/app/services/expense_claim_constants.py b/server/src/app/services/expense_claim_constants.py index 1ea7f84..506fb0f 100644 --- a/server/src/app/services/expense_claim_constants.py +++ b/server/src/app/services/expense_claim_constants.py @@ -27,6 +27,7 @@ EXPENSE_TYPE_LABELS = { MAX_DRAFT_CLAIMS_PER_USER = 3 EDITABLE_CLAIM_STATUSES = ("draft", "supplement", "returned") SYSTEM_GENERATED_ITEM_TYPES = {"travel_allowance"} +OPTIONAL_ATTACHMENT_ITEM_TYPES = {"ride_ticket", "travel_allowance"} TRAVEL_DETAIL_ITEM_TYPES = { "train_ticket", "flight_ticket", diff --git a/server/src/app/services/expense_claim_draft_flow.py b/server/src/app/services/expense_claim_draft_flow.py index b52a7e9..b213fe8 100644 --- a/server/src/app/services/expense_claim_draft_flow.py +++ b/server/src/app/services/expense_claim_draft_flow.py @@ -307,6 +307,13 @@ class ExpenseClaimDraftFlowMixin: claim.risk_flags_json = final_risk_flags 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 review_action == "link_to_existing_draft" and claim.items: self._append_document_items( @@ -319,6 +326,8 @@ class ExpenseClaimDraftFlowMixin: item_specs=document_specs, ) self._sync_claim_from_items(claim) + elif skip_primary_item: + self._sync_application_link_draft_without_items(claim) else: self._upsert_primary_item( claim=claim, @@ -379,6 +388,66 @@ class ExpenseClaimDraftFlowMixin: "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 def _merge_application_link_flag( risk_flags: list[Any], diff --git a/server/src/app/services/expense_claim_item_sync.py b/server/src/app/services/expense_claim_item_sync.py index 162cf4f..1e39050 100644 --- a/server/src/app/services/expense_claim_item_sync.py +++ b/server/src/app/services/expense_claim_item_sync.py @@ -23,6 +23,7 @@ from app.services.expense_claim_constants import ( AI_REVIEW_REPEAT_RISK_WARNING_COUNT, DOCUMENT_FACT_ITEM_TYPES, LOCATION_REQUIRED_EXPENSE_TYPES, + OPTIONAL_ATTACHMENT_ITEM_TYPES, SYSTEM_GENERATED_ITEM_TYPES, TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, @@ -399,6 +400,20 @@ class ExpenseClaimItemSyncMixin: return 1 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: parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()] for item in claim.items: @@ -454,16 +469,16 @@ class ExpenseClaimItemSyncMixin: def _format_submission_blocked_message(issues: list[str]) -> str: normalized_issues = [str(issue or "").strip() for issue in issues if str(issue or "").strip()] 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) ) def _validate_claim_for_submission(self, claim: ExpenseClaim) -> list[str]: issues: list[str] = [] 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): issues.append("申请人未完善") @@ -498,7 +513,7 @@ class ExpenseClaimItemSyncMixin: issues.append(f"{prefix}缺少地点") if item.item_amount is None or item.item_amount <= Decimal("0.00"): 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}缺少票据标识") return issues diff --git a/server/src/app/services/expense_claim_pre_review.py b/server/src/app/services/expense_claim_pre_review.py index 49868d6..dd6f441 100644 --- a/server/src/app/services/expense_claim_pre_review.py +++ b/server/src/app/services/expense_claim_pre_review.py @@ -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 self.db.commit() @@ -105,16 +105,16 @@ class ExpenseClaimPreReviewMixin: business_stage: str, ) -> dict[str, Any]: if passed: - message = "AI预审通过,费用明细和附件可进入下一步提交审批。" + message = "自动检测通过,费用明细和附件可提交审批。" else: - message = f"AI预审发现 {blocking_count} 条重大风险,请逐条填写原因后再进入下一步。" + message = f"自动检测发现 {blocking_count} 条重大风险,请逐条填写原因后再提交审批。" return with_risk_business_stage( { "source": "ai_pre_review", "event_type": "expense_claim_ai_pre_review", "severity": "info" if passed else "high", - "label": "AI预审通过" if passed else "AI预审未通过", + "label": "自动检测通过" if passed else "自动检测未通过", "message": message, "status": "passed" if passed else "failed", "passed": passed, diff --git a/server/src/app/services/expense_claim_review_preview.py b/server/src/app/services/expense_claim_review_preview.py index 72dde54..dad351c 100644 --- a/server/src/app/services/expense_claim_review_preview.py +++ b/server/src/app/services/expense_claim_review_preview.py @@ -198,7 +198,7 @@ class ExpenseClaimReviewPreviewMixin: if review_message: break return { - "message": review_message or f"报销单 {claim.claim_no} 经 AI预审后转为待补充,请先修正后再提交。", + "message": review_message or f"报销单 {claim.claim_no} 经自动检测后转为待补充,请先修正后再提交。", "submission_blocked": True, "draft_only": False, "claim_id": claim.id, @@ -211,7 +211,7 @@ class ExpenseClaimReviewPreviewMixin: return { "message": ( - f"报销单 {claim.claim_no} 已完成 AI预审," + f"报销单 {claim.claim_no} 已完成自动检测," f"当前节点为 {claim.approval_stage or '审批中'}。" ), "draft_only": False, diff --git a/server/src/app/services/expense_claim_risk_review.py b/server/src/app/services/expense_claim_risk_review.py index 47360c6..6904b44 100644 --- a/server/src/app/services/expense_claim_risk_review.py +++ b/server/src/app/services/expense_claim_risk_review.py @@ -62,9 +62,9 @@ class ExpenseClaimRiskReviewMixin( { "source": "submission_review", "severity": "high", - "label": "AI预审重点复核", + "label": "自动检测重点复核", "message": ( - f"AI预审发现 {len(high_attachment_flags)} 条高风险附件," + f"自动检测发现 {len(high_attachment_flags)} 条高风险附件," "已随单流转给审批人重点复核。" ), } @@ -74,9 +74,9 @@ class ExpenseClaimRiskReviewMixin( { "source": "submission_review", "severity": "medium", - "label": "AI预审提醒", + "label": "自动检测提醒", "message": ( - f"AI预审发现 {len(medium_attachment_flags)} 条中风险附件," + f"自动检测发现 {len(medium_attachment_flags)} 条中风险附件," "已随单流转给审批人复核。" ), } @@ -90,7 +90,7 @@ class ExpenseClaimRiskReviewMixin( "source": "submission_review", "severity": "medium", "label": "审批链待分配", - "message": "AI预审发现直属领导缺失,已提交到审批环节等待分配或复核。", + "message": "自动检测发现直属领导缺失,已提交到审批环节等待分配或复核。", } ) @@ -147,7 +147,7 @@ class ExpenseClaimRiskReviewMixin( ) if attention_reasons: - summary_message = "AI预审发现需审批重点关注事项:" + ";".join( + summary_message = "自动检测发现需审批重点关注事项:" + ";".join( dict.fromkeys(attention_reasons) ) review_flags.insert( @@ -155,7 +155,7 @@ class ExpenseClaimRiskReviewMixin( { "source": "submission_review", "severity": "medium", - "label": "AI预审重点复核", + "label": "自动检测重点复核", "message": summary_message, }, ) @@ -167,7 +167,7 @@ class ExpenseClaimRiskReviewMixin( "approval_stage": "直属领导审批", "risk_flags": preserved_flags + review_flags, "message": ( - f"报销单 {claim.claim_no} 已完成 AI预审," + f"报销单 {claim.claim_no} 已完成自动检测," f"现已提交给直属领导 {manager_name or '审批人'} 审批。" ), "passed": True, diff --git a/server/src/app/services/expense_claims.py b/server/src/app/services/expense_claims.py index 762ee16..b570ec1 100644 --- a/server/src/app/services/expense_claims.py +++ b/server/src/app/services/expense_claims.py @@ -11,7 +11,7 @@ from pathlib import Path from types import SimpleNamespace 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.exc import IntegrityError 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.employee import Employee 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.reimbursement import ( ExpenseClaimItemCreate, @@ -560,6 +562,9 @@ class ExpenseClaimService( if claim is 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: raise ValueError("已归档单据不能删除,只有高级管理员可以执行删除。") @@ -572,6 +577,7 @@ class ExpenseClaimService( resource_id = claim.id self._release_budget_for_delete(claim, current_user) + self._delete_claim_analysis_records(resource_id) self._attachment_storage.delete_claim_files(claim) self.db.delete(claim) self.db.commit() @@ -588,6 +594,16 @@ class ExpenseClaimService( 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( self, claim_id: str, @@ -740,8 +756,6 @@ class ExpenseClaimService( - - diff --git a/server/src/app/services/risk_rule_template_executor.py b/server/src/app/services/risk_rule_template_executor.py index c298d02..9ef9f56 100644 --- a/server/src/app/services/risk_rule_template_executor.py +++ b/server/src/app/services/risk_rule_template_executor.py @@ -14,6 +14,7 @@ CITY_CONSISTENCY_SEMANTIC_TYPES = { "travel_city_consistency", "travel_route_city_consistency", } +ROUTE_CITY_SPLIT_PATTERN = re.compile(r"\s*(?:至|到|→|->|-|-|—|~|~|/|、|,|,|;|;)\s*") class RiskRuleTemplateExecutor: @@ -612,19 +613,32 @@ class RiskRuleTemplateExecutor: ) -> list[str]: if len(route_values) < 2: return [] - allowed = {value.lower() for value in [*reference_values, *home_values] if value} - if not allowed: + allowed_values = [value for value in [*reference_values, *home_values] if value] + if not allowed_values: return [] candidates = route_values if home_values else route_values[1:-1] unexpected: list[str] = [] for city in candidates: - normalized = city.lower() - if normalized in allowed: + if RiskRuleTemplateExecutor._values_overlap([city], allowed_values): continue if city not in unexpected: unexpected.append(city) 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( self, field_key: str, contexts: list[dict[str, Any]] ) -> list[str]: @@ -643,7 +657,7 @@ class RiskRuleTemplateExecutor: else self._scan_document_values(document_info, "city") ) 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: values.extend(self._scan_document_values(document_info, field_key)) return self._normalize_values(values) @@ -878,9 +892,9 @@ class RiskRuleTemplateExecutor: left_set = {value.lower() for value in left_values} right_set = {value.lower() for value in right_values} 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"}: - return not bool(left_set & right_set) + return not RiskRuleTemplateExecutor._values_overlap(left_values, right_values) if operator == "contains_any": return any(any(right in left for right in right_set) for left in left_set) return bool(left_set & right_set) diff --git a/server/src/app/services/user_agent_application.py b/server/src/app/services/user_agent_application.py index 0654188..25fdaf3 100644 --- a/server/src/app/services/user_agent_application.py +++ b/server/src/app/services/user_agent_application.py @@ -4,7 +4,7 @@ import re from datetime import UTC, datetime from decimal import Decimal, InvalidOperation -from sqlalchemy import select +from sqlalchemy import or_, select from app.api.deps import CurrentUserContext from app.models.financial_record import ExpenseClaim @@ -20,7 +20,10 @@ from app.services.document_numbering import ( build_document_number, 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.application_system_estimate import apply_application_system_estimate_to_facts @@ -32,6 +35,43 @@ APPLICATION_CONTEXT_VALUES = { "preapproval", } APPLICATION_BASE_FIELDS = ("time", "location", "reason") +APPLICATION_TIME_LABELS = ("行程时间", "招待时间", "申请时间", "发生时间", "业务发生时间", "时间") +APPLICATION_FIELD_LABELS = ( + "申请类型", + "费用类型", + "姓名", + "申请人", + "部门", + "岗位", + "职级", + "直属领导", + *APPLICATION_TIME_LABELS, + "地点", + "业务地点", + "发生地点", + "目的地", + "事由", + "申请事由", + "出差事由", + "原因", + "用途", + "天数", + "出差天数", + "申请天数", + "出行方式", + "交通方式", + "交通工具", + "出行工具", + "用户预估费用", + "预估费用", + "预计总费用", + "预计费用", + "预计金额", + "申请金额", + "预算", + "金额", + "费用", +) APPLICATION_TRANSPORT_OPTIONS = ("飞机", "火车", "轮船") APPLICATION_TRANSPORT_KEYWORDS = { "飞机": ("飞机", "机票", "航班", "乘机", "坐飞机"), @@ -64,6 +104,18 @@ APPLICATION_SUBMIT_KEYWORDS = ( "直接提交", ) APPLICATION_SHORT_CONFIRMATIONS = {"提交", "确认", "是", "好的", "可以", "没问题"} +APPLICATION_MISSING_VALUES = {"", "待补充", "待确认", "未知", "暂无", "无", "null", "none"} +APPLICATION_DUPLICATE_IGNORED_STATUSES = { + "cancelled", + "canceled", + "void", + "voided", + "deleted", + "已取消", + "已作废", + "作废", + "已删除", +} class UserAgentApplicationMixin: @@ -119,7 +171,12 @@ class UserAgentApplicationMixin: step = self._resolve_expense_application_step(payload, facts) application_claim = None 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_claim_id"] = application_claim.id facts["manager_name"] = self._resolve_application_manager_name(payload, application_claim) @@ -128,7 +185,11 @@ class UserAgentApplicationMixin: citations=[], suggested_actions=self._build_expense_application_actions(step, facts), 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, risk_flags=risk_flags, 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( [ "这是费用申请核对结果,请核对:", @@ -225,13 +299,27 @@ class UserAgentApplicationMixin: facts[key] = value 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"]: facts["applicant"] = str( context_json.get("name") or context_json.get("user_name") 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 "" ).strip() if not facts["department"]: @@ -239,7 +327,12 @@ class UserAgentApplicationMixin: context_json.get("department") or context_json.get("department_name") 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 "" ).strip() if not facts["position"]: @@ -247,6 +340,8 @@ class UserAgentApplicationMixin: context_json.get("position") or context_json.get("employee_position") or context_json.get("employeePosition") + or current_user.position + or (employee.position if employee is not None else "") or "" ).strip() if not facts["manager_name"]: @@ -255,7 +350,17 @@ class UserAgentApplicationMixin: or context_json.get("managerName") or context_json.get("direct_manager_name") 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 "" ).strip() @@ -266,6 +371,10 @@ class UserAgentApplicationMixin: facts.get("days", ""), 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) return facts @@ -285,11 +394,12 @@ class UserAgentApplicationMixin: return value return "" + reason = UserAgentApplicationMixin._cleanup_application_reason_candidate(pick("reason")) return { "application_type": pick("applicationType", "application_type"), "time": pick("time", "timeRange", "time_range"), "location": pick("location"), - "reason": pick("reason"), + "reason": reason, "days": pick("days"), "transport_mode": pick("transportMode", "transport_mode"), "amount": pick("amount"), @@ -313,6 +423,10 @@ class UserAgentApplicationMixin: "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( self, payload: UserAgentRequest, @@ -384,10 +498,16 @@ class UserAgentApplicationMixin: def _resolve_application_time_from_text(message: str) -> str: labeled = UserAgentApplicationMixin._resolve_application_labeled_value( message, - ("发生时间", "业务发生时间", "申请时间", "时间"), + APPLICATION_TIME_LABELS, ) if labeled: return labeled + range_match = re.search( + r"(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)\s*(?:至|到|~|—|–|--)\s*(?P20\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( r"(?P20\d{2}[-/.年]\d{1,2}[-/.月]\d{1,2}日?)", str(message or ""), @@ -406,11 +526,26 @@ class UserAgentApplicationMixin: return start_date if start_date == end_date else f"{start_date} 至 {end_date}" 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 def _resolve_application_labeled_value(message: str, labels: tuple[str, ...]) -> str: 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( - rf"(?:{label_pattern})[::]\s*(?P[^\n,。;;]+)", + rf"(?:{label_pattern})[::]\s*(?P[\s\S]*?)(?=\s*(?:{next_label_pattern})[::]|[\n,。;;]|$)", str(message or ""), ) return match.group("value").strip() if match else "" @@ -478,7 +613,7 @@ class UserAgentApplicationMixin: ("事由", "申请事由", "出差事由", "原因", "用途"), ) if labeled: - return labeled + return UserAgentApplicationMixin._cleanup_application_reason_candidate(labeled) text = str(message or "").strip() if not text: @@ -492,7 +627,15 @@ class UserAgentApplicationMixin: if not candidates: 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 def _cleanup_application_reason_candidate(segment: str) -> str: @@ -501,10 +644,12 @@ class UserAgentApplicationMixin: return "" text = re.sub( - r"^(?:发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|天数|出差天数|申请天数|出行方式|交通方式|交通工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额)[::]\s*", + r"^(?:行程时间|招待时间|发生时间|业务发生时间|申请时间|时间|地点|业务地点|发生地点|目的地|天数|出差天数|申请天数|出行方式|交通方式|交通工具|出行工具|用户预估费用|预估费用|预计总费用|预计费用|预计金额|申请金额|预算|金额|费用)[::]\s*", "", 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): return "" if re.fullmatch(r"(?P\d+|[一二两三四五六七八九十]{1,3})\s*天", text): @@ -617,8 +762,8 @@ class UserAgentApplicationMixin: return { "expense_type": "申请类型", "amount": "系统预估费用", - "time_range": "发生时间", - "time": "发生时间", + "time_range": "申请时间", + "time": "申请时间", "location": "地点", "reason": "申请事由", "days": "天数", @@ -656,7 +801,7 @@ class UserAgentApplicationMixin: @staticmethod def _resolve_application_prefill_config(field: str) -> tuple[str, str]: config = { - "time": ("补充发生时间", "申请时间段:"), + "time": ("补充申请时间", "申请时间段:"), "location": ("补充地点", "地点:"), "reason": ("补充申请事由", "事由:"), "days": ("补充天数", "天数:"), @@ -699,7 +844,17 @@ class UserAgentApplicationMixin: return "差旅费用申请" @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( f"{label}:{value or '待补充'}" for label, value in ( @@ -709,7 +864,7 @@ class UserAgentApplicationMixin: ("岗位", facts.get("position", "")), ("职级", facts.get("grade", "")), ("直属领导", facts.get("manager_name", "")), - ("发生时间", facts.get("time", "")), + (time_label, facts.get("time", "")), ("地点", facts.get("location", "")), ("事由", facts.get("reason", "")), ("天数", facts.get("days", "")), @@ -722,12 +877,14 @@ class UserAgentApplicationMixin: ) ) - @staticmethod + @classmethod def _build_application_summary_table( + cls, facts: dict[str, str], *, include_empty: bool = True, ) -> str: + time_label = cls._resolve_application_time_label(facts) rows = [ ("申请类型", facts.get("application_type", "")), ("姓名", facts.get("applicant", "")), @@ -735,7 +892,7 @@ class UserAgentApplicationMixin: ("岗位", facts.get("position", "")), ("职级", facts.get("grade", "")), ("直属领导", facts.get("manager_name", "")), - ("发生时间", facts.get("time", "")), + (time_label, facts.get("time", "")), ("地点", facts.get("location", "")), ("事由", facts.get("reason", "")), ("天数", facts.get("days", "")), @@ -816,6 +973,90 @@ class UserAgentApplicationMixin: self.db.refresh(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 def _build_application_detail_flag(facts: dict[str, str]) -> dict[str, object]: return with_risk_business_stage( @@ -895,6 +1136,24 @@ class UserAgentApplicationMixin: or context_json.get("departmentName") or "" ).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( context_json.get("manager_name") or context_json.get("managerName") diff --git a/server/src/app/services/user_agent_application_dates.py b/server/src/app/services/user_agent_application_dates.py index 3fdd62d..5f44169 100644 --- a/server/src/app/services/user_agent_application_dates.py +++ b/server/src/app/services/user_agent_application_dates.py @@ -43,6 +43,20 @@ def resolve_application_days_count(days_text: str) -> int: 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: if time_text: match = re.search( diff --git a/server/src/app/services/user_agent_review_messages.py b/server/src/app/services/user_agent_review_messages.py index 13ed700..bc1407a 100644 --- a/server/src/app/services/user_agent_review_messages.py +++ b/server/src/app/services/user_agent_review_messages.py @@ -183,9 +183,14 @@ class UserAgentReviewMessageMixin: if draft_payload is not None and draft_payload.claim_no: return ( f"已按您当前确认的信息保存为草稿 {draft_payload.claim_no}。" - "后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。" + "系统已完成草稿规则校验,风险与异常可在单据详情查看。" + "如果还有其他票据,可以继续在当前对话上传,我会归集到这张草稿。" ) - return "已按您当前确认的信息保存为草稿。后续上传附件或补充票据信息时,请关联这张草稿;补齐缺失项后再继续提交。" + return ( + "已按您当前确认的信息保存为草稿。" + "系统已完成草稿规则校验,风险与异常可在单据详情查看。" + "如果还有其他票据,可以继续在当前对话上传,我会归集到这张草稿。" + ) if review_action == "link_to_existing_draft": document_count = self._resolve_review_document_count(payload) followup_copy = self._build_review_action_followup_copy(review_payload) @@ -221,7 +226,7 @@ class UserAgentReviewMessageMixin: "如果确有特殊情况,请先在附加说明中补充原因;补充后可以继续提交给审批人重点复核。" ) return ( - "AI预审暂未通过,所以还没有提交到审批人。\n" + "自动检测暂未通过,所以还没有提交到审批人。\n" f"{reason_lines}\n" "请先处理以上项目;处理完成后再点继续下一步。" ) @@ -266,7 +271,7 @@ class UserAgentReviewMessageMixin: "如确有特殊情况,请在附加说明中补充原因后继续提交审批。" ) return ( - f"AI预审未通过:{reason_text}。" + f"自动检测未通过:{reason_text}。" "请先根据风险提示补充原因、调整金额或更换附件,整改后再继续提交。" ) @@ -478,7 +483,7 @@ class UserAgentReviewMessageMixin: if missing_slots: return f"当前仍有 {'、'.join(missing_slots)},暂时只能保存为草稿,补齐后再继续下一步。" if receipt_briefs: - return "当前必需票据已具备;如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传,也可以继续下一步或保存草稿。" + return "当前仍有必需票据待补充,暂时只能保存为草稿;补齐后再继续下一步。" if review_payload.can_proceed: return "当前信息已较完整,您可以继续下一步,也可以先保存为草稿。" return "" @@ -511,17 +516,9 @@ class UserAgentReviewMessageMixin: for item in travel_receipt_state.get("required_missing_labels", []) 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] = [] if required_labels: provide_items.append("1. 酒店住宿发票/住宿清单(必须,当前待上传)") - if optional_labels: - provide_items.append(f"{len(provide_items) + 1}. 市内交通/乘车票据(非必须,如打车、地铁、停车等)") sections = [ f"您好,{user_name}。我先按票据信息做一次差旅预检。", @@ -546,11 +543,6 @@ class UserAgentReviewMessageMixin: "处理建议:酒店票据仍缺失,暂时不能继续下一步。" "您可以先保存为草稿,补齐后再提交。" ) - elif can_proceed and optional_labels: - sections.append( - "处理建议:必需票据已具备。" - "如暂时没有乘车票据,也可以继续下一步,或先保存为草稿。" - ) elif can_proceed: sections.append( "处理建议:当前信息已较完整,确认无误后可以继续下一步;" diff --git a/server/src/app/services/user_agent_review_slots.py b/server/src/app/services/user_agent_review_slots.py index 8ab7a40..74edef2 100644 --- a/server/src/app/services/user_agent_review_slots.py +++ b/server/src/app/services/user_agent_review_slots.py @@ -232,6 +232,17 @@ class UserAgentReviewSlotMixin: 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 if time_range.start_date and time_range.end_date: normalized_value = ( @@ -265,6 +276,16 @@ class UserAgentReviewSlotMixin: 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": request_context = payload.context_json.get("request_context") if isinstance(request_context, dict): @@ -370,6 +391,17 @@ class UserAgentReviewSlotMixin: 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( claim_groups=claim_groups, ) @@ -420,6 +452,22 @@ class UserAgentReviewSlotMixin: 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", "") if amount_value: normalized = self._normalize_amount_text(amount_value) diff --git a/server/src/app/services/user_agent_review_travel_receipts.py b/server/src/app/services/user_agent_review_travel_receipts.py index 219f13f..2ab3786 100644 --- a/server/src/app/services/user_agent_review_travel_receipts.py +++ b/server/src/app/services/user_agent_review_travel_receipts.py @@ -99,9 +99,7 @@ class UserAgentReviewTravelReceiptMixin: } 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 ["酒店的报销票据待上传(必须)"] - optional_missing_labels = [] if has_local_transport else ["市内交通/乘车票据可继续上传(非必须)"] ticket_amount = sum( (self._extract_amount_decimal_from_card(card) or Decimal("0.00")) for card in long_distance_cards @@ -116,9 +114,9 @@ class UserAgentReviewTravelReceiptMixin: "destination": self._resolve_travel_receipt_destination(payload, long_distance_cards), "days": self._resolve_travel_receipt_days(payload, long_distance_cards), "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, - "optional_missing_labels": optional_missing_labels, + "optional_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", []) if str(item).strip() ] - optional_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: + if not required_labels: return [] - content_parts = [*required_labels, *optional_labels] required_text = ";".join(required_labels) - optional_text = ";".join(optional_labels) return [ UserAgentReviewRiskBrief( title="差旅票据待补充", - level="warning" if required_labels else "info", - content=";".join(content_parts), + level="warning", + content=required_text, detail=( "系统已识别到长途交通票据,会按差旅报销口径核对住宿、交通等票据完整性。" - + (f"当前必须补充:{required_text}。" if required_text else "") - + (f"当前还可以补充:{optional_text}。" if optional_text else "") - ), - suggestion=( - "请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。" - if required_labels - else "如还有市内交通、打车、地铁或停车等乘车票据,可以继续上传;没有也可以进入下一步或保存草稿。" + + f"当前必须补充:{required_text}。" ), + suggestion="请先补充酒店住宿发票或住宿清单;在补齐前只能保存为草稿。", ) ] @@ -606,6 +592,10 @@ class UserAgentReviewTravelReceiptMixin: message = str(payload.tool_payload.get("message") or "").strip() for prefix in ( "提交前请先补全信息:", + "自动检测暂未通过,原因如下:", + "自动检测未通过,原因如下:", + "自动检测暂未通过:", + "自动检测未通过:", "AI预审暂未通过,原因如下:", "AI预审未通过,原因如下:", "AI预审暂未通过:", @@ -618,7 +608,9 @@ class UserAgentReviewTravelReceiptMixin: reasons.extend( item.strip() 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)) diff --git a/server/tests/test_expense_claim_service.py b/server/tests/test_expense_claim_service.py index 2d9f958..ead2f55 100644 --- a/server/tests/test_expense_claim_service.py +++ b/server/tests/test_expense_claim_service.py @@ -165,6 +165,32 @@ def test_validate_claim_for_submission_still_requires_location_for_travel_claim( 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: user_id = "preview-only@example.com" 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"] == "支撑国网仿生产环境部署" +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: with build_session() as 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.status == "draft" - assert reviewed.approval_stage == "AI预审" + assert reviewed.approval_stage == "待提交" assert reviewed.submitted_at is None pre_review_flag = next( flag @@ -3098,6 +3198,93 @@ def test_executive_can_delete_submitted_claim() -> 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: current_user = CurrentUserContext( username="executive-archive-delete@example.com", diff --git a/server/tests/test_orchestrator_review_flow.py b/server/tests/test_orchestrator_review_flow.py index 74fc33f..3b9d33e 100644 --- a/server/tests/test_orchestrator_review_flow.py +++ b/server/tests/test_orchestrator_review_flow.py @@ -268,7 +268,7 @@ def test_review_next_step_blocked_returns_reasons_and_removes_next_step_action( assert result["draft_payload"]["status"] == "draft" assert response.conversation_id 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 "next_step" not 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.trace_summary.scenario == "expense" assert "费用申请" in result["answer"] - assert "| 发生时间 | 2026-05-25" in result["answer"] + assert "| 行程时间 | 2026-05-25" in result["answer"] assert "请先在下面选择报销场景" not in result["answer"] assert result.get("review_payload") is None diff --git a/server/tests/test_reimbursement_endpoints.py b/server/tests/test_reimbursement_endpoints.py index bff5a1c..af1faba 100644 --- a/server/tests/test_reimbursement_endpoints.py +++ b/server/tests/test_reimbursement_endpoints.py @@ -16,6 +16,7 @@ from app.main import create_app from app.models.employee import Employee from app.models.financial_record import ExpenseClaim, ExpenseClaimItem from app.models.organization import OrganizationUnit +from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.role import Role from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead 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() with session_factory() as 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 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: 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 diff --git a/server/tests/test_risk_rule_generation.py b/server/tests/test_risk_rule_generation.py index 9b45121..6095913 100644 --- a/server/tests/test_risk_rule_generation.py +++ b/server/tests/test_risk_rule_generation.py @@ -666,6 +666,82 @@ def test_current_keyword_city_consistency_rule_hits_ticket_city_mismatch() -> No 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: text = ( "差旅报销时,先检查是否已上传交通票据、住宿票据或其他能识别城市的附件;" diff --git a/server/tests/test_user_agent_service.py b/server/tests/test_user_agent_service.py index 2046f86..a0ea2a0 100644 --- a/server/tests/test_user_agent_service.py +++ b/server/tests/test_user_agent_service.py @@ -209,7 +209,7 @@ def test_user_agent_application_context_uses_application_language() -> None: 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 "请先在下面选择报销场景" 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: 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 "当前还需要先补充:申请事由" 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) 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 "伊犁出差" 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 @@ -325,6 +325,106 @@ def test_user_agent_application_builds_system_estimate_after_transport_choice() 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: session_factory = build_session_factory() 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 "| 发生时间 | 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 @@ -395,6 +495,45 @@ def test_user_agent_application_builds_preview_when_amount_is_ready() -> None: 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: session_factory = build_session_factory() initial_message = ( @@ -408,7 +547,7 @@ def test_user_agent_application_submit_enters_leader_review() -> None: "| 字段 | 内容 |\n" "| --- | --- |\n" "| 申请类型 | 差旅费用申请 |\n" - "| 发生时间 | 2026-05-25 |\n" + "| 行程时间 | 2026-05-25 |\n" "| 地点 | 上海市 |\n" "| 事由 | 支持上海国网服务器部署 |\n" "| 天数 | 3天 |\n" @@ -443,6 +582,58 @@ def test_user_agent_application_submit_enters_leader_review() -> None: 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: session_factory = build_session_factory() with session_factory() as db: @@ -1173,6 +1364,57 @@ def test_user_agent_continues_identification_after_expense_type_selection() -> N 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: session_factory = build_session_factory() 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["location"].value == "上海" 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: @@ -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.claim_no == "BX202605220001" 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 @@ -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 "酒店住宿发票/住宿清单(必须,当前待上传)" in response.answer - assert "市内交通/乘车票据(非必须" in response.answer + assert "市内交通/乘车票据(非必须" not in response.answer assert "只能保存为草稿" in response.answer or "保存为草稿" 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 -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() with session_factory() as db: 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.can_proceed is True assert response.review_payload.missing_slots == [] - receipt_brief = next(item for item in response.review_payload.risk_briefs if item.title == "差旅票据待补充") - assert receipt_brief.level == "info" - assert "市内交通/乘车票据可继续上传(非必须)" in receipt_brief.content - assert "酒店的报销票据待上传(必须)" not in receipt_brief.content + assert not any(item.title == "差旅票据待补充" for item in response.review_payload.risk_briefs) action_types = [item.action_type for item in response.review_payload.confirmation_actions] assert "save_draft" in action_types assert "next_step" in action_types - assert "市内交通/乘车票据(非必须" in response.answer + assert "市内交通/乘车票据(非必须" not in response.answer assert "继续下一步" in response.answer diff --git a/web/src/assets/login-left-enterprise-visual-v1.webp b/web/src/assets/login-left-enterprise-visual-v1.webp new file mode 100644 index 0000000..44dc687 Binary files /dev/null and b/web/src/assets/login-left-enterprise-visual-v1.webp differ diff --git a/web/src/assets/login-operations-illustration.png b/web/src/assets/login-operations-illustration.png new file mode 100644 index 0000000..5d6c297 Binary files /dev/null and b/web/src/assets/login-operations-illustration.png differ diff --git a/web/src/assets/login-reference-chart-panels.png b/web/src/assets/login-reference-chart-panels.png new file mode 100644 index 0000000..3b19353 Binary files /dev/null and b/web/src/assets/login-reference-chart-panels.png differ diff --git a/web/src/assets/login-reference-left-panel.webp b/web/src/assets/login-reference-left-panel.webp new file mode 100644 index 0000000..f794a1f Binary files /dev/null and b/web/src/assets/login-reference-left-panel.webp differ diff --git a/web/src/assets/login-reference-main-visual-preview.png b/web/src/assets/login-reference-main-visual-preview.png new file mode 100644 index 0000000..1069bcd Binary files /dev/null and b/web/src/assets/login-reference-main-visual-preview.png differ diff --git a/web/src/assets/login-reference-main-visual.png b/web/src/assets/login-reference-main-visual.png new file mode 100644 index 0000000..b01f906 Binary files /dev/null and b/web/src/assets/login-reference-main-visual.png differ diff --git a/web/src/assets/personal-workbench-card-glass-capability.webp b/web/src/assets/personal-workbench-card-glass-capability.webp new file mode 100644 index 0000000..3f4989b Binary files /dev/null and b/web/src/assets/personal-workbench-card-glass-capability.webp differ diff --git a/web/src/assets/personal-workbench-card-glass-panel.webp b/web/src/assets/personal-workbench-card-glass-panel.webp new file mode 100644 index 0000000..b99e77a Binary files /dev/null and b/web/src/assets/personal-workbench-card-glass-panel.webp differ diff --git a/web/src/assets/personal-workbench-hero-bg-concept.png b/web/src/assets/personal-workbench-hero-bg-concept.png new file mode 100644 index 0000000..419d844 Binary files /dev/null and b/web/src/assets/personal-workbench-hero-bg-concept.png differ diff --git a/web/src/assets/personal-workbench-hero-bg-theme-base.png b/web/src/assets/personal-workbench-hero-bg-theme-base.png new file mode 100644 index 0000000..2e0afd6 Binary files /dev/null and b/web/src/assets/personal-workbench-hero-bg-theme-base.png differ diff --git a/web/src/assets/personal-workbench-hero-bg-theme-base.webp b/web/src/assets/personal-workbench-hero-bg-theme-base.webp new file mode 100644 index 0000000..41d8924 Binary files /dev/null and b/web/src/assets/personal-workbench-hero-bg-theme-base.webp differ diff --git a/web/src/assets/personal-workbench-hero-bg-theme-preview.png b/web/src/assets/personal-workbench-hero-bg-theme-preview.png new file mode 100644 index 0000000..830eeea Binary files /dev/null and b/web/src/assets/personal-workbench-hero-bg-theme-preview.png differ diff --git a/web/src/assets/styles/app.css b/web/src/assets/styles/app.css index 1a69401..66aaaef 100644 --- a/web/src/assets/styles/app.css +++ b/web/src/assets/styles/app.css @@ -207,6 +207,10 @@ background: #fff; } +.mobile-hamburger-btn { + display: none; +} + @keyframes loginEntrySidebarIn { from { opacity: 0; @@ -270,9 +274,28 @@ .app > .main { flex: 1 1 100%; 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; } .mobile-overlay { diff --git a/web/src/assets/styles/components/document-list-shared.css b/web/src/assets/styles/components/document-list-shared.css index 26eb1d7..b8187c2 100644 --- a/web/src/assets/styles/components/document-list-shared.css +++ b/web/src/assets/styles/components/document-list-shared.css @@ -477,7 +477,18 @@ td small { @media (max-width: 760px) { .status-tabs { gap: 18px; + flex-wrap: nowrap; 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, @@ -492,4 +503,111 @@ td small { display: grid; 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; + } } diff --git a/web/src/assets/styles/components/enterprise-page-shell.css b/web/src/assets/styles/components/enterprise-page-shell.css index 8ae497c..15fad54 100644 --- a/web/src/assets/styles/components/enterprise-page-shell.css +++ b/web/src/assets/styles/components/enterprise-page-shell.css @@ -654,8 +654,7 @@ .enterprise-list-page .create-request-btn, .enterprise-list-page .create-btn, .enterprise-list-page .export-btn, - .enterprise-list-page .template-btn, - .enterprise-list-page .page-size-select { + .enterprise-list-page .template-btn { width: 100%; } @@ -666,8 +665,35 @@ 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 { - justify-self: stretch; + width: 160px; + max-width: 100%; + justify-self: center; } } diff --git a/web/src/assets/styles/components/personal-workbench-glass.css b/web/src/assets/styles/components/personal-workbench-glass.css new file mode 100644 index 0000000..c92d596 --- /dev/null +++ b/web/src/assets/styles/components/personal-workbench-glass.css @@ -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); +} diff --git a/web/src/assets/styles/components/personal-workbench-insights.css b/web/src/assets/styles/components/personal-workbench-insights.css index acb30e7..d407366 100644 --- a/web/src/assets/styles/components/personal-workbench-insights.css +++ b/web/src/assets/styles/components/personal-workbench-insights.css @@ -78,10 +78,17 @@ gap: 10px; min-height: 0; 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-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: border-color 180ms var(--ease), background-color 180ms var(--ease); @@ -90,7 +97,9 @@ .insight-metric-row:hover, .insight-profile-card:hover { 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, diff --git a/web/src/assets/styles/components/personal-workbench-responsive.css b/web/src/assets/styles/components/personal-workbench-responsive.css index 967d89f..c925d57 100644 --- a/web/src/assets/styles/components/personal-workbench-responsive.css +++ b/web/src/assets/styles/components/personal-workbench-responsive.css @@ -15,7 +15,7 @@ } .assistant-hero { - --assistant-art-width: min(560px, 42vw); + --assistant-bg-position: 56% center; padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px; } @@ -58,9 +58,7 @@ } .assistant-hero { - --assistant-art-width: min(620px, 44vw); - --assistant-art-x: 48px; - --assistant-art-y: -10px; + --assistant-bg-position: 58% center; padding: var(--hero-padding-top) 18px var(--hero-padding-bottom) 44px; } @@ -112,12 +110,11 @@ } .assistant-hero { - --assistant-art-width: min(540px, 50vw); - --assistant-art-x: 36px; - --assistant-art-y: -8px; - background: - 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(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%); + --assistant-bg-position: 62% center; + --assistant-readability-mask: + linear-gradient(90deg, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0.88) 58%, rgba(255, 255, 255, 0.44) 100%); + --assistant-theme-tint: + 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%); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); } @@ -149,17 +146,23 @@ grid-template-rows: none; gap: 14px; 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 { min-height: auto; - --assistant-art-width: min(380px, 78vw); - --assistant-art-x: 12px; - --assistant-art-y: -6px; + --assistant-bg-position: 68% center; + --assistant-readability-mask: + 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; - 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); -webkit-backdrop-filter: blur(12px); } @@ -262,7 +265,7 @@ } .assistant-hero { - --assistant-art-width: min(280px, 70vw); + --assistant-bg-position: 72% center; padding: 20px 14px 20px; } } diff --git a/web/src/assets/styles/components/personal-workbench.css b/web/src/assets/styles/components/personal-workbench.css index 4743917..1d33f06 100644 --- a/web/src/assets/styles/components/personal-workbench.css +++ b/web/src/assets/styles/components/personal-workbench.css @@ -53,19 +53,25 @@ .workbench :where(button:disabled) { cursor: not-allowed; opacity: 0.7; } .assistant-hero { - --assistant-art-width: min(680px, 46vw); - --assistant-art-x: 56px; - --assistant-art-y: -12px; + --assistant-bg-position: center right; + --assistant-bg-size: cover; + --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; z-index: 2; min-height: 0; - overflow: visible; + overflow: hidden; 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-radius: 4px; 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%), - 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-readability-mask), + 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); -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); @@ -73,15 +79,7 @@ } .assistant-hero::after { - content: ""; - 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; + content: none; } .assistant-hero::before { @@ -90,7 +88,8 @@ inset: 0; border-radius: inherit; 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; z-index: 1; } @@ -317,7 +316,6 @@ .capability-card { position: relative; - isolation: isolate; display: grid; grid-template-columns: 40px minmax(0, 1fr) 10px; align-items: center; @@ -331,6 +329,11 @@ background: var(--workbench-surface); text-align: left; 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 { diff --git a/web/src/assets/styles/components/top-bar.css b/web/src/assets/styles/components/top-bar.css index 2913721..0267e80 100644 --- a/web/src/assets/styles/components/top-bar.css +++ b/web/src/assets/styles/components/top-bar.css @@ -1,4 +1,7 @@ .topbar { + box-sizing: border-box; + width: 100%; + min-width: 0; display: flex; align-items: center; justify-content: space-between; @@ -14,6 +17,7 @@ .title-group { min-width: 0; + max-width: 100%; } .eyebrow { @@ -46,6 +50,8 @@ } .top-actions { + min-width: 0; + max-width: 100%; display: flex; align-items: center; justify-content: flex-end; @@ -282,6 +288,8 @@ } .kpi-chips { + min-width: 0; + max-width: 100%; display: flex; gap: 10px; } @@ -531,6 +539,9 @@ @media (max-width: 960px) { .topbar { + width: 100%; + min-width: 0; + max-width: 100%; flex-direction: column; align-items: stretch; } @@ -596,18 +607,26 @@ .kpi-chips { width: 100%; - overflow-x: auto; - padding-bottom: 2px; - scrollbar-width: thin; + min-width: 0; + max-width: 100%; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + gap: 8px; + overflow: visible; + padding-bottom: 0; } .kpi-chip { - min-width: 118px; + min-width: 0; padding: 8px 12px; } + .chip-value, .chip-label, .chip-delta { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } diff --git a/web/src/assets/styles/components/travel-reimbursement-insight-panel.css b/web/src/assets/styles/components/travel-reimbursement-insight-panel.css index 549b833..d9be43d 100644 --- a/web/src/assets/styles/components/travel-reimbursement-insight-panel.css +++ b/web/src/assets/styles/components/travel-reimbursement-insight-panel.css @@ -161,6 +161,30 @@ 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 { display: grid; gap: 6px; @@ -289,6 +313,26 @@ 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, .flow-icon-btn, .review-document-nav-btn, diff --git a/web/src/assets/styles/components/travel-reimbursement-message-application.css b/web/src/assets/styles/components/travel-reimbursement-message-application.css index b735b5f..c8d5855 100644 --- a/web/src/assets/styles/components/travel-reimbursement-message-application.css +++ b/web/src/assets/styles/components/travel-reimbursement-message-application.css @@ -16,6 +16,89 @@ 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 { display: grid; grid-template-columns: 36px minmax(0, 1fr) auto; diff --git a/web/src/assets/styles/views/audit-view-part2.css b/web/src/assets/styles/views/audit-view-part2.css index b512735..62fa2d4 100644 --- a/web/src/assets/styles/views/audit-view-part2.css +++ b/web/src/assets/styles/views/audit-view-part2.css @@ -1271,6 +1271,11 @@ } @media (max-width: 860px) { + .skill-center { + height: auto; + min-height: 100%; + } + .skill-list, .detail-card, .side-card, @@ -1278,6 +1283,25 @@ 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, .card-head, .detail-actions, @@ -1306,6 +1330,102 @@ 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, .form-grid, .contract-grid { @@ -1618,3 +1738,91 @@ 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; + } +} diff --git a/web/src/assets/styles/views/budget-center-dialog.css b/web/src/assets/styles/views/budget-center-dialog.css index 8d164db..5a1f6d3 100644 --- a/web/src/assets/styles/views/budget-center-dialog.css +++ b/web/src/assets/styles/views/budget-center-dialog.css @@ -500,3 +500,49 @@ 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)); + } +} diff --git a/web/src/assets/styles/views/budget-center-view.css b/web/src/assets/styles/views/budget-center-view.css index 747e9ef..7bf397c 100644 --- a/web/src/assets/styles/views/budget-center-view.css +++ b/web/src/assets/styles/views/budget-center-view.css @@ -445,12 +445,23 @@ } @media (max-width: 760px) { + .budget-center-page { + height: auto; + min-height: 100%; + display: block; + overflow: visible; + } + .budget-list { - padding: 16px; + height: auto; + min-height: 100%; + display: block; + overflow: visible; + padding: 12px; } .budget-detail-page { - padding: 16px 16px 0; + padding: 12px 12px 0; } .budget-select-filter, @@ -464,6 +475,121 @@ 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 { grid-template-columns: 1fr; } @@ -471,4 +597,71 @@ .budget-status-explain-list { 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; + } } diff --git a/web/src/assets/styles/views/digital-employees-view.css b/web/src/assets/styles/views/digital-employees-view.css index 7f37951..97b3762 100644 --- a/web/src/assets/styles/views/digital-employees-view.css +++ b/web/src/assets/styles/views/digital-employees-view.css @@ -168,3 +168,137 @@ 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; + } +} diff --git a/web/src/assets/styles/views/documents-center-view.css b/web/src/assets/styles/views/documents-center-view.css index ad797e3..083f483 100644 --- a/web/src/assets/styles/views/documents-center-view.css +++ b/web/src/assets/styles/views/documents-center-view.css @@ -222,8 +222,19 @@ } @media (max-width: 760px) { + .documents-page { + height: auto; + min-height: 100%; + display: block; + overflow: visible; + } + .documents-list { - padding: 16px; + height: auto; + min-height: 100%; + display: block; + overflow: visible; + padding: 12px; } .document-status-filter { diff --git a/web/src/assets/styles/views/employee-management-view.css b/web/src/assets/styles/views/employee-management-view.css index 01b1210..d9e0079 100644 --- a/web/src/assets/styles/views/employee-management-view.css +++ b/web/src/assets/styles/views/employee-management-view.css @@ -1202,6 +1202,13 @@ td.cell-updated { } @media (max-width: 860px) { + .employee-center, + .employee-list, + .employee-detail { + height: auto; + min-height: 100%; + } + .employee-list, .detail-card, .side-card, @@ -1209,6 +1216,15 @@ td.cell-updated { padding: 16px; } + .employee-center, + .employee-list { + overflow: visible; + } + + .employee-list { + display: block; + } + .list-toolbar, .card-head, .detail-actions, @@ -1223,6 +1239,21 @@ td.cell-updated { 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 { grid-template-columns: 1fr; justify-items: stretch; @@ -1237,6 +1268,20 @@ td.cell-updated { 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 { width: min(280px, calc(100vw - 64px)); } @@ -1246,6 +1291,18 @@ td.cell-updated { 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, .form-grid, .role-grid { @@ -1265,4 +1322,99 @@ td.cell-updated { .history-row-time { 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; + } } diff --git a/web/src/assets/styles/views/login-view.css b/web/src/assets/styles/views/login-view.css index 6fec840..839f33d 100644 --- a/web/src/assets/styles/views/login-view.css +++ b/web/src/assets/styles/views/login-view.css @@ -2,495 +2,316 @@ position: relative; min-height: var(--desktop-stage-height, 100dvh); display: grid; - grid-template-columns: minmax(620px, .96fr) minmax(520px, .84fr); - justify-content: center; + grid-template-columns: minmax(760px, 1fr) minmax(420px, 540px); align-items: center; - gap: clamp(32px, 4.8vw, 76px); - padding: 48px clamp(40px, 5vw, 86px); + gap: clamp(28px, 3.6vw, 58px); + padding: 40px clamp(42px, 4vw, 58px) 30px; overflow: hidden; background: - linear-gradient(120deg, rgba(var(--theme-primary-rgb, 58, 124, 165), .10), transparent 34%), - linear-gradient(105deg, #f8fafc 0%, #f5faff 44%, #f8fafc 100%); + linear-gradient(112deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.09), transparent 38%), + linear-gradient(180deg, #f8fbfd 0%, #eef5f9 100%); } .login-page::before { content: ""; position: absolute; inset: 0; - z-index: 0; background: - linear-gradient(90deg, rgba(15,23,42,.045) 1px, transparent 1px), - linear-gradient(0deg, rgba(15,23,42,.04) 1px, transparent 1px), - radial-gradient(circle at 28% 72%, rgba(var(--theme-primary-rgb, 58, 124, 165), .10), transparent 28%), - radial-gradient(circle at 75% 22%, rgba(37,99,235,.06), transparent 30%); - background-size: 72px 72px, 72px 72px, auto, auto; - mask-image: linear-gradient(100deg, rgba(0,0,0,.7), rgba(0,0,0,.32) 48%, rgba(0,0,0,.16)); + linear-gradient(90deg, rgba(15, 23, 42, 0.032) 1px, transparent 1px), + linear-gradient(0deg, rgba(15, 23, 42, 0.028) 1px, transparent 1px); + background-size: 72px 72px; + mask-image: linear-gradient(105deg, rgba(0, 0, 0, 0.56), rgba(0, 0, 0, 0.14)); pointer-events: none; } -.login-page::after { - content: ""; - position: absolute; - left: -9vw; - top: 13vh; - z-index: 0; - width: min(820px, 58vw); - height: min(560px, 64vh); - border: 1px solid rgba(148,163,184,.22); - border-radius: 8px; - background: - linear-gradient(90deg, transparent 0 28%, rgba(15,23,42,.055) 28% calc(28% + 1px), transparent calc(28% + 1px)), - repeating-linear-gradient(0deg, transparent 0 35px, rgba(15,23,42,.05) 36px), - linear-gradient(135deg, rgba(255,255,255,.74), rgba(var(--theme-primary-rgb, 58, 124, 165), .10)); - box-shadow: 0 34px 80px rgba(15,23,42,.08); - transform: rotate(-7deg); - pointer-events: none; +.login-visual, +.login-panel { + position: relative; + z-index: 1; } -.page-brand { - position: absolute; - top: 38px; - left: clamp(42px, 6vw, 86px); - z-index: 2; +.login-visual { + min-height: min(900px, calc(var(--desktop-stage-height, 100dvh) - 70px)); + display: grid; + grid-template-columns: minmax(360px, 0.96fr) minmax(420px, 1.04fr); + grid-template-rows: auto auto minmax(230px, 1fr) auto auto; + column-gap: clamp(16px, 2vw, 34px); + align-items: start; + animation: loginVisualIn 420ms cubic-bezier(0.16, 1, 0.3, 1) both; +} + +.visual-brand, +.visual-copy, +.visual-feature-list, +.visual-main-asset, +.visual-chart-asset, +.visual-footer { + position: relative; +} + +.visual-brand { + grid-column: 1 / 3; display: inline-flex; + width: fit-content; align-items: center; gap: 10px; - color: #111827; - font-size: 22px; - font-weight: 900; + color: #0f172a; + font-size: 18px; + font-weight: 800; + z-index: 3; } :deep(.logo-mark) { - width: 34px; - height: 34px; + width: 26px; + height: 26px; display: inline-grid; place-items: center; color: var(--theme-primary-active); } :deep(.logo-mark svg) { - width: 34px; - height: 34px; + width: 26px; + height: 26px; fill: currentColor; } -.hero { - position: relative; - z-index: 1; - align-self: stretch; - display: grid; - align-content: center; - justify-items: start; - padding-top: 40px; - transform: translateX(34px); +.visual-copy { + grid-column: 1 / 3; + grid-row: 2 / 3; + width: min(660px, 70%); + margin-top: 86px; + z-index: 3; } -.eyebrow-text { - color: var(--theme-primary-active); - font-size: 14px; +.visual-copy p, +.visual-copy h1, +.visual-copy span { + margin: 0; +} + +.visual-copy p { + color: #0f2f56; + font-size: 24px; + line-height: 1.25; font-weight: 900; - letter-spacing: .08em; - text-transform: uppercase; } -.hero h1 { - margin-top: 16px; - color: #0f172a; - font-size: clamp(38px, 3.8vw, 54px); - line-height: 1.12; - font-weight: 950; +.visual-copy h1 { + width: 100%; + margin-top: 12px; + color: #0f2f56; + font-size: clamp(29px, 2.2vw, 35px); + line-height: 1.28; + font-weight: 900; + letter-spacing: 0; } -.hero-lead { - margin-top: 14px; - color: #111827; - font-size: clamp(23px, 2.15vw, 31px); - font-weight: 800; -} - -.hero-sub { - margin-top: 14px; - color: #64748b; - font-size: 16px; - font-weight: 600; -} - -.hero-stage { - position: relative; - width: min(760px, 100%); - height: 350px; - margin-top: 22px; - margin-left: 0; -} - -.hero-stage::before { - content: ""; - position: absolute; - left: 285px; - bottom: 38px; - width: 230px; - height: 62px; - border-radius: 50%; - background: linear-gradient(90deg, rgba(var(--theme-primary-rgb, 58, 124, 165), .14), rgba(37,99,235,.08)); - filter: blur(4px); -} - -.flow-line { - position: absolute; - z-index: 0; +.visual-copy span { display: block; - border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), .22); - border-left: 0; - border-bottom: 0; - border-radius: 0 8px 0 0; + width: min(420px, 100%); + margin-top: 18px; + color: #475569; + font-size: 15px; + line-height: 1.85; } -.flow-line::after { - content: ""; - position: absolute; - right: -3px; - top: -4px; - width: 8px; - height: 8px; - border-radius: 999px; - background: var(--theme-primary); - box-shadow: 0 0 0 5px rgba(var(--theme-primary-rgb, 58, 124, 165), .12); -} - -.flow-a { - left: 190px; - top: 76px; - width: 170px; - height: 72px; -} - -.flow-b { - left: 190px; - bottom: 96px; - width: 142px; - height: 82px; - transform: scaleY(-1); -} - -.flow-c { - right: 182px; - top: 96px; - width: 132px; - height: 70px; - transform: scaleX(-1); -} - -.metric-card, -.document-card, -.round-badge { - position: absolute; - border: 1px solid rgba(215, 224, 234, .86); - background: rgba(255,255,255,.78); - box-shadow: 0 18px 36px rgba(65, 88, 110, .10); - backdrop-filter: blur(16px); -} - -.metric-card { - z-index: 2; - width: 166px; - min-height: 110px; +.visual-feature-list { + grid-column: 1 / 2; + grid-row: 3 / 4; display: grid; - gap: 7px; - padding: 17px 18px; - border-radius: 8px; + gap: 24px; + margin-top: 46px; + z-index: 3; } -.metric-card span { - color: #334155; - font-size: 13px; - font-weight: 800; +.visual-feature-list article { + display: grid; + grid-template-columns: 54px minmax(0, 1fr); + gap: 18px; + align-items: center; } -.metric-card strong { - color: #0f172a; +.visual-feature-icon { + width: 50px; + height: 50px; + display: grid; + place-items: center; + border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.16); + border-radius: 4px; + background: rgba(255, 255, 255, 0.62); + color: var(--theme-primary-active); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); +} + +.visual-feature-icon .el-icon { font-size: 25px; - line-height: 1; +} + +.visual-feature-list strong { + display: block; + color: #0f2f56; + font-size: 15px; + line-height: 1.45; font-weight: 900; } -.metric-card small { +.visual-feature-list p { + margin: 5px 0 0; color: #64748b; font-size: 12px; - font-weight: 700; + line-height: 1.65; } -.up { color: var(--success); } -.danger { color: #ef4444; } - -.amount { left: 20px; top: 20px; } -.risk { left: 42px; bottom: 24px; } -.audit { right: 22px; top: 24px; } -.sla { right: 40px; bottom: 20px; } - -.mini-bars { - height: 30px; - display: flex; - align-items: end; - gap: 6px; - margin-top: 2px; -} - -.mini-bars i { - width: 14px; - border-radius: 4px 4px 0 0; - background: linear-gradient(180deg, #93c5fd, #dbeafe); -} -.mini-bars i:nth-child(1) { height: 11px; } -.mini-bars i:nth-child(2) { height: 18px; } -.mini-bars i:nth-child(3) { height: 24px; } -.mini-bars i:nth-child(4) { height: 32px; } - -.document-card { - z-index: 1; - left: 286px; - top: 44px; - width: 220px; - height: 214px; - padding: 28px 28px; - border-radius: 8px; - transform: rotate(2deg); -} - -.document-card span { - color: #1e293b; - font-size: 18px; - font-weight: 900; -} - -.document-card > i { - display: block; - height: 10px; - margin-top: 22px; - border-radius: 999px; - background: #e4ebf5; -} -.document-card > i:nth-of-type(2) { width: 78%; margin-top: 16px; } -.document-card > i:nth-of-type(3) { width: 54%; margin-top: 16px; } - -.doc-check { - position: absolute; - right: -16px; - bottom: -12px; - width: 54px; - height: 54px; - display: grid; - place-items: center; - border-radius: 999px; - background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active)); - color: #fff; - font-size: 27px; - box-shadow: 0 14px 28px rgba(var(--theme-primary-rgb, 58, 124, 165), .22); -} - -.shield-art { - position: absolute; - z-index: 3; - left: 316px; - bottom: 0; - width: 155px; - height: 155px; +.visual-main-asset { + grid-column: 2 / 3; + grid-row: 2 / 5; + align-self: start; + width: min(570px, 118%); + justify-self: end; + margin-top: 62px; object-fit: contain; - filter: drop-shadow(0 22px 24px rgba(125, 91, 54, .16)); + filter: saturate(0.9) contrast(1.02); + z-index: 1; } -.round-badge { - z-index: 4; - width: 58px; - height: 58px; - display: grid; - place-items: center; - border-radius: 999px; - color: #3b82f6; - font-size: 24px; - font-weight: 950; +.visual-chart-asset { + grid-column: 1 / 3; + grid-row: 4 / 5; + align-self: end; + justify-self: start; + width: min(590px, 64%); + margin: 28px 0 30px; + object-fit: contain; + filter: saturate(0.9) contrast(1.02); + z-index: 2; } -.round-badge.ai { - left: 258px; - top: 30px; - width: 52px; - height: 52px; - color: #3b82f6; - font-size: 21px; - box-shadow: 0 14px 30px rgba(59,130,246,.14); +.login-visual::after { + content: ""; + position: absolute; + right: 0; + top: 42px; + width: min(560px, 55%); + height: 78%; + background: linear-gradient(135deg, rgba(var(--theme-primary-rgb, 58, 124, 165), 0.20), rgba(var(--theme-secondary-rgb, 79, 111, 159), 0.06)); + mix-blend-mode: color; + mask-image: radial-gradient(ellipse at 62% 50%, #000 0%, rgba(0, 0, 0, 0.76) 48%, transparent 78%); + opacity: 0.36; + z-index: 0; + pointer-events: none; } -.feature-strip { - width: min(760px, 100%); - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 18px; - margin-top: 18px; - margin-left: 0; -} - -.feature-strip article { - min-height: 78px; - display: grid; - grid-template-columns: 42px minmax(0, 1fr); +.visual-footer { + grid-column: 1 / 3; + grid-row: 5 / 6; + align-self: end; + display: inline-flex; align-items: center; - gap: 12px; - padding: 12px 14px; - border: 1px solid rgba(215, 224, 234, .82); - border-radius: 6px; - background: rgba(255,255,255,.76); - box-shadow: 0 12px 30px rgba(65, 88, 110, .08); - backdrop-filter: blur(16px); -} - -.feature-strip article > span { - width: 40px; - height: 40px; - display: grid; - place-items: center; - border-radius: 11px; - font-size: 21px; -} - -.feature-strip .primary { background: var(--theme-primary-soft); color: var(--theme-primary-active); } -.feature-strip .red { background: #fee2e2; color: #ef4444; } -.feature-strip .blue { background: #dbeafe; color: #3b82f6; } - -.feature-strip strong { - color: #0f172a; - font-size: 15px; - font-weight: 900; -} - -.feature-strip p { - display: block; - margin-top: 3px; + justify-content: center; + gap: 14px; color: #64748b; - font-size: 11.5px; - line-height: 1.45; + font-size: 12px; + z-index: 3; +} + +.visual-footer i { + width: 1px; + height: 14px; + background: #cbd5e1; +} + +.login-panel { + display: grid; + align-items: center; } .login-card { - position: relative; - z-index: 1; width: 100%; - max-width: 560px; - justify-self: center; + min-height: 748px; display: grid; - padding: 58px 60px 44px; - border: 1px solid rgba(215, 224, 234, .96); - border-radius: 8px; - background: rgba(255,255,255,.86); - box-shadow: 0 24px 64px rgba(65, 88, 110, .16); - backdrop-filter: blur(18px); + align-content: start; + justify-self: center; + padding: 56px 56px 38px; + border: 1px solid rgba(203, 213, 225, 0.88); + border-radius: 4px; + background: rgba(255, 255, 255, 0.94); + box-shadow: 0 24px 68px rgba(15, 23, 42, 0.10); + backdrop-filter: blur(16px); + animation: loginCardIn 420ms 80ms cubic-bezier(0.16, 1, 0.3, 1) both; } .card-brand { display: inline-flex; align-items: center; - justify-content: center; - gap: 12px; + gap: 10px; color: #0f172a; - font-size: 22px; - font-weight: 950; + font-size: 15px; + font-weight: 800; +} + +.card-brand :deep(.logo-mark), +.card-brand :deep(.logo-mark svg) { + width: 22px; + height: 22px; } .card-head { - margin-top: 24px; - text-align: center; + margin-top: 34px; } .card-head h2 { + margin: 0; color: #0f172a; - font-size: 34px; - line-height: 1.15; - font-weight: 950; + font-size: 32px; + line-height: 1.18; + font-weight: 900; + letter-spacing: 0; } .card-head p { - margin-top: 12px; - color: #64748b; + margin: 10px 0 0; + color: #475569; font-size: 16px; + line-height: 1.5; } .login-form { display: grid; - gap: 16px; - margin-top: 30px; + gap: 18px; + margin-top: 32px; } -.field { - position: relative; - display: flex; - align-items: center; - min-height: 52px; +.form-field { + display: grid; } -.field > .mdi { - position: absolute; - left: 16px; - color: #64748b; - font-size: 19px; -} - -.field input, -.field select { +.login-input, +.login-select { width: 100%; - height: 52px; - padding: 0 50px 0 48px; - border: 1px solid #d7e0ea; - border-radius: 8px; - background: rgba(255,255,255,.86); +} + +:deep(.login-input .el-input__wrapper), +:deep(.login-select .el-select__wrapper) { + min-height: 50px; + border-radius: 4px; + background: rgba(255, 255, 255, 0.96); +} + +:deep(.login-input .el-input__inner), +:deep(.login-select .el-select__selected-item) { color: #0f172a; - font-size: 15px; - transition: border-color 160ms ease, box-shadow 160ms ease, background 160ms ease; + font-size: 14px; + font-weight: 500; } -.field select { - appearance: none; - cursor: pointer; -} - -.field input::placeholder { +:deep(.login-input .el-input__inner::placeholder) { color: #94a3b8; } -.field input:focus, -.field select:focus { - border-color: var(--theme-primary); - background: #fff; - box-shadow: 0 0 0 3px var(--theme-focus-ring, rgba(58, 124, 165, .14)); - outline: none; -} - -.field-select-chevron { - position: absolute; - right: 12px; - width: 34px; - height: 34px; - display: grid; - place-items: center; - border-radius: 8px; - color: #64748b; - pointer-events: none; -} - -.field-icon-btn { - position: absolute; - right: 12px; - width: 34px; - height: 34px; - display: grid; - place-items: center; - border: 0; - border-radius: 8px; - background: transparent; - color: #64748b; -} - -.field-icon-btn:hover { - background: #f1f5f9; - color: var(--theme-primary-active); +:deep(.login-input .el-input__prefix), +:deep(.login-input .el-input__suffix), +:deep(.login-select .el-select__suffix) { + color: #94a3b8; } .form-meta { @@ -498,119 +319,76 @@ align-items: center; justify-content: space-between; gap: 16px; - margin-top: 2px; + min-height: 28px; +} + +:deep(.login-checkbox .el-checkbox__label) { + color: #334155; + font-size: 13px; + font-weight: 600; +} + +.link-button { + min-height: 28px; + padding: 0; + border: 0; + background: transparent; + color: var(--theme-primary-active); + font-size: 13px; + font-weight: 700; + cursor: pointer; +} + +.link-button:hover { + color: var(--theme-primary-hover); } .login-error { - padding: 12px 14px; - border: 1px solid rgba(239, 68, 68, .18); - border-radius: 8px; - background: #fef2f2; - color: #b91c1c; + margin: 0; + padding: 10px 12px; + border: 1px solid rgba(var(--danger-rgb, 220, 38, 38), 0.22); + border-radius: 4px; + background: var(--danger-soft, #fef2f2); + color: var(--danger-active, #991b1b); font-size: 13px; line-height: 1.55; } -.remember { - display: inline-flex; - align-items: center; - gap: 8px; - color: #334155; - font-size: 14px; +.login-submit, +.login-sso { + width: 100%; + min-height: 50px; + border-radius: 4px; + font-weight: 800; } -.remember input { - width: 16px; - height: 16px; - accent-color: var(--theme-primary); -} - -.link-btn { - border: 0; - background: transparent; - color: #2563eb; - font-size: 14px; - font-weight: 700; -} - -.submit-btn, -.sso-btn { - height: 52px; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 10px; - border-radius: 8px; - font-size: 17px; - font-weight: 900; -} - -.submit-btn { +.login-submit { margin-top: 4px; - border: 0; - background: linear-gradient(135deg, var(--theme-primary), var(--theme-primary-active)); - color: #fff; - box-shadow: 0 16px 30px rgba(var(--theme-primary-rgb, 58, 124, 165), .20); + box-shadow: 0 14px 28px rgba(var(--theme-primary-rgb, 58, 124, 165), 0.20); } -.submit-btn:hover { - background: linear-gradient(135deg, var(--theme-primary-hover), var(--theme-primary-active)); -} - -.submit-btn:disabled, -.sso-btn:disabled { - opacity: .6; - cursor: not-allowed; - box-shadow: none; -} - -.divider { - position: relative; - display: grid; - place-items: center; - height: 28px; - color: #94a3b8; - font-size: 13px; -} - -.divider::before { - content: ""; - position: absolute; - left: 0; - right: 0; - top: 50%; - height: 1px; - background: #e2e8f0; -} - -.divider span { - position: relative; - padding: 0 16px; - background: rgba(255,255,255,.9); -} - -.sso-btn { - border: 1px solid var(--theme-primary); - background: rgba(255,255,255,.78); +.login-sso { + margin-left: 0; color: var(--theme-primary-active); } -.sso-btn:hover { - background: var(--theme-primary-soft); -} - .security-note { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - margin-top: 34px; + justify-self: center; + margin-top: 42px; color: #64748b; - font-size: 13px; + font-size: 12px; + line-height: 1.7; + text-align: center; } -.security-note .mdi { - color: #94a3b8; +.security-note button { + padding: 0; + border: 0; + background: transparent; + color: var(--theme-primary-active); + font: inherit; + font-weight: 700; + cursor: pointer; } .sr-only { @@ -625,46 +403,182 @@ border: 0; } -@media (max-width: 1280px) { - .login-page { - grid-template-columns: minmax(520px, 1fr) minmax(480px, 540px); - gap: 44px; - padding-inline: 48px; +@keyframes loginVisualIn { + from { + opacity: 0; + transform: translateX(-18px); } - .hero-stage { - transform: scale(.88); - transform-origin: left center; - margin-bottom: -32px; + to { + opacity: 1; + transform: translateX(0); } - - .feature-strip { - width: 520px; - gap: 14px; - margin-left: 0; - } - - .login-card { max-width: 500px; } } -@media (max-height: 840px) and (min-width: 981px) { - .hero { - padding-top: 18px; +@keyframes loginCardIn { + from { + opacity: 0; + transform: scale3d(0.985, 0.985, 1) translateY(12px); } - .hero-stage { - margin-top: 16px; - transform: scale(.9); - transform-origin: left center; - margin-bottom: -22px; + to { + opacity: 1; + transform: scale3d(1, 1, 1) translateY(0); + } +} + +@media (min-width: 1321px) { + .login-page { + --login-edge: clamp(42px, 3.6vw, 56px); + --login-card-width: 535px; + --login-card-left: min(calc(100vw - var(--login-card-width) - var(--login-edge)), 1002px); + --login-visual-top: 56px; + --login-visual-width: calc(var(--login-card-left) - var(--login-edge) - 24px); + display: block; + padding: 0; + } + + .login-visual { + position: absolute; + left: var(--login-edge); + top: var(--login-visual-top); + width: var(--login-visual-width); + height: calc(var(--desktop-stage-height, 100dvh) - 86px); + min-height: 0; + display: block; + } + + .login-panel { + position: absolute; + left: var(--login-card-left); + top: clamp(96px, 11.3vh, 112px); + width: var(--login-card-width); + display: block; } .login-card { + width: 100%; + height: min(748px, calc(var(--desktop-stage-height, 100dvh) - 80px)); + min-height: 0; + } + + .visual-brand { + position: absolute; + left: 0; + top: 0; + } + + .visual-copy { + position: absolute; + left: 0; + top: 120px; + width: min(660px, 72%); + margin-top: 0; + } + + .visual-feature-list { + position: absolute; + left: 0; + top: 312px; + width: 380px; + margin-top: 0; + } + + .visual-main-asset { + position: absolute; + left: 392px; + top: 86px; + width: min(585px, calc(var(--login-visual-width) - 360px)); + margin-top: 0; + } + + .visual-chart-asset { + position: absolute; + left: 0; + top: 619px; + width: min(585px, 66%); + margin: 0; + } + + .login-visual::after { + right: -4px; + top: 84px; + width: min(620px, 58%); + height: 70%; + } + + .visual-footer { + position: absolute; + left: 0; + right: 0; + bottom: 14px; + } +} + +@media (max-width: 1320px) { + .login-page { + grid-template-columns: minmax(640px, 1fr) minmax(410px, 500px); + gap: 28px; + padding-inline: 38px; + } + + .login-visual { + grid-template-columns: minmax(320px, 0.92fr) minmax(380px, 1.08fr); + } + + .visual-copy { + margin-top: 72px; + } + + .visual-main-asset { + width: min(540px, 116%); + } + + .visual-chart-asset { + width: min(560px, 68%); + } + + .login-card { + min-height: 600px; + padding: 44px 44px 30px; + } +} + +@media (max-height: 820px) and (min-width: 981px) { + .login-page { + padding-block: 28px 22px; + } + + .login-visual { + min-height: 620px; + } + + .visual-copy { + margin-top: 58px; + } + + .visual-feature-list { + gap: 18px; + margin-top: 34px; + } + + .visual-main-asset { + width: min(500px, 108%); + margin-top: 46px; + } + + .visual-chart-asset { + width: min(540px, 66%); + margin-block: 18px 18px; + } + + .login-card { + min-height: 560px; padding-block: 38px 28px; } .card-head { - margin-top: 18px; + margin-top: 24px; } .login-form { @@ -673,7 +587,7 @@ } .security-note { - margin-top: 24px; + margin-top: 28px; } } @@ -681,43 +595,169 @@ .login-page { min-height: var(--desktop-stage-height, 100dvh); grid-template-columns: 1fr; - padding: 92px 28px 28px; - overflow: auto; + align-content: start; + gap: 18px; + padding: 18px 22px 24px; + overflow-x: hidden; + overflow-y: auto; } - .page-brand { - top: 24px; - left: 24px; + .login-visual { + min-height: 0; + grid-template-columns: 1fr; + grid-template-rows: auto auto auto auto auto auto; + row-gap: 18px; } - .hero { + .visual-brand, + .visual-copy, + .visual-feature-list, + .visual-main-asset, + .visual-chart-asset, + .visual-footer { + grid-column: 1; + } + + .visual-brand { + grid-row: 1; + } + + .visual-copy { + grid-row: 2; + width: 100%; + margin-top: 12px; + } + + .visual-main-asset { + grid-row: 3; + width: min(520px, 100%); + margin-top: 0; + justify-self: center; + } + + .visual-feature-list { + grid-row: 4; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; + margin-top: 0; + } + + .visual-feature-list article { + grid-template-columns: 34px minmax(0, 1fr); + gap: 9px; + padding: 10px; + border: 1px solid rgba(203, 213, 225, 0.62); + border-radius: 4px; + background: rgba(255, 255, 255, 0.66); + } + + .visual-feature-icon { + width: 32px; + height: 32px; + } + + .visual-feature-icon .el-icon { + font-size: 18px; + } + + .visual-feature-list p { display: none; - transform: none; + } + + .visual-chart-asset { + grid-row: 5; + width: min(520px, 100%); + margin: 0; + justify-self: center; + } + + .visual-footer { + grid-row: 6; + } + + .login-visual::after { + top: auto; + bottom: 86px; + width: min(520px, 100%); + height: 42%; } .login-card { max-width: 520px; - padding: 40px 28px 30px; - } -} - -@media (max-width: 520px) { - .login-page { - padding-inline: 22px; + min-height: 0; + padding: 30px 24px 26px; } - .login-card { - padding: 32px 22px 24px; - border-radius: 8px; + .card-brand { + display: none; + } + + .card-head { + margin-top: 0; } .card-head h2 { - font-size: 30px; + font-size: 28px; + } +} + +@media (max-width: 620px) { + .login-page { + padding-inline: 16px; + } + + .visual-copy h1 { + font-size: 27px; + } + + .visual-feature-list { + grid-template-columns: 1fr; + } + + .visual-feature-list p { + display: block; + } + + .login-card { + padding: 24px 18px 20px; + } + + .login-form { + gap: 13px; + margin-top: 22px; + } + + :deep(.login-input .el-input__wrapper), + :deep(.login-select .el-select__wrapper), + .login-submit, + .login-sso { + min-height: 44px; + } +} + +@media (max-width: 420px) { + .visual-copy p { + font-size: 21px; + } + + .visual-copy h1 { + font-size: 24px; + } + + .card-head h2 { + font-size: 25px; } .form-meta { align-items: flex-start; flex-direction: column; - gap: 10px; + gap: 8px; + } +} + +@media (prefers-reduced-motion: reduce) { + .login-visual, + .login-card { + animation: none; } } diff --git a/web/src/assets/styles/views/policies-view.css b/web/src/assets/styles/views/policies-view.css index 42fd40c..c5a9aa6 100644 --- a/web/src/assets/styles/views/policies-view.css +++ b/web/src/assets/styles/views/policies-view.css @@ -1163,6 +1163,46 @@ th { } @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, .preview-head, .llm-wiki-section-head, @@ -1177,10 +1217,134 @@ th { width: 100%; } - .summary-grid, - .list-foot { - grid-template-columns: 1fr; - } + .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, + .list-foot { + grid-template-columns: 1fr; + } .list-foot { gap: 12px; diff --git a/web/src/assets/styles/views/receipt-folder-view.css b/web/src/assets/styles/views/receipt-folder-view.css index e69caf7..4043dca 100644 --- a/web/src/assets/styles/views/receipt-folder-view.css +++ b/web/src/assets/styles/views/receipt-folder-view.css @@ -676,10 +676,139 @@ } @media (max-width: 760px) { + .receipt-folder-page { + height: auto; + min-height: 100%; + display: block; + overflow: visible; + } + .receipt-folder-list { + height: auto; + min-height: 100%; + display: block; + overflow: visible; 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-toolbar-actions, .receipt-preview-tools { diff --git a/web/src/assets/styles/views/settings-view.css b/web/src/assets/styles/views/settings-view.css index eefa4b1..30264d2 100644 --- a/web/src/assets/styles/views/settings-view.css +++ b/web/src/assets/styles/views/settings-view.css @@ -804,6 +804,10 @@ } @media (max-width: 640px) { + .settings-shell { + overflow: hidden; + } + .settings-toolbar { padding: 16px; } @@ -824,4 +828,26 @@ .settings-nav { 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; + } } diff --git a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css index c38748d..dffd4ba 100644 --- a/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css +++ b/web/src/assets/styles/views/travel-reimbursement-create-view-part4.css @@ -317,41 +317,75 @@ } @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-viewport-inset: 10px; + --assistant-viewport-inset: 0; } :global(.assistant-el-overlay) { - --assistant-viewport-inset: 10px; + --assistant-viewport-inset: 0; } .assistant-modal, .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 { - padding: 18px 18px 16px; - align-items: flex-start; - flex-direction: column; + min-height: 58px; + padding: calc(10px + env(safe-area-inset-top, 0px)) 138px 10px 12px; + align-items: center; + flex-direction: row; + border-bottom: 1px solid #e5edf5; } .assistant-header-actions { - top: 18px; - right: 18px; - gap: 10px; + top: calc(9px + env(safe-area-inset-top, 0px)); + right: 10px; + gap: 6px; width: auto; - justify-content: space-between; + justify-content: flex-end; } .assistant-toggle-btn, .session-trash-btn, .assistant-close-btn, .close-btn { - width: 40px; - height: 40px; + width: 38px; + height: 38px; border-radius: 4px; + font-size: 15px; + } + + .assistant-title { font-size: 16px; + line-height: 1.25; + } + + .assistant-subtitle { + display: none; } .flow-step-card header { @@ -359,16 +393,87 @@ } .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 { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--composer-control-size, 40px); + align-items: end; gap: 8px; --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 { - min-height: 32px; + flex-basis: 100%; + min-height: 40px; + max-height: 104px; + padding: 8px 2px; + line-height: 20px; } .travel-calculator-form { @@ -376,20 +481,87 @@ } .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 { width: 100%; + min-width: 0; justify-content: center; } + .shortcut-chip span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .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 { - 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, diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index 891c34c..aa29943 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -769,6 +769,15 @@ 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 { position: relative; display: grid; diff --git a/web/src/components/audit/AuditAssetList.vue b/web/src/components/audit/AuditAssetList.vue index 6230321..b684192 100644 --- a/web/src/components/audit/AuditAssetList.vue +++ b/web/src/components/audit/AuditAssetList.vue @@ -194,7 +194,7 @@ :class="{ 'is-disabled': skill.usesJsonRiskRule && skill.statusValue === 'generating' }" @click="emit('open-asset-detail', skill)" > - +
{{ skill.short }}
@@ -203,8 +203,8 @@
- {{ skill.category }} - + {{ skill.category }} + - {{ skill.scope }} - {{ skill.model }} - {{ skill.versionDisplay || skill.version }} - + {{ skill.scope }} + {{ skill.model }} + {{ skill.versionDisplay || skill.version }} + {{ skill.status }} - {{ skill.hitRate }} - + {{ skill.hitRate }} + {{ skill.isOnlineLabel }} - + {{ skill.isEnabledLabel }} - {{ skill.updatedAt }} + {{ skill.updatedAt }} diff --git a/web/src/components/audit/DigitalEmployeeListPanel.vue b/web/src/components/audit/DigitalEmployeeListPanel.vue index 2a9b5cc..8ff6e81 100644 --- a/web/src/components/audit/DigitalEmployeeListPanel.vue +++ b/web/src/components/audit/DigitalEmployeeListPanel.vue @@ -156,15 +156,15 @@ {{ employee.name }} - {{ employee.skillCategory }} - {{ employee.owner }} - {{ employee.scope }} - {{ employee.executionMode }} - + {{ employee.skillCategory }} + {{ employee.owner }} + {{ employee.scope }} + {{ employee.executionMode }} + {{ employee.status }} - {{ employee.enabledLabel }} - {{ employee.updatedAt || '-' }} + {{ employee.enabledLabel }} + {{ employee.updatedAt || '-' }} @@ -292,4 +292,21 @@ function changePageSize(size) { 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; + } +} diff --git a/web/src/components/business/PersonalWorkbench.vue b/web/src/components/business/PersonalWorkbench.vue index d67c84b..a86564d 100644 --- a/web/src/components/business/PersonalWorkbench.vue +++ b/web/src/components/business/PersonalWorkbench.vue @@ -7,7 +7,7 @@ note="把费用申请、报销进度、制度问答和待办处理集中到一个入口。" /> -
+

嗨,{{ displayUserName }},我是您的 AI 费用助手

@@ -358,16 +358,17 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import PanelHead from '../shared/PanelHead.vue' import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.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 { useToast } from '../../composables/useToast.js' import { useWorkbenchComposerDate } from '../../composables/useWorkbenchComposerDate.js' import { - assistantCapabilities, buildExpenseStatItems, + filterAssistantCapabilitiesForUser, progressItems, progressSteps, quickPromptItems, + resolveWorkbenchCapabilityGridClass, todoItems, } from '../../data/personalWorkbench.js' import { fetchAgentRuns } from '../../services/agentAssets.js' @@ -433,9 +434,6 @@ let employeeProfileLoadSeq = 0 const MAX_ATTACHMENTS = 10 const SESSION_TYPE_EXPENSE = 'expense' 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(() => Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId) @@ -456,28 +454,8 @@ const composerPendingLabel = computed(() => { } return '' }) -const currentRoleCodes = computed(() => { - const user = 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 visibleAssistantCapabilities = computed(() => filterAssistantCapabilitiesForUser(currentUser.value)) +const capabilityGridClass = computed(() => resolveWorkbenchCapabilityGridClass(currentUser.value)) const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary)) const visibleExpenseStatItems = computed(() => { const preferredKeys = ['monthly-amount', 'monthly-count', 'in-review', 'pending-payment'] @@ -817,6 +795,7 @@ watch(currentUserProfileKey, (nextKey, previousKey) => { + diff --git a/web/src/components/travel/BudgetAssistantReport.vue b/web/src/components/travel/BudgetAssistantReport.vue index c37a7a8..dac050f 100644 --- a/web/src/components/travel/BudgetAssistantReport.vue +++ b/web/src/components/travel/BudgetAssistantReport.vue @@ -28,7 +28,7 @@
- 上季度费用结构 + {{ expenseStructureTitle }} {{ report.centerLabel }}
费用类型拆解 - 用于编制下一季度预算 + 用于编制{{ report.periodType || '下一期预算' }}
预算构成编辑 - {{ report.periodType || '预算' }} · 可直接调整 + {{ editorSubtitle }}
-
+
费用类型 - 编制金额 - 提醒 - 告警 - 风险 + 预算金额 + 建议预算 预算说明 + 建议
{{ row.name }} -