16 Commits

Author SHA1 Message Date
caoxiaozhu
75d5c178e1 feat(workbench): persist topbar notification state 2026-06-03 21:43:35 +08:00
caoxiaozhu
b9826a1985 fix: keep adjusted risks visible to reviewers 2026-06-03 19:14:40 +08:00
caoxiaozhu
0f8bc4071a fix: preserve reviewer risk notice after standard adjustment 2026-06-03 19:10:29 +08:00
caoxiaozhu
cb36d78fa2 fix: 优化顶部导航栏布局与工作台摘要展示并清理旧票据数据 2026-06-03 17:40:52 +08:00
caoxiaozhu
8e2477587f fix: handle risk explanation standard adjustment 2026-06-03 17:31:40 +08:00
caoxiaozhu
67b81a1bd8 fix(workbench): replay profile radar animation 2026-06-03 17:31:12 +08:00
caoxiaozhu
9c24a852e7 fix(workbench): remount expense stats chart on reopen 2026-06-03 17:22:48 +08:00
caoxiaozhu
95956afbc6 fix(notifications): refine bell notification center 2026-06-03 17:16:09 +08:00
caoxiaozhu
c73178b65d fix(documents): move unread notice into bell 2026-06-03 17:05:34 +08:00
caoxiaozhu
8c2f301d85 fix(documents): sort newest rows first 2026-06-03 16:52:49 +08:00
caoxiaozhu
4717ee6086 fix(documents): refine unread badges and mark all read 2026-06-03 16:46:13 +08:00
caoxiaozhu
513ff909f9 fix: remove manual expense detail add action 2026-06-03 16:44:06 +08:00
caoxiaozhu
92198549f6 fix: require explicit transport mode for applications 2026-06-03 16:36:02 +08:00
caoxiaozhu
59d3bf0f00 fix(auth): keep admin out of personal workbench 2026-06-03 16:31:27 +08:00
caoxiaozhu
04f0951b3d fix: restrict application linking for reimbursement drafts 2026-06-03 16:28:09 +08:00
caoxiaozhu
8887cf5a27 fix(workbench): stretch profile tag card 2026-06-03 15:53:30 +08:00
102 changed files with 4045 additions and 1716 deletions

View File

@@ -0,0 +1,111 @@
# 通知中心状态持久化概念文档
## 功能一句话
为首页小铃铛通知中心补齐服务端状态接口,让同一用户在不同电脑登录时看到一致的已读、清空和隐藏状态,并优化笔记本等小屏幕下的通知弹窗可读性。
## 背景与问题
当前小铃铛通知由前端从单据中心、个人工作台摘要等数据源即时生成,但已读与清空状态主要写入浏览器 `localStorage`。这会导致同一账号在 A 电脑清空通知后,换到 B 电脑仍然看到通知。
同时,通知条数较多或屏幕高度较小时,列表内容容易挤压头部操作区,通知标题与描述也容易在窄宽度下互相挤压。
## 目标与非目标
目标:
- 提供当前用户维度的通知状态接口。
- 支持批量同步通知状态,至少覆盖已读与隐藏。
- 前端优先使用服务端状态,接口不可用时保留本地降级能力。
- 优化小屏幕通知弹窗,列表多时使用内部滚动,标题、描述与操作按钮不互相挤压。
非目标:
- 不做独立消息投递系统。
- 不新增推送、WebSocket 或邮件通知能力。
- 不改变通知来源生成逻辑,当前仍由单据中心和工作台摘要生成。
## 用户与场景
- 普通员工:在个人工作台查看待办、单据新消息,跨电脑登录后已读状态一致。
- 审批人:处理待审批单据后,通知中心不因换电脑重新显示已清空内容。
- 管理员:仍可看到系统内已有通知入口,但 admin 是否展示工作台由现有逻辑决定。
## 功能能力
- `GET /notification-states`:读取当前登录用户的通知状态集合。
- `POST /notification-states`:批量保存当前登录用户的通知状态。
- 状态字段:
- `notification_id`:前端生成的稳定通知 ID。
- `read_at`:已读时间。
- `hidden_at`:隐藏或清空时间。
- `context_json`:保留通知来源、类型等低风险上下文,便于排查。
- 前端能力:
- 打开工作台或弹窗时读取服务端状态。
- 点击通知写入已读。
- 清空通知写入隐藏。
- 接口失败时仍写入本地缓存,避免用户操作失效。
## 方案设计
后端:
- 新增 `NotificationState` SQLAlchemy 模型。
- 新增 `NotificationStateService`,负责按 `CurrentUserContext.username` 读写状态。
- 新增 `notification_states` endpoint并挂到 API v1 router。
- 服务初始化时使用项目现有 `Base.metadata.create_all(..., tables=[...])` 模式确保表存在。
前端:
- 新增 `web/src/services/notificationStates.js` 封装接口。
- `TopBar.vue``localStorage` 状态作为初始兜底,服务端状态返回后合并覆盖。
- `markNotificationRead``clearAllNotifications` 做乐观更新,再异步同步服务端。
- 对单据通知仍调用现有 `markDocumentInboxRowRead`,同时写入通知状态接口,保证跨设备一致。
小屏幕布局:
- 弹窗宽度使用 `clamp``100vw` 约束。
- 弹窗最大高度使用 `min(..., calc(100vh - ...))`
- 列表作为唯一滚动区域,头部和 tab 固定在弹窗网格内。
- 通知描述允许两行截断,避免窄屏时横向挤压。
## 算法与公式
当前功能不涉及显式数学公式。状态合并规则为:
$$
visible = notification\_id \notin hiddenIds
$$
$$
unread = sourceUnread \land notification\_id \notin readIds \land notification\_id \notin hiddenIds
$$
服务端状态优先,前端本地状态仅作为接口失败或首次加载前的兜底。
## 测试方案
- 后端单元测试:
- 当前用户只能读取自己的通知状态。
- 批量 upsert 后可读取 `read_at``hidden_at`
- 清空通知写入 hidden 状态。
- 前端静态测试:
- `TopBar` 引用通知状态服务。
- 已读、清空操作会同步服务端。
- 小屏 CSS 使用弹窗 max-height、内部滚动和移动端约束。
- 构建验证:
- 运行前端构建确认 Vue 与服务导入无误。
- 在容器内运行后端定向 pytest。
## 指标与验收
- 同一用户跨电脑登录后,已读和清空状态由服务端保持一致。
- 接口失败时用户仍可本地清空,不阻断主要流程。
- 通知弹窗在笔记本高度下不会挤压头部按钮,列表内部滚动。
- 通知标题、描述、时间在窄屏下不横向溢出。
## 风险与开放问题
- 当前通知本身仍由前端即时生成,服务端只保存状态,不保存完整通知正文。
- 通知 ID 需要保持稳定,否则服务端状态无法命中;本次沿用现有 `document:``workbench:` 前缀。
- 历史 localStorage 状态会作为首次迁移兜底,后续服务端会逐步成为主状态源。

View File

@@ -0,0 +1,10 @@
# 通知中心状态持久化 TODO
- [x] 调研现有小铃铛通知来源、localStorage 键和单据中心已读逻辑。[CONCEPT: 背景与问题] 证据:`TopBar.vue``useDocumentCenterInbox.js``documentCenterNewState.js` 已确认。
- [x] 新增后端通知状态模型、Schema、Service 与 API endpoint。[CONCEPT: 方案设计] 证据:`notification_states` 支持按用户保存已读与隐藏状态。
- [x] 将通知状态接口挂载到 API v1 router并保持当前用户隔离。[CONCEPT: 功能能力] 证据:`GET /notification-states``POST /notification-states` 已接入。
- [x] 新增前端 `notificationStates` 服务封装读取与批量保存。[CONCEPT: 前端] 证据:服务层统一请求 `/notification-states`
- [x] 改造 `TopBar` 已读、清空逻辑,优先同步服务端,保留本地降级。[CONCEPT: 前端] 证据:小铃铛点击已读、清空通知都会写入状态接口。
- [x] 优化通知弹窗笔记本与窄屏布局,避免条数多时挤压。[CONCEPT: 小屏幕布局] 证据弹窗限制视口高度列表滚动描述两行截断420px 下隐藏行箭头。
- [x] 补充后端和前端回归测试。[CONCEPT: 测试方案] 证据:`server/tests/test_notification_states.py``web/tests/sidebar-document-unread-dot.test.mjs`
- [x] 运行容器后端定向 pytest、前端 Node 测试与前端 build。[CONCEPT: 指标与验收] 证据pytest 2 passedNode 4 passedVite build passed。

View File

@@ -0,0 +1,38 @@
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext, get_current_user, get_db
from app.schemas.notification_state import NotificationStateBatchPatch, NotificationStateListRead
from app.services.notification_states import NotificationStateService
router = APIRouter(prefix="/notification-states")
DbSession = Annotated[Session, Depends(get_db)]
CurrentUser = Annotated[CurrentUserContext, Depends(get_current_user)]
@router.get(
"",
response_model=NotificationStateListRead,
summary="读取当前用户通知状态",
description="读取当前登录用户的小铃铛通知已读和隐藏状态,用于跨设备保持一致。",
)
def list_notification_states(db: DbSession, current_user: CurrentUser) -> NotificationStateListRead:
return NotificationStateService(db).list_states(current_user)
@router.post(
"",
response_model=NotificationStateListRead,
summary="批量保存当前用户通知状态",
description="批量保存当前登录用户的小铃铛通知已读和隐藏状态。",
)
def patch_notification_states(
payload: NotificationStateBatchPatch,
db: DbSession,
current_user: CurrentUser,
) -> NotificationStateListRead:
return NotificationStateService(db).patch_states(payload, current_user)

View File

@@ -20,6 +20,7 @@ from app.schemas.reimbursement import (
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimRead, ExpenseClaimRead,
ExpenseClaimReturnPayload, ExpenseClaimReturnPayload,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate, ExpenseClaimUpdate,
ReimbursementCreate, ReimbursementCreate,
ReimbursementRead, ReimbursementRead,
@@ -233,6 +234,43 @@ def update_expense_claim(
return claim return claim
@router.post(
"/claims/{claim_id}/standard-adjustment",
response_model=ExpenseClaimRead,
summary="接受职级报销标准重算",
description="在草稿报销单存在中高风险但提交人不补充异常说明时,按职级可报销标准重算实际报销金额。",
responses={
status.HTTP_404_NOT_FOUND: {
"model": ErrorResponse,
"description": "报销单不存在。",
},
status.HTTP_400_BAD_REQUEST: {
"model": ErrorResponse,
"description": "报销单状态不允许重算或入参不合法。",
},
},
)
def accept_expense_claim_standard_adjustment(
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
db: DbSession,
current_user: CurrentUser,
) -> ExpenseClaimRead:
service = ExpenseClaimService(db)
try:
claim = service.accept_standard_adjustment(
claim_id=claim_id,
payload=payload,
current_user=current_user,
)
except ValueError as error:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(error)) from error
if claim is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Claim not found")
return claim
@router.patch( @router.patch(
"/claims/{claim_id}/items/{item_id}", "/claims/{claim_id}/items/{item_id}",
response_model=ExpenseClaimRead, response_model=ExpenseClaimRead,

View File

@@ -14,6 +14,7 @@ from app.api.v1.endpoints.employees import router as employees_router
from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router from app.api.v1.endpoints.employee_profiles import router as employee_profiles_router
from app.api.v1.endpoints.health import router as health_router from app.api.v1.endpoints.health import router as health_router
from app.api.v1.endpoints.knowledge import router as knowledge_router from app.api.v1.endpoints.knowledge import router as knowledge_router
from app.api.v1.endpoints.notification_states import router as notification_states_router
from app.api.v1.endpoints.ocr import router as ocr_router from app.api.v1.endpoints.ocr import router as ocr_router
from app.api.v1.endpoints.ontology import router as ontology_router from app.api.v1.endpoints.ontology import router as ontology_router
from app.api.v1.endpoints.orchestrator import router as orchestrator_router from app.api.v1.endpoints.orchestrator import router as orchestrator_router
@@ -36,6 +37,7 @@ router.include_router(agent_traces_router, tags=["agent-traces"])
router.include_router(analytics_router, tags=["analytics"]) router.include_router(analytics_router, tags=["analytics"])
router.include_router(audit_logs_router, tags=["audit-logs"]) router.include_router(audit_logs_router, tags=["audit-logs"])
router.include_router(knowledge_router, tags=["knowledge"]) router.include_router(knowledge_router, tags=["knowledge"])
router.include_router(notification_states_router, tags=["notification-states"])
router.include_router(ocr_router, tags=["ocr"]) router.include_router(ocr_router, tags=["ocr"])
router.include_router(ontology_router, tags=["ontology"]) router.include_router(ontology_router, tags=["ontology"])
router.include_router(orchestrator_router, tags=["orchestrator"]) router.include_router(orchestrator_router, tags=["orchestrator"])

View File

@@ -23,6 +23,7 @@ from app.models.financial_record import (
) )
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.hermes_report import HermesRiskReport from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest from app.models.reimbursement import ReimbursementRequest
from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.risk_observation import RiskObservation, RiskObservationFeedback
@@ -60,6 +61,7 @@ __all__ = [
"HermesTaskConfig", "HermesTaskConfig",
"HermesTaskExecutionLog", "HermesTaskExecutionLog",
"HermesRiskReport", "HermesRiskReport",
"NotificationState",
"OrganizationUnit", "OrganizationUnit",
"ReimbursementRequest", "ReimbursementRequest",
"RiskObservation", "RiskObservation",

View File

@@ -16,6 +16,7 @@ from app.models.financial_record import (
) )
from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog from app.models.hermes_config import HermesTaskConfig, HermesTaskExecutionLog
from app.models.hermes_report import HermesRiskReport from app.models.hermes_report import HermesRiskReport
from app.models.notification_state import NotificationState
from app.models.organization import OrganizationUnit from app.models.organization import OrganizationUnit
from app.models.reimbursement import ReimbursementRequest from app.models.reimbursement import ReimbursementRequest
from app.models.risk_observation import RiskObservation, RiskObservationFeedback from app.models.risk_observation import RiskObservation, RiskObservationFeedback
@@ -51,6 +52,7 @@ __all__ = [
"HermesTaskConfig", "HermesTaskConfig",
"HermesTaskExecutionLog", "HermesTaskExecutionLog",
"HermesRiskReport", "HermesRiskReport",
"NotificationState",
"OrganizationUnit", "OrganizationUnit",
"ReimbursementRequest", "ReimbursementRequest",
"RiskObservation", "RiskObservation",

View File

@@ -0,0 +1,32 @@
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any
from sqlalchemy import DateTime, Index, String, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.db.base_class import Base
class NotificationState(Base):
__tablename__ = "notification_states"
__table_args__ = (
UniqueConstraint("user_id", "notification_id", name="uq_notification_states_user_notification"),
Index("ix_notification_states_user_updated", "user_id", "updated_at"),
)
id: Mapped[str] = mapped_column(String(36), primary_key=True, default=lambda: str(uuid.uuid4()))
user_id: Mapped[str] = mapped_column(String(100), index=True)
notification_id: Mapped[str] = mapped_column(String(180), index=True)
read_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
hidden_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
context_json: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
)

View File

@@ -0,0 +1,45 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
def _normalize_text(value: Any) -> str:
return str(value or "").strip()
class NotificationStatePatch(BaseModel):
notification_id: str = Field(min_length=1, max_length=180)
read: bool = False
hidden: bool = False
context_json: dict[str, Any] = Field(default_factory=dict)
@field_validator("notification_id", mode="before")
@classmethod
def normalize_notification_id(cls, value: Any) -> str:
return _normalize_text(value)
@field_validator("context_json", mode="before")
@classmethod
def normalize_context(cls, value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
class NotificationStateBatchPatch(BaseModel):
states: list[NotificationStatePatch] = Field(default_factory=list, max_length=100)
class NotificationStateRead(BaseModel):
model_config = ConfigDict(from_attributes=True)
notification_id: str
read_at: datetime | None
hidden_at: datetime | None
context_json: dict[str, Any]
updated_at: datetime
class NotificationStateListRead(BaseModel):
states: list[NotificationStateRead] = Field(default_factory=list)

View File

@@ -121,6 +121,19 @@ class ExpenseClaimUpdate(BaseModel):
reason: str | None = Field(default=None, max_length=500) reason: str | None = Field(default=None, max_length=500)
class ExpenseClaimStandardAdjustmentRisk(BaseModel):
risk_id: str | None = Field(default=None, max_length=120)
item_id: str | None = Field(default=None, max_length=120)
title: str | None = Field(default=None, max_length=120)
risk: str | None = Field(default=None, max_length=500)
original_amount: Decimal | None = None
reimbursable_amount: Decimal | None = None
class ExpenseClaimStandardAdjustmentPayload(BaseModel):
risks: list[ExpenseClaimStandardAdjustmentRisk] = Field(default_factory=list, max_length=20)
class ExpenseClaimRead(BaseModel): class ExpenseClaimRead(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)

View File

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

View File

@@ -110,6 +110,10 @@ from app.services.expense_rule_runtime import (
from app.services.ocr import OcrService from app.services.ocr import OcrService
APPROVED_APPLICATION_LINK_STATUSES = {"approved", "completed"}
INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES = {"cancelled", "canceled", "deleted"}
class ExpenseClaimDraftFlowMixin: class ExpenseClaimDraftFlowMixin:
def upsert_draft_from_ontology( def upsert_draft_from_ontology(
self, self,
@@ -176,6 +180,12 @@ class ExpenseClaimDraftFlowMixin:
) )
is_new_claim = claim is None is_new_claim = claim is None
before_json = self._serialize_claim(claim) if claim is not None else None before_json = self._serialize_claim(claim) if claim is not None else None
application_link_block_result = self._build_application_link_block_result(
context_json=context_json,
target_claim=claim,
)
if application_link_block_result is not None:
return application_link_block_result
if is_new_claim: if is_new_claim:
existing_draft_count = self._count_draft_claims_for_owner( existing_draft_count = self._count_draft_claims_for_owner(
employee=employee, employee=employee,
@@ -517,6 +527,219 @@ class ExpenseClaimDraftFlowMixin:
return list(risk_flags or []) return list(risk_flags or [])
return [*list(risk_flags or []), link_flag] return [*list(risk_flags or []), link_flag]
def _build_application_link_block_result(
self,
*,
context_json: dict[str, Any],
target_claim: ExpenseClaim | None,
) -> dict[str, Any] | None:
link_flag = self._build_application_link_flag(context_json)
if link_flag is None:
return None
application_claim = self._find_application_claim_for_link(link_flag)
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
display_no = application_claim_no or "未编号申请单"
if application_claim is None or not self._is_expense_application_claim(application_claim):
return self._build_application_link_rejected_result(
f"未找到可关联的申请单 {display_no}。请先选择已审批通过的申请单。",
)
normalized_status = str(application_claim.status or "").strip().lower()
if normalized_status not in APPROVED_APPLICATION_LINK_STATUSES:
return self._build_application_link_rejected_result(
f"申请单 {application_claim.claim_no} 当前不是已审批通过状态,不能用于快速报销关联。",
application_claim=application_claim,
)
existing_reimbursement = self._find_existing_reimbursement_for_application_link(
application_claim=application_claim,
link_flag=link_flag,
target_claim=target_claim,
)
if existing_reimbursement is not None:
return self._build_application_link_rejected_result(
(
f"申请单 {application_claim.claim_no} 已经关联报销单 {existing_reimbursement.claim_no}"
"请进入该草稿或单据继续补充,不能重复生成。"
),
application_claim=application_claim,
existing_claim=existing_reimbursement,
)
return None
def _find_application_claim_for_link(self, link_flag: dict[str, Any]) -> ExpenseClaim | None:
application_claim_id = str(link_flag.get("application_claim_id") or "").strip()
application_claim_no = str(link_flag.get("application_claim_no") or "").strip()
if application_claim_id:
claim = self.db.get(ExpenseClaim, application_claim_id)
if claim is not None and self._is_expense_application_claim(claim):
return claim
if application_claim_no:
return self.db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.claim_no == application_claim_no)
.limit(1)
)
return None
def _find_existing_reimbursement_for_application_link(
self,
*,
application_claim: ExpenseClaim,
link_flag: dict[str, Any],
target_claim: ExpenseClaim | None,
) -> ExpenseClaim | None:
generated_draft = self._find_generated_reimbursement_from_application(
application_claim=application_claim,
target_claim=target_claim,
)
if generated_draft is not None:
return generated_draft
linked_ids, linked_nos = self._collect_application_link_reference_values(link_flag)
linked_ids.add(str(application_claim.id or "").strip())
linked_nos.add(str(application_claim.claim_no or "").strip().upper())
linked_ids.discard("")
linked_nos.discard("")
for claim in list(self.db.scalars(select(ExpenseClaim)).all()):
if self._is_same_target_claim(claim, target_claim):
continue
if self._is_expense_application_claim(claim):
continue
if self._is_inactive_application_link_reimbursement(claim):
continue
if self._claim_references_application(claim, linked_ids=linked_ids, linked_nos=linked_nos):
return claim
return None
def _find_generated_reimbursement_from_application(
self,
*,
application_claim: ExpenseClaim,
target_claim: ExpenseClaim | None,
) -> ExpenseClaim | None:
for flag in list(application_claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
generated_draft_id = str(
flag.get("generated_draft_claim_id")
or flag.get("generatedDraftClaimId")
or ""
).strip()
generated_draft_no = str(
flag.get("generated_draft_claim_no")
or flag.get("generatedDraftClaimNo")
or ""
).strip()
claim = self.db.get(ExpenseClaim, generated_draft_id) if generated_draft_id else None
if claim is None and generated_draft_no:
claim = self.db.scalar(
select(ExpenseClaim)
.where(ExpenseClaim.claim_no == generated_draft_no)
.limit(1)
)
if claim is None:
continue
if self._is_same_target_claim(claim, target_claim):
continue
if self._is_expense_application_claim(claim):
continue
if self._is_inactive_application_link_reimbursement(claim):
continue
return claim
return None
@staticmethod
def _is_same_target_claim(claim: ExpenseClaim, target_claim: ExpenseClaim | None) -> bool:
return bool(target_claim is not None and claim.id == target_claim.id)
@staticmethod
def _is_inactive_application_link_reimbursement(claim: ExpenseClaim) -> bool:
status = str(claim.status or "").strip().lower()
return status in INACTIVE_APPLICATION_LINK_REIMBURSEMENT_STATUSES
@classmethod
def _claim_references_application(
cls,
claim: ExpenseClaim,
*,
linked_ids: set[str],
linked_nos: set[str],
) -> bool:
for flag in list(claim.risk_flags_json or []):
flag_ids, flag_nos = cls._collect_application_link_reference_values(flag)
if flag_ids.intersection(linked_ids) or flag_nos.intersection(linked_nos):
return True
return False
@classmethod
def _collect_application_link_reference_values(cls, payload: Any) -> tuple[set[str], set[str]]:
ids: set[str] = set()
claim_nos: set[str] = set()
if not isinstance(payload, dict):
return ids, claim_nos
cls._add_application_link_reference(ids, claim_nos, payload)
for key in (
"application_detail",
"applicationDetail",
"review_form_values",
"reviewFormValues",
"expense_scene_selection",
"expenseSceneSelection",
):
nested_ids, nested_nos = cls._collect_application_link_reference_values(payload.get(key))
ids.update(nested_ids)
claim_nos.update(nested_nos)
ids.discard("")
claim_nos.discard("")
return ids, claim_nos
@staticmethod
def _add_application_link_reference(
ids: set[str],
claim_nos: set[str],
payload: dict[str, Any],
) -> None:
for key in ("application_claim_id", "applicationClaimId"):
ids.add(str(payload.get(key) or "").strip())
for key in ("application_claim_no", "applicationClaimNo"):
claim_nos.add(str(payload.get(key) or "").strip().upper())
@staticmethod
def _build_application_link_rejected_result(
message: str,
*,
application_claim: ExpenseClaim | None = None,
existing_claim: ExpenseClaim | None = None,
) -> dict[str, Any]:
result: dict[str, Any] = {
"message": message,
"draft_only": False,
"status": "blocked",
"application_link_blocked": True,
"submission_blocked": True,
"submission_blocked_reasons": [message],
"missing_fields": [message],
"risk_flags": ["application_link_blocked"],
}
if application_claim is not None:
result["application_claim_id"] = application_claim.id
result["application_claim_no"] = application_claim.claim_no
result["application_status"] = application_claim.status
if existing_claim is not None:
result["existing_claim_id"] = existing_claim.id
result["existing_claim_no"] = existing_claim.claim_no
result["existing_claim_status"] = existing_claim.status
return result
@staticmethod @staticmethod
def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None: def _build_application_link_flag(context_json: dict[str, Any]) -> dict[str, Any] | None:
review_values = ExpenseClaimDraftFlowMixin._normalize_context_object( review_values = ExpenseClaimDraftFlowMixin._normalize_context_object(

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import re import re
from datetime import UTC, date, datetime, timedelta from datetime import UTC, date, datetime, timedelta
from decimal import Decimal from decimal import Decimal, InvalidOperation
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Any
@@ -24,6 +24,7 @@ from app.services.expense_claim_constants import (
DOCUMENT_FACT_ITEM_TYPES, DOCUMENT_FACT_ITEM_TYPES,
LOCATION_REQUIRED_EXPENSE_TYPES, LOCATION_REQUIRED_EXPENSE_TYPES,
OPTIONAL_ATTACHMENT_ITEM_TYPES, OPTIONAL_ATTACHMENT_ITEM_TYPES,
STANDARD_ADJUSTMENT_RISK_SOURCE,
SYSTEM_GENERATED_ITEM_TYPES, SYSTEM_GENERATED_ITEM_TYPES,
TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES, TRAVEL_ALLOWANCE_TRIGGER_ITEM_TYPES,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
@@ -329,6 +330,45 @@ class ExpenseClaimItemSyncMixin:
return destination return destination
return "" return ""
@staticmethod
def _parse_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
def _collect_standard_adjusted_amounts(self, claim: ExpenseClaim) -> dict[str, Decimal]:
adjusted_amounts: dict[str, Decimal] = {}
for flag in list(claim.risk_flags_json or []):
if not isinstance(flag, dict):
continue
if str(flag.get("source") or "").strip() != STANDARD_ADJUSTMENT_RISK_SOURCE:
continue
item_id = str(flag.get("item_id") or flag.get("itemId") or "").strip()
if not item_id:
continue
amount = self._parse_standard_adjustment_amount(
flag.get("reimbursable_amount") or flag.get("reimbursableAmount")
)
if amount is None:
continue
adjusted_amounts[item_id] = amount
return adjusted_amounts
def _resolve_item_amount_for_claim_total(
self,
item: ExpenseClaimItem,
adjusted_amounts: dict[str, Decimal],
) -> Decimal:
original_amount = Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
item_id = str(item.id or "").strip()
adjusted_amount = adjusted_amounts.get(item_id)
if adjusted_amount is None:
return original_amount
return min(max(adjusted_amount, Decimal("0.00")), original_amount)
def _sync_claim_from_items(self, claim: ExpenseClaim) -> None: def _sync_claim_from_items(self, claim: ExpenseClaim) -> None:
self._sync_travel_allowance_item(claim) self._sync_travel_allowance_item(claim)
if not claim.items: if not claim.items:
@@ -346,7 +386,11 @@ class ExpenseClaimItemSyncMixin:
), ),
) )
primary_item = ordered_items[0] primary_item = ordered_items[0]
total_amount = sum((item.item_amount for item in ordered_items), Decimal("0.00")) adjusted_amounts = self._collect_standard_adjusted_amounts(claim)
total_amount = sum(
(self._resolve_item_amount_for_claim_total(item, adjusted_amounts) for item in ordered_items),
Decimal("0.00"),
)
claim.amount = total_amount.quantize(Decimal("0.01")) claim.amount = total_amount.quantize(Decimal("0.01"))
claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip()) claim.invoice_count = sum(1 for item in ordered_items if str(item.invoice_id or "").strip())

View File

@@ -72,7 +72,11 @@ class ExpenseClaimPolicyReviewMixin:
limit_config=item_limit, limit_config=item_limit,
reason_text="\n".join( reason_text="\n".join(
part part
for part in [reason_corpus, str(item.item_reason or "").strip()] for part in [
reason_corpus,
str(item.item_reason or "").strip(),
str(item.item_note or "").strip(),
]
if part if part
), ),
) )
@@ -333,7 +337,12 @@ class ExpenseClaimPolicyReviewMixin:
f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚," f"{band_label} 职级在{standard_label}的住宿标准为 {cap} 元/晚,"
f"当前酒店识别金额约 {nightly_amount} 元/晚。" f"当前酒店识别金额约 {nightly_amount} 元/晚。"
) )
item_reason = str(context["item"].item_reason or "").strip() item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords) item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
if has_standard_exception or item_has_exception: if has_standard_exception or item_has_exception:
flags.append( flags.append(
@@ -368,7 +377,12 @@ class ExpenseClaimPolicyReviewMixin:
if allowed_level is None or class_level <= allowed_level: if allowed_level is None or class_level <= allowed_level:
continue continue
item_reason = str(context["item"].item_reason or "").strip() item_reason = " ".join(
[
str(context["item"].item_reason or "").strip(),
str(context["item"].item_note or "").strip(),
]
).strip()
item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords) item_has_exception = self._text_contains_keywords(item_reason, policy.standard_exception_keywords)
message = f"{band_label} 职级当前默认不可报销 {class_label}" message = f"{band_label} 职级当前默认不可报销 {class_label}"
if has_standard_exception or item_has_exception: if has_standard_exception or item_has_exception:
@@ -463,6 +477,7 @@ class ExpenseClaimPolicyReviewMixin:
parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()] parts = [str(claim.reason or "").strip(), str(claim.location or "").strip()]
for item in claim.items: for item in claim.items:
parts.append(str(item.item_reason or "").strip()) parts.append(str(item.item_reason or "").strip())
parts.append(str(item.item_note or "").strip())
parts.append(str(item.item_location or "").strip()) parts.append(str(item.item_location or "").strip())
return "\n".join(part for part in parts if part) return "\n".join(part for part in parts if part)

View File

@@ -27,6 +27,7 @@ from app.schemas.ontology import OntologyEntity, OntologyParseResult
from app.schemas.reimbursement import ( from app.schemas.reimbursement import (
ExpenseClaimItemCreate, ExpenseClaimItemCreate,
ExpenseClaimItemUpdate, ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate, ExpenseClaimUpdate,
TravelReimbursementCalculatorRequest, TravelReimbursementCalculatorRequest,
) )
@@ -109,6 +110,7 @@ from app.services.expense_claim_constants import (
TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS, TRAVEL_POLICY_FLIGHT_CLASS_PATTERNS,
TRAVEL_POLICY_TRAIN_CLASS_PATTERNS, TRAVEL_POLICY_TRAIN_CLASS_PATTERNS,
TRAVEL_POLICY_HOTEL_NIGHT_PATTERN, TRAVEL_POLICY_HOTEL_NIGHT_PATTERN,
STANDARD_ADJUSTMENT_RISK_SOURCE,
) )
from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin from app.services.expense_claim_risk_review import ExpenseClaimRiskReviewMixin
from app.services.expense_amounts import ( from app.services.expense_amounts import (
@@ -290,6 +292,126 @@ class ExpenseClaimService(
return claim return claim
@staticmethod
def _normalize_standard_adjustment_amount(value: Any) -> Decimal | None:
try:
raw_value = "" if value is None else value
amount = Decimal(str(raw_value)).quantize(Decimal("0.01"))
except (InvalidOperation, ValueError):
return None
return amount if amount >= Decimal("0.00") else None
@staticmethod
def _format_adjustment_money(value: Decimal) -> str:
normalized = Decimal(value or Decimal("0.00")).quantize(Decimal("0.01"))
return f"{normalized:.2f}"
def accept_standard_adjustment(
self,
*,
claim_id: str,
payload: ExpenseClaimStandardAdjustmentPayload,
current_user: CurrentUserContext,
) -> ExpenseClaim | None:
claim = self.get_claim(claim_id, current_user)
if claim is None:
return None
self._ensure_draft_claim(claim)
if self._is_expense_application_claim(claim):
raise ValueError("费用申请单不支持按报销标准重算。")
risk_entries = list(payload.risks or [])
if not risk_entries:
raise ValueError("请至少选择一条需要按职级标准重算的风险。")
before_json = self._serialize_claim(claim)
item_map = {str(item.id or "").strip(): item for item in list(claim.items or [])}
now_text = datetime.now(UTC).isoformat()
adjustment_flags: list[dict[str, Any]] = []
for index, entry in enumerate(risk_entries, start=1):
item_id = str(entry.item_id or "").strip()
item = item_map.get(item_id)
if item is None:
continue
original_amount = (
self._normalize_standard_adjustment_amount(entry.original_amount)
or Decimal(item.item_amount or Decimal("0.00")).quantize(Decimal("0.01"))
)
reimbursable_amount = (
self._normalize_standard_adjustment_amount(entry.reimbursable_amount)
or original_amount
)
reimbursable_amount = min(max(reimbursable_amount, Decimal("0.00")), original_amount)
employee_absorbed_amount = (original_amount - reimbursable_amount).quantize(Decimal("0.01"))
item_label = (
str(item.item_reason or "").strip()
or str(entry.title or "").strip()
or f"费用明细第 {index}"
)
source_risk = str(entry.risk or entry.title or "原风险未补充异常说明").strip()
message = (
f"提交人已选择按职级最高报销标准审核:{item_label} 原票据金额 "
f"{self._format_adjustment_money(original_amount)} 元,实际报销金额 "
f"{self._format_adjustment_money(reimbursable_amount)} 元,超出 "
f"{self._format_adjustment_money(employee_absorbed_amount)} 元由员工自行承担。"
)
adjustment_flags.append(
with_risk_business_stage(
{
"source": STANDARD_ADJUSTMENT_RISK_SOURCE,
"event_type": "standard_adjustment_accepted",
"severity": "medium",
"label": "接受职级标准审核",
"title": "提交人接受职级最高报销标准",
"message": message,
"summary": "提交人未补充异常说明,已选择按职级最高报销标准重算实际报销金额。",
"suggestion": "领导和财务审批时请确认该差额由员工自行承担,并按实际报销金额入账。",
"risk_id": str(entry.risk_id or "").strip(),
"source_risk": source_risk,
"item_id": item_id,
"original_amount": self._format_adjustment_money(original_amount),
"reimbursable_amount": self._format_adjustment_money(reimbursable_amount),
"employee_absorbed_amount": self._format_adjustment_money(employee_absorbed_amount),
"risk_domain": "amount",
"actionability": "review_decision",
"visibility_scope": "leader",
"created_at": now_text,
},
"reimbursement",
)
)
if not adjustment_flags:
raise ValueError("未找到可按职级标准重算的费用明细。")
preserved_flags = [
flag
for flag in list(claim.risk_flags_json or [])
if not (
isinstance(flag, dict)
and str(flag.get("source") or "").strip() == STANDARD_ADJUSTMENT_RISK_SOURCE
)
]
claim.risk_flags_json = [*preserved_flags, *adjustment_flags]
self._sync_claim_from_items(claim)
self.db.commit()
self.db.refresh(claim)
self.audit_service.log_action(
actor=current_user.name or current_user.username,
action="expense_claim.standard_adjustment_accept",
resource_type="expense_claim",
resource_id=claim.id,
before_json=before_json,
after_json=self._serialize_claim(claim),
)
return claim
def update_claim_item( def update_claim_item(
self, self,
*, *,
@@ -758,6 +880,3 @@ class ExpenseClaimService(

View File

@@ -0,0 +1,88 @@
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.orm import Session
from app.api.deps import CurrentUserContext
from app.db.base import Base
from app.models.notification_state import NotificationState
from app.schemas.notification_state import (
NotificationStateBatchPatch,
NotificationStateListRead,
NotificationStateRead,
)
class NotificationStateService:
def __init__(self, db: Session) -> None:
self.db = db
def ensure_storage_ready(self) -> None:
Base.metadata.create_all(bind=self.db.get_bind(), tables=[NotificationState.__table__])
def list_states(self, current_user: CurrentUserContext) -> NotificationStateListRead:
self.ensure_storage_ready()
stmt = (
select(NotificationState)
.where(NotificationState.user_id == self._user_key(current_user))
.order_by(NotificationState.updated_at.desc())
)
states = list(self.db.scalars(stmt).all())
return NotificationStateListRead(
states=[NotificationStateRead.model_validate(item) for item in states]
)
def patch_states(
self,
payload: NotificationStateBatchPatch,
current_user: CurrentUserContext,
) -> NotificationStateListRead:
self.ensure_storage_ready()
user_id = self._user_key(current_user)
patches = [item for item in payload.states if item.notification_id]
if not patches:
return self.list_states(current_user)
ids = {item.notification_id for item in patches}
existing_rows = list(
self.db.scalars(
select(NotificationState).where(
NotificationState.user_id == user_id,
NotificationState.notification_id.in_(ids),
)
).all()
)
existing_by_id = {item.notification_id: item for item in existing_rows}
now = datetime.now(UTC)
for patch in patches:
row = existing_by_id.get(patch.notification_id)
if row is None:
row = NotificationState(
user_id=user_id,
notification_id=patch.notification_id,
context_json={},
)
self.db.add(row)
existing_by_id[patch.notification_id] = row
if patch.read and row.read_at is None:
row.read_at = now
if patch.hidden and row.hidden_at is None:
row.hidden_at = now
if patch.context_json:
row.context_json = self._merge_context(row.context_json, patch.context_json)
self.db.commit()
return self.list_states(current_user)
@staticmethod
def _user_key(current_user: CurrentUserContext) -> str:
return str(current_user.username or current_user.name or "anonymous").strip() or "anonymous"
@staticmethod
def _merge_context(current: dict | None, patch: dict) -> dict:
base = current if isinstance(current, dict) else {}
return {**base, **patch}

View File

@@ -78,6 +78,9 @@ CANONICAL_ONTOLOGY_FIELDS = frozenset(ONTOLOGY_FIELD_ALIASES) | frozenset(
"application_policy_estimate", "application_policy_estimate",
"application_rule_name", "application_rule_name",
"application_rule_version", "application_rule_version",
"original_amount",
"reimbursable_amount",
"employee_absorbed_amount",
} }
) )

View File

@@ -1,121 +0,0 @@
{
"id": "057901b1-d38a-4e0c-9d53-44d14244317e",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:39:27.813933+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

View File

@@ -1,121 +0,0 @@
{
"id": "0abbdfaf-7952-4854-a5b2-bc34298fa1c4",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:08:52.697458+00:00",
"status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
"linked_item_id": "b1b343b0-3564-4d35-919a-0e4220a9fceb",
"linked_at": "2026-06-01T06:08:52.697458+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,66 +0,0 @@
{
"id": "29ec7c4c-abc0-46f0-8eae-3136c2d6fef7",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-30T07:00:12.286631+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,66 +0,0 @@
{
"id": "2fd4856f-e918-4d29-a0b2-340cc1fdec03",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-05-30T07:00:40.560540+00:00",
"status": "linked",
"linked_claim_id": "6b8e29c9-8bd6-453b-b594-ed0e5b59b91f",
"linked_claim_no": "RE-20260530065944-M94FAPB9",
"linked_item_id": "329f477a-d926-4101-8ec8-4c8a95150f22",
"linked_at": "2026-05-30T07:00:40.560540+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,16 +1,16 @@
{ {
"id": "e8d4f21f-846f-4321-a341-52cd3dfb5acc", "id": "33a1c4b9-56e1-49d1-823e-5cb4680f5a40",
"owner_key": "caoxiaozhu_xf.com", "owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf", "file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf", "source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf", "media_type": "application/pdf",
"size_bytes": 24995, "size_bytes": 24995,
"uploaded_at": "2026-06-01T06:57:44.644255+00:00", "uploaded_at": "2026-06-03T08:39:19.288158+00:00",
"status": "linked", "status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8", "linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260601060546-EE2PHJRK", "linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "26ccabf8-7e69-4812-acc6-fa18899ec5b2", "linked_item_id": "eb1e9fde-b7e8-4f6e-823f-d8252489e7f9",
"linked_at": "2026-06-01T06:57:44.644255+00:00", "linked_at": "2026-06-03T08:39:19.288158+00:00",
"engine": "paddleocr_mobile", "engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile", "model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", "ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",

View File

@@ -1,121 +0,0 @@
{
"id": "64b90764-b957-4a54-b231-0646ee60d1cd",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:08:07.688668+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "67dc947b-be67-444d-9a91-897190170021",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:40:08.599943+00:00",
"status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8",
"linked_claim_no": "RE-20260601060546-EE2PHJRK",
"linked_item_id": "c1693c4a-dcbd-4a8b-941c-bb0abc6ec65a",
"linked_at": "2026-06-01T06:40:08.599943+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,16 +1,16 @@
{ {
"id": "fa80f870-10a3-4797-a151-39e702927eb5", "id": "67f51c17-a2bc-42bd-99a4-199ee32b18c3",
"owner_key": "caoxiaozhu_xf.com", "owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf", "file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf", "source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf", "media_type": "application/pdf",
"size_bytes": 24940, "size_bytes": 24940,
"uploaded_at": "2026-06-01T06:40:32.249473+00:00", "uploaded_at": "2026-06-03T08:40:26.766004+00:00",
"status": "linked", "status": "linked",
"linked_claim_id": "19a8eaf2-13f2-45fc-b389-a292fadfd6d8", "linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260601060546-EE2PHJRK", "linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "b0b28405-30b5-4c35-9bd5-13abe4d2c4cd", "linked_item_id": "977f01f8-e7ab-487b-8055-db8864464784",
"linked_at": "2026-06-01T06:40:32.249473+00:00", "linked_at": "2026-06-03T08:40:26.766004+00:00",
"engine": "paddleocr_mobile", "engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile", "model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快", "ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",

View File

@@ -1,121 +0,0 @@
{
"id": "6a7335e9-b36a-415c-b2d5-26f421ec72b0",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-06-01T06:08:07.724830+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

View File

@@ -0,0 +1,55 @@
{
"id": "8678e169-d800-4846-9354-eb768a9b65f8",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "酒店3.jpg",
"source_file_name": "酒店3.jpg",
"media_type": "image/jpeg",
"size_bytes": 153582,
"uploaded_at": "2026-06-03T08:41:47.393654+00:00",
"status": "linked",
"linked_claim_id": "2ad80b25-b153-407e-91be-ed2651045fb1",
"linked_claim_no": "RE-20260603083825-876B85XW",
"linked_item_id": "42250571-3e84-4f27-b3e8-aa224b5cb2f7",
"linked_at": "2026-06-03T08:41:47.393654+00:00",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "上海喜来登酒店(样例)\n住宿费用单\n单据编号SH-SAMPLE-20260223-001\n开单期2026年223\n宾客姓名曹笑\n住期2026年220\n离店期2026年223\n住晚数3晚\n房型豪华床房\n房号1808\n项目\n日期\n数量\n单价\n金额\n备注\n住宿费\n2026-02-20至2026-02-22\n3晚\n¥362/晚\n¥1086\n豪华大床房\n金额大写壹仟零捌拾陆元整\n合计¥1086\n备注\n1.如有疑问请致电前台021-28958888。\n2.退房时间为中午1200超时退房将按酒店规定收取相关费用。\n3.感谢您的下榻,期待您的再次光临!\n酒店地址上海市浦东新区银城中路88号 邮编200120\n样例票据|仅供系统测试|无效凭证",
"summary": "上海喜来登酒店样例住宿费用单单据编号SH-SAMPLE-20260223-001",
"ocr_avg_score": 0.988790222009023,
"ocr_line_count": 30,
"page_count": 1,
"document_type": "hotel_invoice",
"document_type_label": "酒店住宿票据",
"scene_code": "hotel",
"scene_label": "住宿票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.71,
"ocr_classification_evidence": [
"住宿",
"离店",
"酒店"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "1086元"
},
{
"key": "date",
"label": "日期",
"value": "2026-02-20"
},
{
"key": "merchant_name",
"label": "商户",
"value": "上海喜来登酒店"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.jpg",
"preview_media_type": "image/jpeg"
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "87c5ddbf-15be-4d21-982d-808267902ab0",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月23_上海-武汉.pdf",
"source_file_name": "2月23_上海-武汉.pdf",
"media_type": "application/pdf",
"size_bytes": 24940,
"uploaded_at": "2026-06-01T06:39:27.857168+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票\n州\n国家税务总局\n发票号码26319166100006175398\n开票日期:2026年05月18日\n上海市税务局\n上海虹桥站\n武汉站\nG456\nShanghaihongqiao\nWuhan\n2026年02月23日\n13:54开\n12车08B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6610061086021394837402026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9620026834309101,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-23 13:54"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26319166100006175398"
},
{
"key": "route",
"label": "行程",
"value": "上海-武汉"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "上海虹桥"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "武汉"
},
{
"key": "train_no",
"label": "车次",
"value": "G456"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6610061086021394837402026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "12车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "08B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -1,121 +0,0 @@
{
"id": "fd4f2229-bf01-48ae-99b9-b98458e2b632",
"owner_key": "caoxiaozhu_xf.com",
"file_name": "2月20_武汉-上海.pdf",
"source_file_name": "2月20_武汉-上海.pdf",
"media_type": "application/pdf",
"size_bytes": 24995,
"uploaded_at": "2026-06-01T06:56:59.257104+00:00",
"status": "unlinked",
"linked_claim_id": "",
"linked_claim_no": "",
"linked_item_id": "",
"linked_at": "",
"engine": "paddleocr_mobile",
"model": "PP-OCRv5_mobile",
"ocr_text": "电子发票\n铁路电子客票)\n州\n国家税务总局\n发票号码26429165800002785705\n湖北省税务局\n开票日期:2026年05月18日\n武汉站\n上海虹桥站\nG458\nWuhan\nShanghaihongqiao\n2026年02月20日\n07:55开\n06车01B号\n二等座\n票价¥354.00\n4201061987****1615\n曹笑竹\n电子客票号6580061086021391007342026\n购买方名称:曹笑竹\n统一社会信用代码\n买票请到12306发货请到95306\n中国铁路祝您旅途愉快",
"summary": "电子发票;(铁路电子客票);州",
"ocr_avg_score": 0.9580968717734019,
"ocr_line_count": 24,
"page_count": 1,
"document_type": "train_ticket",
"document_type_label": "火车/高铁票",
"scene_code": "travel",
"scene_label": "差旅票据",
"ocr_classification_source": "rule",
"ocr_classification_confidence": 0.88,
"ocr_classification_evidence": [
"铁路电子客票",
"电子客票",
"铁路",
"二等座"
],
"document_fields": [
{
"key": "amount",
"label": "金额",
"value": "354元"
},
{
"key": "date",
"label": "列车出发时间",
"value": "2026-02-20 07:55"
},
{
"key": "merchant_name",
"label": "商户",
"value": "中国铁路"
},
{
"key": "invoice_number",
"label": "票据号码",
"value": "26429165800002785705"
},
{
"key": "route",
"label": "行程",
"value": "武汉-上海"
},
{
"key": "invoice_date",
"label": "开票日期",
"value": "2026-05-18"
},
{
"key": "departure_station",
"label": "出发地点",
"value": "武汉"
},
{
"key": "arrival_station",
"label": "到达地点",
"value": "上海虹桥"
},
{
"key": "train_no",
"label": "车次",
"value": "G458"
},
{
"key": "passenger_name",
"label": "乘车人",
"value": "曹笑竹"
},
{
"key": "id_number",
"label": "身份证号",
"value": "4201061987****1615"
},
{
"key": "electronic_ticket_no",
"label": "电子客票号",
"value": "6580061086021391007342026"
},
{
"key": "seat_class",
"label": "席别",
"value": "二等座"
},
{
"key": "carriage_no",
"label": "车厢",
"value": "06车"
},
{
"key": "seat_no",
"label": "座位号",
"value": "01B"
},
{
"key": "fare",
"label": "票价",
"value": "354.00元"
}
],
"editable_fields": {},
"ocr_warnings": [],
"previewable": true,
"preview_kind": "image",
"preview_file_name": "preview.png",
"preview_media_type": "image/png"
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -19,7 +19,12 @@ from app.models.organization import OrganizationUnit
from app.models.role import Role from app.models.role import Role
from app.schemas.ontology import OntologyParseRequest from app.schemas.ontology import OntologyParseRequest
from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead from app.schemas.ocr import OcrRecognizeBatchRead, OcrRecognizeDocumentRead
from app.schemas.reimbursement import ExpenseClaimItemCreate, ExpenseClaimItemUpdate, ExpenseClaimUpdate from app.schemas.reimbursement import (
ExpenseClaimItemCreate,
ExpenseClaimItemUpdate,
ExpenseClaimStandardAdjustmentPayload,
ExpenseClaimUpdate,
)
from app.services.agent_conversations import AgentConversationService from app.services.agent_conversations import AgentConversationService
from app.services.budget import BudgetService from app.services.budget import BudgetService
from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage from app.services.expense_claim_attachment_storage import ExpenseClaimAttachmentStorage
@@ -69,6 +74,36 @@ def build_claim(*, expense_type: str, location: str) -> ExpenseClaim:
return claim return claim
def build_application_claim(
*,
id: str,
claim_no: str,
employee: Employee,
status: str = "approved",
amount: Decimal = Decimal("3000.00"),
) -> ExpenseClaim:
return ExpenseClaim(
id=id,
claim_no=claim_no,
employee_id=employee.id,
employee_name=employee.name,
department_id=employee.organization_unit_id,
department_name="Tech",
project_code=None,
expense_type="travel_application",
reason="support deployment",
location="Shanghai",
amount=amount,
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
submitted_at=datetime(2026, 2, 18, tzinfo=UTC),
status=status,
approval_stage=APPROVAL_DONE_STAGE if status in {"approved", "completed"} else "Pending",
risk_flags_json=[],
)
def build_session() -> Session: def build_session() -> Session:
engine = create_engine( engine = create_engine(
"sqlite+pysqlite:///:memory:", "sqlite+pysqlite:///:memory:",
@@ -322,6 +357,12 @@ def test_upsert_draft_from_ontology_persists_linked_application_context() -> Non
email=user_id, email=user_id,
) )
db.add(employee) db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-1",
claim_no="AP-202605-001",
employee=employee,
))
db.commit() db.commit()
ontology = SemanticOntologyService(db).parse( ontology = SemanticOntologyService(db).parse(
OntologyParseRequest( OntologyParseRequest(
@@ -384,6 +425,12 @@ def test_upsert_linked_application_draft_without_receipts_has_no_placeholder_ite
grade="P5", grade="P5",
) )
db.add(employee) db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-linked-no-receipt",
claim_no="AP-202606-001",
employee=employee,
))
db.commit() db.commit()
ontology = SemanticOntologyService(db).parse( ontology = SemanticOntologyService(db).parse(
OntologyParseRequest( OntologyParseRequest(
@@ -474,6 +521,11 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
) )
db.add(employee) db.add(employee)
db.flush() db.flush()
db.add(build_application_claim(
id="application-linked-existing-placeholder",
claim_no="AP-202606-002",
employee=employee,
))
existing_claim = ExpenseClaim( existing_claim = ExpenseClaim(
claim_no="RE-202606020001-PLACEHOLDER", claim_no="RE-202606020001-PLACEHOLDER",
employee_id=employee.id, employee_id=employee.id,
@@ -550,6 +602,123 @@ def test_upsert_linked_application_draft_clears_existing_placeholder_item() -> N
assert claim.items == [] assert claim.items == []
def test_upsert_linked_application_requires_approved_application() -> None:
user_id = "linked-application-status-block@example.com"
message = "save reimbursement draft from linked travel application"
with build_session() as db:
employee = Employee(employee_no="E5108", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-returned-blocked",
claim_no="AP-202606-STATUS",
employee=employee,
status="returned",
))
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": "Linked Employee",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "travel",
"application_claim_id": "application-returned-blocked",
"application_claim_no": "AP-202606-STATUS",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-returned-blocked",
"application_claim_no": "AP-202606-STATUS",
},
},
)
assert result["status"] == "blocked"
assert result["application_link_blocked"] is True
assert result["application_claim_no"] == "AP-202606-STATUS"
assert _count_claims(db) == 1
def test_upsert_linked_application_rejects_duplicate_reimbursement_draft() -> None:
user_id = "linked-application-duplicate-block@example.com"
message = "save another reimbursement draft from linked travel application"
with build_session() as db:
employee = Employee(employee_no="E5109", name="Linked Employee", email=user_id)
db.add(employee)
db.flush()
db.add(build_application_claim(
id="application-duplicate-blocked",
claim_no="AP-202606-DUP",
employee=employee,
))
existing_claim = ExpenseClaim(
claim_no="RE-202606-DUP-DRAFT",
employee_id=employee.id,
employee_name=employee.name,
department_name="Tech",
project_code=None,
expense_type="travel",
reason="support deployment",
location="Shanghai",
amount=Decimal("0.00"),
currency="CNY",
invoice_count=0,
occurred_at=datetime(2026, 2, 20, tzinfo=UTC),
status="draft",
approval_stage="Pending",
risk_flags_json=[
{
"source": "application_link",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
}
],
)
db.add(existing_claim)
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": "Linked Employee",
"user_input_text": message,
"review_action": "save_draft",
"review_form_values": {
"expense_type": "travel",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
},
"expense_scene_selection": {
"expense_type": "travel",
"application_claim_id": "application-duplicate-blocked",
"application_claim_no": "AP-202606-DUP",
},
},
)
assert result["status"] == "blocked"
assert result["application_link_blocked"] is True
assert result["existing_claim_no"] == "RE-202606-DUP-DRAFT"
assert _count_claims(db) == 2
def test_sync_travel_allowance_uses_linked_application_range_days() -> None: def test_sync_travel_allowance_uses_linked_application_range_days() -> None:
with build_session() as db: with build_session() as db:
employee = Employee( employee = Employee(
@@ -2451,6 +2620,77 @@ def test_submit_claim_runs_ai_review_and_routes_to_direct_manager() -> None:
assert submitted.submitted_at is not None assert submitted.submitted_at is not None
def test_accept_standard_adjustment_recalculates_claim_amount_and_preserves_on_submit() -> None:
current_user = CurrentUserContext(
username="emp-standard@example.com",
name="张三",
role_codes=[],
is_admin=False,
)
with build_session() as db:
manager = Employee(
employee_no="E7030",
name="李经理",
email="manager-standard@example.com",
)
employee = Employee(
employee_no="E7031",
name="张三",
email="emp-standard@example.com",
manager=manager,
)
claim = build_claim(expense_type="hotel", location="北京")
claim.employee = employee
claim.employee_id = employee.id
claim.amount = Decimal("880.00")
claim.items[0].item_type = "hotel_ticket"
claim.items[0].item_reason = "北京住宿"
claim.items[0].item_amount = Decimal("880.00")
db.add_all([manager, employee, claim])
db.commit()
service = ExpenseClaimService(db)
adjusted = service.accept_standard_adjustment(
claim_id=claim.id,
payload=ExpenseClaimStandardAdjustmentPayload(
risks=[
{
"risk_id": "risk-hotel-1",
"item_id": claim.items[0].id,
"title": "住宿超标待说明",
"risk": "住宿标准为 600 元/晚,当前酒店识别金额约 880 元/晚。",
"original_amount": Decimal("880.00"),
"reimbursable_amount": Decimal("600.00"),
}
]
),
current_user=current_user,
)
assert adjusted is not None
assert adjusted.amount == Decimal("600.00")
standard_flag = next(
flag
for flag in adjusted.risk_flags_json
if isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
)
assert standard_flag["original_amount"] == "880.00"
assert standard_flag["reimbursable_amount"] == "600.00"
assert standard_flag["employee_absorbed_amount"] == "280.00"
assert standard_flag["visibility_scope"] == "leader"
submitted = service.submit_claim(claim.id, current_user)
assert submitted is not None
assert submitted.status == "submitted"
assert submitted.amount == Decimal("600.00")
assert any(
isinstance(flag, dict) and flag.get("source") == "reimbursement_standard_adjustment"
for flag in submitted.risk_flags_json
)
def test_pre_review_claim_records_ai_result_without_submitting() -> None: def test_pre_review_claim_records_ai_result_without_submitting() -> None:
current_user = CurrentUserContext( current_user = CurrentUserContext(
username="emp-pre-review@example.com", username="emp-pre-review@example.com",

View File

@@ -0,0 +1,119 @@
from __future__ import annotations
from collections.abc import Generator
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import StaticPool
from app.api.deps import CurrentUserContext, get_db
from app.db.base import Base
from app.main import create_app
from app.schemas.notification_state import NotificationStateBatchPatch, NotificationStatePatch
from app.services.notification_states import NotificationStateService
def build_session() -> Session:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
return session_factory()
def build_client() -> TestClient:
engine = create_engine(
"sqlite+pysqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
Base.metadata.create_all(bind=engine)
session_factory = sessionmaker(bind=engine, autoflush=False, autocommit=False)
app = create_app()
def override_db() -> Generator[Session, None, None]:
db = session_factory()
try:
yield db
finally:
db.close()
app.dependency_overrides[get_db] = override_db
return TestClient(app)
def test_notification_state_service_persists_user_scoped_read_and_hidden_state() -> None:
with build_session() as db:
service = NotificationStateService(db)
user = CurrentUserContext(username="alice", name="Alice", role_codes=[], is_admin=False)
other_user = CurrentUserContext(username="bob", name="Bob", role_codes=[], is_admin=False)
saved = service.patch_states(
NotificationStateBatchPatch(
states=[
NotificationStatePatch(
notification_id="document:owned:EXP-001",
read=True,
hidden=True,
context_json={"kind": "document"},
)
]
),
user,
)
other_saved = service.patch_states(
NotificationStateBatchPatch(
states=[
NotificationStatePatch(
notification_id="document:owned:EXP-001",
read=True,
)
]
),
other_user,
)
assert len(saved.states) == 1
assert saved.states[0].notification_id == "document:owned:EXP-001"
assert saved.states[0].read_at is not None
assert saved.states[0].hidden_at is not None
assert saved.states[0].context_json["kind"] == "document"
assert other_saved.states[0].hidden_at is None
def test_notification_state_endpoint_reads_and_updates_current_user_state() -> None:
client = build_client()
headers = {"x-auth-username": "alice", "x-auth-name": "Alice"}
post_response = client.post(
"/api/v1/notification-states",
json={
"states": [
{
"notification_id": "workbench:todo:EXP-002",
"read": True,
"hidden": False,
"context_json": {"kind": "workbench"},
}
]
},
headers=headers,
)
get_response = client.get("/api/v1/notification-states", headers=headers)
other_response = client.get(
"/api/v1/notification-states",
headers={"x-auth-username": "bob", "x-auth-name": "Bob"},
)
assert post_response.status_code == 200
assert get_response.status_code == 200
payload = get_response.json()
assert payload["states"][0]["notification_id"] == "workbench:todo:EXP-002"
assert payload["states"][0]["read_at"] is not None
assert payload["states"][0]["hidden_at"] is None
assert payload["states"][0]["context_json"]["kind"] == "workbench"
assert other_response.json()["states"] == []

View File

@@ -16,6 +16,7 @@
color: #64748b; color: #64748b;
font-size: 14px; font-size: 14px;
font-weight: 750; font-weight: 750;
overflow: visible;
} }
.status-tabs button.active { .status-tabs button.active {
@@ -33,20 +34,33 @@
background: var(--theme-primary); background: var(--theme-primary);
} }
.scope-tab-label {
display: inline-flex;
align-items: flex-start;
gap: 4px;
line-height: 1.2;
}
.scope-tab-badge { .scope-tab-badge {
min-width: 18px; position: static;
height: 18px; flex: 0 0 auto;
min-width: 14px;
height: 14px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 5px; margin-top: -5px;
padding: 0 3px;
border: 1px solid #fff;
border-radius: 999px; border-radius: 999px;
background: #ef4444; background: #ef4444;
color: #fff; color: #fff;
font-size: 11px; font-size: 9.5px;
font-weight: 850; font-weight: 850;
line-height: 1; line-height: 1;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.22); box-shadow:
0 0 0 2px rgba(239, 68, 68, 0.1),
0 6px 14px rgba(239, 68, 68, 0.22);
} }
.document-toolbar { .document-toolbar {
@@ -167,6 +181,38 @@
filter: none; filter: none;
} }
.mark-read-btn {
min-height: 34px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 0 12px;
border: 1px solid #fecaca;
border-radius: 4px;
background: #fff;
color: #dc2626;
font-size: 13px;
font-weight: 800;
white-space: nowrap;
transition:
border-color 160ms ease,
background 160ms ease,
color 160ms ease,
box-shadow 160ms ease;
}
.mark-read-btn .mdi {
font-size: 16px;
}
.mark-read-btn:hover {
border-color: #fca5a5;
background: #fff5f5;
color: #b91c1c;
box-shadow: 0 8px 18px rgba(239, 68, 68, 0.1);
}
.table-wrap { .table-wrap {
min-height: 400px; min-height: 400px;
margin-top: 10px; margin-top: 10px;
@@ -495,6 +541,7 @@ td small {
.document-actions, .document-actions,
.list-search, .list-search,
.filter-btn, .filter-btn,
.mark-read-btn,
.page-size-select { .page-size-select {
width: 100%; width: 100%;
} }

View File

@@ -73,9 +73,9 @@
.insight-metric-row, .insight-metric-row,
.insight-profile-card { .insight-profile-card {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 10px; gap: 8px;
min-height: 0; min-height: 0;
padding: 7px 9px; padding: 7px 9px;
border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12); border: 1px solid rgba(var(--theme-primary-rgb, 58, 124, 165), 0.12);
@@ -102,6 +102,15 @@
rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04); rgba(var(--theme-primary-rgb, 58, 124, 165), 0.04);
} }
.insight-metric-icon,
.insight-profile-icon {
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--workbench-muted);
font-size: 16px;
}
.insight-metric-label, .insight-metric-label,
.insight-profile-label { .insight-profile-label {
min-width: 0; min-width: 0;
@@ -136,19 +145,21 @@
font-weight: 700; font-weight: 700;
} }
.insight-metric-row--amount .insight-metric-icon,
.insight-metric-row--amount .insight-metric-value { .insight-metric-row--amount .insight-metric-value {
color: var(--workbench-primary-active); color: var(--workbench-primary-active);
} }
.insight-metric-row--warning .insight-metric-icon,
.insight-metric-row--warning .insight-metric-value { .insight-metric-row--warning .insight-metric-value {
color: var(--warning); color: var(--warning);
} }
.insight-metric-row--info .insight-metric-icon,
.insight-metric-row--info .insight-metric-value { .insight-metric-row--info .insight-metric-value {
color: var(--workbench-chart-blue); color: var(--workbench-chart-blue);
} }
.insight-profile-icon,
.insight-profile-hint { .insight-profile-hint {
display: none; display: none;
} }

View File

@@ -505,12 +505,19 @@
border-top: 0; border-top: 0;
} }
.progress-identity,
.progress-result {
gap: 12px;
}
.progress-identity strong, .progress-identity strong,
.progress-result strong { .progress-result strong {
margin-bottom: 2px;
overflow: hidden; overflow: hidden;
color: var(--workbench-ink); color: var(--workbench-ink);
font-size: 13px; font-size: 13px;
font-weight: 850; font-weight: bold;
line-height: 1.25; line-height: 1.25;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
@@ -573,24 +580,29 @@
} }
.progress-status--warning { .progress-status--warning {
background: var(--warning-soft); background: var(--warning-soft, #fff7ed);
color: var(--warning); color: var(--warning, #ea580c);
} }
.progress-status--success { .progress-status--success {
background: var(--workbench-primary-soft); background: var(--workbench-primary-soft, #eaf4fa);
color: var(--workbench-primary-active); color: var(--workbench-primary-active, #255b7d);
} }
.progress-status--muted { .progress-status--muted {
background: var(--info-soft); background: var(--info-soft, #f1f5f9);
color: var(--workbench-muted); color: var(--workbench-muted, #64748b);
}
.progress-status--danger {
background: var(--danger-soft, #fef2f2);
color: var(--danger, #dc2626);
} }
.progress-row { .progress-row {
position: relative; position: relative;
display: grid; display: grid;
grid-template-columns: minmax(78px, 0.42fr) minmax(132px, 0.74fr) minmax(84px, 0.42fr) minmax(260px, 1.28fr) minmax(92px, auto); grid-template-columns: minmax(118px, 0.58fr) minmax(132px, 0.74fr) minmax(84px, 0.42fr) minmax(260px, 1.28fr) minmax(92px, auto);
align-items: center; align-items: center;
gap: 12px; gap: 12px;
width: 100%; width: 100%;
@@ -638,6 +650,54 @@
pointer-events: none; pointer-events: none;
} }
.progress-time-wrapper {
display: flex;
align-items: center;
gap: 14px;
min-width: 0;
}
.expense-type-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
font-size: 20px;
flex-shrink: 0;
}
.expense-type-icon--blue {
background: color-mix(in srgb, var(--workbench-primary, #3a7ca5) 12%, #ffffff);
color: var(--workbench-primary, #3a7ca5);
}
.expense-type-icon--amber {
background: color-mix(in srgb, var(--workbench-chart-amber, #b58b4c) 12%, #ffffff);
color: var(--workbench-chart-amber, #b58b4c);
}
.expense-type-icon--emerald {
background: color-mix(in srgb, #10b981 12%, #ffffff);
color: #10b981;
}
.expense-type-icon--violet {
background: color-mix(in srgb, #8b5cf6 12%, #ffffff);
color: #8b5cf6;
}
.expense-type-icon--cyan {
background: color-mix(in srgb, #06b6d4 12%, #ffffff);
color: #06b6d4;
}
.expense-type-icon--muted {
background: var(--info-soft, #f1f5f9);
color: var(--workbench-muted, #64748b);
}
.progress-time, .progress-time,
.progress-identity, .progress-identity,
.progress-type, .progress-type,
@@ -649,6 +709,7 @@
.progress-time { .progress-time {
color: var(--workbench-muted); color: var(--workbench-muted);
justify-items: center;
} }
.progress-time time { .progress-time time {
@@ -669,6 +730,16 @@
line-height: 1.2; line-height: 1.2;
} }
.progress-time .time-capsule {
margin-top: 4px;
padding: 2px 6px;
border-radius: 999px;
background: var(--danger-soft, #fef2f2);
color: var(--danger, #dc2626);
font-weight: 850;
line-height: 1.2;
}
.progress-result { .progress-result {
justify-items: end; justify-items: end;
} }

View File

@@ -221,6 +221,7 @@
font-weight: 700; font-weight: 700;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
text-overflow: ellipsis;
opacity: 1; opacity: 1;
transition: transition:
max-width var(--rail-motion-duration) var(--rail-motion-ease), max-width var(--rail-motion-duration) var(--rail-motion-ease),
@@ -250,16 +251,6 @@
will-change: min-width, max-width, padding, opacity; will-change: min-width, max-width, padding, opacity;
} }
.nav-unread-dot {
flex: 0 0 auto;
width: 8px;
height: 8px;
border: 2px solid #fff;
border-radius: 999px;
background: #ef4444;
box-shadow: 0 6px 14px rgba(239, 68, 68, 0.26);
}
.rail-user { .rail-user {
position: relative; position: relative;
min-width: 0; min-width: 0;
@@ -469,14 +460,6 @@
transition-delay: 0ms; transition-delay: 0ms;
} }
.rail-collapsed .nav-unread-dot {
position: absolute;
top: 10px;
right: 11px;
width: 9px;
height: 9px;
}
.rail-collapsed { .rail-collapsed {
overflow: visible; overflow: visible;
} }

View File

@@ -408,197 +408,477 @@
display: inline-flex; display: inline-flex;
} }
.notification-wrap.is-open .notification-btn {
border: 1px solid var(--theme-primary-light-5);
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
}
.notification-btn {
position: relative;
overflow: visible;
border: 1px solid transparent;
border-radius: 4px;
transition:
border-color 180ms var(--ease),
background 180ms var(--ease),
color 180ms var(--ease);
}
.notification-badge { .notification-badge {
position: absolute; position: absolute;
top: 2px; top: 2px;
right: 1px; right: 2px;
min-width: 13px; min-width: 16px;
height: 13px; height: 16px;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 3px; padding: 0 4px;
border: 2px solid #fff; border: 2px solid #fff;
border-radius: 999px; border-radius: 4px;
background: #ef4444; background: #dc2626;
color: #fff; color: #fff;
font-size: 8px; font-size: 10px;
font-weight: 850; font-weight: 800;
line-height: 1; line-height: 1;
box-shadow: 0 5px 10px rgba(239, 68, 68, .22); letter-spacing: -0.02em;
}
.notification-panel-enter-active,
.notification-panel-leave-active {
transition:
opacity 220ms var(--ease),
transform 220ms var(--ease);
}
.notification-panel-enter-from,
.notification-panel-leave-to {
opacity: 0;
transform: translateY(-6px);
}
@media (prefers-reduced-motion: reduce) {
.notification-panel-enter-active,
.notification-panel-leave-active {
transition-duration: 1ms;
}
.notification-panel-enter-from,
.notification-panel-leave-to {
transform: none;
}
} }
.notification-popover { .notification-popover {
position: absolute; position: absolute;
top: calc(100% + 10px); top: calc(100% + 8px);
right: -8px; right: 0;
z-index: 60; z-index: 60;
width: min(360px, calc(100vw - 32px)); width: clamp(340px, 34vw, 420px);
max-width: calc(100vw - 24px);
max-height: min(520px, calc(100vh - 96px));
display: grid; display: grid;
gap: 10px; grid-template-rows: auto auto minmax(0, 1fr);
padding: 12px; gap: 0;
border: 1px solid #e5edf5; overflow: hidden;
border: 1px solid #d7e0ea;
border-radius: 4px; border-radius: 4px;
background: rgba(255, 255, 255, 0.98); background: #fff;
box-shadow: box-shadow: 0 16px 40px rgba(15, 23, 42, 0.12);
0 18px 42px rgba(15, 23, 42, 0.14),
inset 0 1px 0 rgba(255, 255, 255, 0.92);
} }
.notification-popover::before { .notification-popover::before {
content: ""; content: "";
position: absolute; display: block;
top: -6px; height: 3px;
right: 18px; background: linear-gradient(
width: 10px; 90deg,
height: 10px; var(--theme-primary-active) 0%,
border-top: 1px solid #e5edf5; var(--theme-primary-light-3, #7eb3d4) 100%
border-left: 1px solid #e5edf5; );
background: #fff;
transform: rotate(45deg);
}
.notification-head,
.notification-tabs {
position: relative;
z-index: 1;
display: flex;
align-items: center;
} }
.notification-head { .notification-head {
display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px;
padding: 12px 14px 10px;
border-bottom: 1px solid #edf2f7;
background: #fafbfd;
}
.notification-head-brand {
min-width: 0;
flex: 1 1 auto;
display: flex;
align-items: center;
gap: 10px; gap: 10px;
} }
.notification-head-icon {
width: 32px;
height: 32px;
flex: 0 0 auto;
display: grid;
place-items: center;
border: 1px solid var(--theme-primary-light-6);
border-radius: 4px;
background: #fff;
color: var(--theme-primary-active);
font-size: 17px;
}
.notification-head-copy {
min-width: 0;
display: grid;
gap: 2px;
}
.notification-head strong { .notification-head strong {
color: #0f172a; color: #0f172a;
font-size: 14px; font-size: 14px;
font-weight: 850; font-weight: 800;
letter-spacing: 0.01em;
} }
.notification-head button { .notification-head small {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 4px;
color: #64748b; color: #64748b;
font-size: 12px;
font-weight: 600;
} }
.notification-head button:hover { .notification-head-actions {
background: #f1f5f9; display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
}
.notification-clear-btn {
height: 28px;
padding: 0 10px;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--theme-primary-active);
font-size: 12px;
font-weight: 750;
white-space: nowrap;
transition:
background 160ms var(--ease),
color 160ms var(--ease);
}
.notification-clear-btn:hover:not(:disabled) {
background: var(--theme-primary-light-9);
}
.notification-clear-btn:disabled {
color: #cbd5e1;
cursor: not-allowed;
}
.notification-close-btn {
width: 28px;
height: 28px;
display: inline-grid;
place-items: center;
border: 0;
border-radius: 4px;
background: transparent;
color: #64748b;
font-size: 18px;
transition:
background 160ms var(--ease),
color 160ms var(--ease);
}
.notification-close-btn:hover {
background: #eef2f7;
color: #0f172a; color: #0f172a;
} }
.notification-tabs { .notification-tabs {
gap: 6px; display: flex;
padding: 3px; align-items: stretch;
border: 1px solid #edf2f7; gap: 0;
border-radius: 4px; padding: 0 14px;
background: #f8fafc; border-bottom: 1px solid #edf2f7;
background: #fff;
} }
.notification-tabs button { .notification-tabs button {
position: relative;
flex: 1 1 0; flex: 1 1 0;
height: 28px; height: 38px;
border-radius: 3px; display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
border: 0;
border-bottom: 2px solid transparent;
margin-bottom: -1px;
background: transparent;
color: #64748b; color: #64748b;
font-size: 12px; font-size: 13px;
font-weight: 750;
transition:
color 180ms var(--ease),
border-color 180ms var(--ease);
}
.notification-tabs button em {
min-width: 20px;
height: 20px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 6px;
border-radius: 4px;
background: #f1f5f9;
color: #475569;
font-style: normal;
font-size: 11px;
font-weight: 800; font-weight: 800;
line-height: 1;
transition:
background 180ms var(--ease),
color 180ms var(--ease);
} }
.notification-tabs button.active { .notification-tabs button.active {
background: #fff;
color: var(--theme-primary-active); color: var(--theme-primary-active);
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); border-bottom-color: var(--theme-primary-active);
}
.notification-tabs button.active em {
background: var(--theme-primary-light-9);
color: var(--theme-primary-active);
} }
.notification-list { .notification-list {
position: relative; display: flex;
z-index: 1; flex-direction: column;
display: grid; min-height: 0;
max-height: 320px; max-height: min(336px, calc(100vh - 226px));
overflow: auto; overflow-x: hidden;
overflow-y: auto;
padding: 6px 0;
scrollbar-width: thin;
scrollbar-color: #cbd5e1 #f8fafc;
}
.notification-list::-webkit-scrollbar {
width: 6px;
}
.notification-list::-webkit-scrollbar-track {
background: #f8fafc;
}
.notification-list::-webkit-scrollbar-thumb {
border-radius: 4px;
background: #cbd5e1;
} }
.notification-row { .notification-row {
display: grid; display: grid;
grid-template-columns: 8px minmax(0, 1fr) 16px; grid-template-columns: 34px minmax(0, 1fr) 16px;
align-items: center; align-items: center;
gap: 10px; gap: 9px;
padding: 10px 4px; min-height: 0;
border-top: 1px solid #edf2f7; padding: 10px 14px;
border: 0;
border-left: 3px solid transparent;
border-radius: 0;
background: transparent;
text-align: left; text-align: left;
transition:
background 180ms var(--ease),
border-color 180ms var(--ease);
} }
.notification-row:first-child { .notification-row + .notification-row {
border-top: 0; border-top: 1px solid #f1f5f9;
}
.notification-row.unread {
border-left-color: var(--theme-primary-active);
background: linear-gradient(90deg, var(--theme-primary-light-9) 0%, #fff 42%);
} }
.notification-row:hover { .notification-row:hover {
background: #f8fafc; background: #f8fafc;
} }
.notification-dot { .notification-row.unread:hover {
width: 7px; background: linear-gradient(90deg, var(--theme-primary-light-8) 0%, #f8fafc 48%);
height: 7px;
border-radius: 999px;
background: var(--theme-primary);
} }
.notification-dot.danger { background: #ef4444; } .notification-type-icon {
.notification-dot.warning { background: #f59e0b; } width: 34px;
.notification-dot.success { background: var(--success); } height: 34px;
.notification-dot.info { background: #3b82f6; } display: grid;
place-items: center;
border: 1px solid var(--theme-primary-light-6);
border-radius: 4px;
background: #fff;
color: var(--theme-primary-active);
font-size: 18px;
}
.notification-type-icon.danger {
border-color: #fecaca;
background: #fff5f5;
color: #dc2626;
}
.notification-type-icon.warning {
border-color: #fde68a;
background: #fffbeb;
color: #d97706;
}
.notification-type-icon.success {
border-color: #bbf7d0;
background: #f0fdf4;
color: #16a34a;
}
.notification-type-icon.info {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.notification-copy { .notification-copy {
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: 2px; gap: 4px;
} }
.notification-copy strong, .notification-copy strong {
.notification-copy small,
.notification-copy em {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.notification-title-line {
min-width: 0;
display: flex;
align-items: center;
gap: 6px;
}
.notification-copy strong { .notification-copy strong {
min-width: 0;
color: #0f172a; color: #0f172a;
font-size: 13px; font-size: 13px;
font-weight: 850; font-weight: 800;
}
.notification-title-line b {
flex: 0 0 auto;
min-width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 5px;
border-radius: 4px;
background: #dc2626;
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 1;
} }
.notification-copy small { .notification-copy small {
display: -webkit-box;
overflow: hidden;
color: #475569; color: #475569;
font-size: 12px; font-size: 12px;
line-height: 1.4;
white-space: normal;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
} }
.notification-copy em { .notification-meta {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.notification-meta em,
.notification-meta time {
min-width: 0;
overflow: hidden;
color: #94a3b8; color: #94a3b8;
font-size: 11px; font-size: 11px;
font-style: normal; font-style: normal;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
} }
.notification-row > .mdi { .notification-meta em {
color: #94a3b8; flex: 1 1 auto;
font-size: 16px; }
.notification-meta time {
flex: 0 0 auto;
}
.notification-row-arrow {
color: #cbd5e1;
font-size: 18px;
transition: color 160ms var(--ease), transform 160ms var(--ease);
}
.notification-row:hover .notification-row-arrow {
color: var(--theme-primary-active);
transform: translateX(2px);
} }
.notification-empty { .notification-empty {
min-height: 112px; min-height: 148px;
display: grid; display: grid;
place-items: center; place-items: center;
gap: 8px; justify-items: center;
color: #94a3b8; gap: 6px;
font-size: 13px; padding: 24px 20px;
text-align: center;
} }
.notification-empty .mdi { .notification-empty-icon {
font-size: 24px; width: 44px;
height: 44px;
display: grid;
place-items: center;
margin-bottom: 4px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #f8fafc;
color: #94a3b8;
font-size: 22px;
}
.notification-empty strong {
color: #334155;
font-size: 14px;
font-weight: 800;
}
.notification-empty > span:last-child {
max-width: 220px;
color: #94a3b8;
font-size: 12px;
line-height: 1.5;
} }
.company-switcher { .company-switcher {
@@ -867,6 +1147,37 @@
gap: 10px; gap: 10px;
} }
.notification-popover {
position: fixed;
top: 72px;
right: 12px;
left: 12px;
width: auto;
max-width: none;
max-height: calc(100vh - 92px);
}
.notification-head {
align-items: flex-start;
padding: 11px 12px 9px;
}
.notification-head-actions {
gap: 2px;
}
.notification-clear-btn {
padding: 0 8px;
}
.notification-list {
max-height: calc(100vh - 218px);
}
.notification-row {
padding: 9px 12px;
}
.company-switcher { .company-switcher {
flex: 1 1 auto; flex: 1 1 auto;
max-width: none; max-width: none;
@@ -911,6 +1222,41 @@
} }
@media (max-width: 420px) { @media (max-width: 420px) {
.notification-popover {
top: 64px;
right: 8px;
left: 8px;
max-height: calc(100vh - 78px);
}
.notification-head {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
}
.notification-head-brand {
gap: 8px;
}
.notification-head-icon,
.notification-type-icon {
width: 30px;
height: 30px;
}
.notification-tabs {
padding: 0 10px;
}
.notification-row {
grid-template-columns: 30px minmax(0, 1fr);
gap: 8px;
}
.notification-row-arrow {
display: none;
}
.topbar.detail-mode { .topbar.detail-mode {
gap: 6px; gap: 6px;
padding: 8px 12px; padding: 8px 12px;

View File

@@ -291,6 +291,7 @@
.receipt-all-field-grid { .receipt-all-field-grid {
max-height: clamp(360px, 60vh, 640px); max-height: clamp(360px, 60vh, 640px);
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 10px; gap: 10px;
padding-right: 4px; padding-right: 4px;
overflow-y: auto; overflow-y: auto;
@@ -666,6 +667,7 @@
} }
.receipt-static-grid, .receipt-static-grid,
.receipt-all-field-grid,
.receipt-data-list.association { .receipt-data-list.association {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -1000,6 +1000,13 @@
line-height: 1.45; line-height: 1.45;
} }
.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
overflow-y: auto;
resize: none;
}
.currency-editor { .currency-editor {
display: grid; display: grid;
grid-template-columns: 34px minmax(0, 1fr); grid-template-columns: 34px minmax(0, 1fr);
@@ -1062,6 +1069,37 @@
white-space: nowrap; white-space: nowrap;
} }
.expense-adjusted-amount {
display: grid;
justify-items: center;
gap: 3px;
}
.expense-original-amount {
color: #b91c1c;
font-size: 12px;
font-weight: 760;
text-decoration-line: line-through;
text-decoration-thickness: 2px;
text-decoration-color: rgba(185, 28, 28, .82);
white-space: nowrap;
}
.expense-reimbursable-amount {
color: #0f172a;
font-size: 13px;
font-weight: 880;
white-space: nowrap;
}
.expense-adjusted-amount em {
color: #991b1b;
font-size: 11px;
font-style: normal;
font-weight: 760;
white-space: nowrap;
}
.expense-filled-at strong { .expense-filled-at strong {
font-size: 12px; font-size: 12px;
white-space: nowrap; white-space: nowrap;
@@ -1950,12 +1988,46 @@
color: #0f172a; color: #0f172a;
} }
.risk-override-card textarea.risk-note-editor-textarea {
min-height: 34px;
max-height: 78px;
resize: none;
}
.risk-override-card textarea:focus { .risk-override-card textarea:focus {
border-color: #ef4444; border-color: #ef4444;
box-shadow: 0 0 0 3px rgba(239, 68, 68, .12); box-shadow: 0 0 0 3px rgba(239, 68, 68, .12);
outline: none; outline: none;
} }
.risk-override-submit-row {
display: grid;
gap: 6px;
}
.risk-override-save-btn {
min-height: 34px;
border: 1px solid #bfdbfe;
border-radius: 4px;
background: #eff6ff;
color: #1d4ed8;
font-size: 12px;
font-weight: 850;
cursor: pointer;
}
.risk-override-save-btn:disabled {
cursor: not-allowed;
opacity: .58;
}
.risk-override-submit-row span {
color: #64748b;
font-size: 12px;
line-height: 1.5;
text-align: center;
}
.validation-card { .validation-card {
border: 1px solid #e5e7eb; border: 1px solid #e5e7eb;
background: #ffffff; background: #ffffff;

View File

@@ -6,7 +6,7 @@
width="min(1040px, calc(100vw - 48px))" width="min(1040px, calc(100vw - 48px))"
:show-close="false" :show-close="false"
:lock-scroll="true" :lock-scroll="true"
:destroy-on-close="false" destroy-on-close
class="expense-profile-dialog" class="expense-profile-dialog"
modal-class="expense-profile-dialog-overlay" modal-class="expense-profile-dialog-overlay"
body-class="expense-profile-dialog-body" body-class="expense-profile-dialog-body"

View File

@@ -136,18 +136,19 @@ watch([() => props.visible, tagIdentity], ([visible]) => {
<style scoped> <style scoped>
.profile-tag-pager { .profile-tag-pager {
height: 100%;
min-height: 308px; min-height: 308px;
display: grid; display: grid;
grid-template-rows: 272px 26px; grid-template-rows: minmax(0, 1fr) 26px;
gap: 10px; gap: 10px;
} }
.profile-tag-list { .profile-tag-list {
display: grid; display: grid;
grid-template-rows: repeat(5, 48px); grid-template-rows: repeat(5, minmax(48px, 1fr));
align-content: start; align-content: stretch;
gap: 8px; gap: 8px;
min-height: 272px; min-height: 0;
animation: profileTagPageIn 180ms cubic-bezier(0.2, 0, 0, 1) both; animation: profileTagPageIn 180ms cubic-bezier(0.2, 0, 0, 1) both;
} }
@@ -159,7 +160,7 @@ watch([() => props.visible, tagIdentity], ([visible]) => {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
min-height: 48px; min-height: 0;
padding: 7px 10px; padding: 7px 10px;
border: 1px solid rgba(var(--tag-accent-rgb), 0.2); border: 1px solid rgba(var(--tag-accent-rgb), 0.2);
border-left: 3px solid rgb(var(--tag-accent-rgb)); border-left: 3px solid rgb(var(--tag-accent-rgb));

View File

@@ -6,6 +6,7 @@
width="min(980px, calc(100vw - 48px))" width="min(980px, calc(100vw - 48px))"
:show-close="false" :show-close="false"
:lock-scroll="true" :lock-scroll="true"
destroy-on-close
class="expense-stats-detail-dialog" class="expense-stats-detail-dialog"
modal-class="expense-stats-detail-overlay" modal-class="expense-stats-detail-overlay"
body-class="expense-stats-detail-body" body-class="expense-stats-detail-body"
@@ -53,6 +54,7 @@
<div v-if="distributionChartItems.length" class="expense-distribution-chart"> <div v-if="distributionChartItems.length" class="expense-distribution-chart">
<div class="expense-distribution-chart-layout"> <div class="expense-distribution-chart-layout">
<DonutChart <DonutChart
:key="distributionChartRenderKey"
class="expense-distribution-donut" class="expense-distribution-donut"
:items="distributionChartItems" :items="distributionChartItems"
:center-value="distributionCenterValue" :center-value="distributionCenterValue"
@@ -139,7 +141,7 @@
</template> </template>
<script setup> <script setup>
import { computed } from 'vue' import { computed, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs' import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs' import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import { ElTag } from 'element-plus/es/components/tag/index.mjs' import { ElTag } from 'element-plus/es/components/tag/index.mjs'
@@ -154,6 +156,7 @@ const props = defineProps({
}) })
const emit = defineEmits(['close']) const emit = defineEmits(['close'])
const chartRenderSeq = ref(0)
const summaryMetrics = computed(() => [ const summaryMetrics = computed(() => [
{ {
@@ -198,6 +201,7 @@ const distributionChartColors = [
'var(--theme-primary-active)' 'var(--theme-primary-active)'
] ]
const distributionCenterValue = computed(() => props.summary.totalAmountLabel || '¥0') const distributionCenterValue = computed(() => props.summary.totalAmountLabel || '¥0')
const distributionChartRenderKey = computed(() => `expense-distribution-${chartRenderSeq.value}`)
const distributionChartItems = computed(() => distributionRows.value.map((row, index) => ({ const distributionChartItems = computed(() => distributionRows.value.map((row, index) => ({
name: row.label, name: row.label,
value: Number(row.amount || 0), value: Number(row.amount || 0),
@@ -205,6 +209,15 @@ const distributionChartItems = computed(() => distributionRows.value.map((row, i
color: distributionChartColors[index % distributionChartColors.length] color: distributionChartColors[index % distributionChartColors.length]
}))) })))
watch(
() => props.visible,
(visible) => {
if (visible) {
chartRenderSeq.value += 1
}
}
)
function resolveDistributionColor(index) { function resolveDistributionColor(index) {
return distributionChartColors[index % distributionChartColors.length] return distributionChartColors[index % distributionChartColors.length]
} }

View File

@@ -205,18 +205,23 @@
:class="{ 'has-long-duration-divider': item.hasLongDurationDivider }" :class="{ 'has-long-duration-divider': item.hasLongDurationDivider }"
@click="openWorkbenchTarget(item)" @click="openWorkbenchTarget(item)"
> >
<span class="progress-time"> <span class="progress-time-wrapper">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time> <span class="expense-type-icon" :class="`expense-type-icon--${item.expenseTypeTone}`">
<small>更新</small> <i :class="item.expenseTypeIcon"></i>
</span>
<span class="progress-time">
<time :datetime="item.updatedAt || ''">{{ item.displayTime }}</time>
<small v-if="item.showTimeCapsule" class="time-capsule">更新时间</small>
<small v-if="item.showUpdateText">更新</small>
</span>
</span> </span>
<span class="progress-identity"> <span class="progress-identity">
<strong>{{ item.id }}</strong> <strong>{{ item.title }}</strong>
<small>{{ item.title }}</small> <small>{{ item.id }}</small>
</span> </span>
<span class="progress-type" :title="item.expenseTypeLabel || '其他费用'"> <span class="progress-type" :title="item.expenseTypeLabel || '其他费用'">
<small>费用类型</small>
<strong>{{ item.expenseTypeLabel || '其他费用' }}</strong> <strong>{{ item.expenseTypeLabel || '其他费用' }}</strong>
</span> </span>
@@ -237,8 +242,8 @@
</span> </span>
<span class="progress-result"> <span class="progress-result">
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
<strong>{{ item.amount }}</strong> <strong>{{ item.amount }}</strong>
<span class="progress-status" :class="`progress-status--${item.statusTone}`">{{ item.status }}</span>
</span> </span>
</button> </button>
</div> </div>
@@ -268,6 +273,9 @@
class="insight-metric-row" class="insight-metric-row"
:class="`insight-metric-row--${item.tone}`" :class="`insight-metric-row--${item.tone}`"
> >
<span class="insight-metric-icon" aria-hidden="true">
<i :class="item.icon"></i>
</span>
<span class="insight-metric-label">{{ item.label }}</span> <span class="insight-metric-label">{{ item.label }}</span>
<strong class="insight-metric-value"> <strong class="insight-metric-value">
{{ item.value }}<small v-if="item.unit">{{ item.unit }}</small> {{ item.value }}<small v-if="item.unit">{{ item.unit }}</small>
@@ -478,6 +486,15 @@ const currentUserProfileKey = computed(() => {
user.employee_no user.employee_no
].map((item) => String(item || '').trim()).filter(Boolean).join('|') ].map((item) => String(item || '').trim()).filter(Boolean).join('|')
}) })
function resolveExpenseTypeStyle(label) {
if (label === '差旅交通') return { icon: 'mdi mdi-airplane', tone: 'blue' }
if (label === '业务招待') return { icon: 'mdi mdi-silverware-fork-knife', tone: 'amber' }
if (label === '办公采购') return { icon: 'mdi mdi-cart-outline', tone: 'emerald' }
if (label === '培训会议') return { icon: 'mdi mdi-projector', tone: 'violet' }
if (label === '市场活动') return { icon: 'mdi mdi-bullhorn-outline', tone: 'cyan' }
return { icon: 'mdi mdi-receipt-text-outline', tone: 'muted' }
}
const visibleProgressItems = computed(() => { const visibleProgressItems = computed(() => {
const rows = Array.isArray(props.workbenchSummary.progressItems) const rows = Array.isArray(props.workbenchSummary.progressItems)
? props.workbenchSummary.progressItems ? props.workbenchSummary.progressItems
@@ -488,10 +505,18 @@ const visibleProgressItems = computed(() => {
isLongDuration: isLongDurationProgress(item?.updatedAt) isLongDuration: isLongDurationProgress(item?.updatedAt)
})) }))
return progressRows.map((item, index) => ({ return progressRows.map((item, index) => {
...item, const isCompleted = item.statusTone === 'muted';
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration const expenseStyle = resolveExpenseTypeStyle(item.expenseTypeLabel);
})) return {
...item,
expenseTypeIcon: expenseStyle.icon,
expenseTypeTone: expenseStyle.tone,
showTimeCapsule: !item.isLongDuration,
showUpdateText: item.isLongDuration && !isCompleted,
hasLongDurationDivider: item.isLongDuration && !progressRows[index - 1]?.isLongDuration
}
})
}) })
const LONG_DURATION_DAYS = 10 const LONG_DURATION_DAYS = 10

View File

@@ -3,12 +3,13 @@
</template> </template>
<script setup> <script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, shallowRef, watch } from 'vue' import { computed, shallowRef } from 'vue'
import { RadarChart as EChartsRadarChart } from 'echarts/charts' import { RadarChart as EChartsRadarChart } from 'echarts/charts'
import { RadarComponent, TooltipComponent } from 'echarts/components' import { RadarComponent, TooltipComponent } from 'echarts/components'
import { init, use } from 'echarts/core' import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers' import { CanvasRenderer } from 'echarts/renderers'
import { useEcharts } from '../../composables/useEcharts.js'
import { useThemeColors } from '../../composables/useThemeColors.js' import { useThemeColors } from '../../composables/useThemeColors.js'
use([RadarComponent, EChartsRadarChart, TooltipComponent, CanvasRenderer]) use([RadarComponent, EChartsRadarChart, TooltipComponent, CanvasRenderer])
@@ -32,8 +33,6 @@ const props = defineProps({
const themeColors = useThemeColors() const themeColors = useThemeColors()
const chartElement = shallowRef(null) const chartElement = shallowRef(null)
let chartInstance = null
let resizeObserver = null
const normalizedItems = computed(() => const normalizedItems = computed(() =>
props.items.map((item, index) => ({ props.items.map((item, index) => ({
@@ -62,8 +61,11 @@ const chartOptions = computed(() => {
return { return {
backgroundColor: 'transparent', backgroundColor: 'transparent',
animationDuration: 760, animation: true,
animationDuration: 980,
animationDurationUpdate: 760,
animationEasing: 'cubicOut', animationEasing: 'cubicOut',
animationEasingUpdate: 'cubicOut',
color: [primary], color: [primary],
tooltip: { tooltip: {
trigger: 'item', trigger: 'item',
@@ -123,6 +125,11 @@ const chartOptions = computed(() => {
{ {
name: props.label, name: props.label,
type: 'radar', type: 'radar',
animation: true,
animationDuration: 980,
animationDurationUpdate: 760,
animationEasing: 'cubicOut',
animationEasingUpdate: 'cubicOut',
symbol: 'circle', symbol: 'circle',
symbolSize: 7, symbolSize: 7,
data: [ data: [
@@ -169,56 +176,7 @@ const chartOptions = computed(() => {
} }
}) })
onMounted(() => { useEcharts(chartElement, chartOptions)
renderChart()
bindResize()
})
onBeforeUnmount(() => {
unbindResize()
if (chartInstance) {
chartInstance.dispose()
chartInstance = null
}
})
watch(chartOptions, () => {
nextTick(renderChart)
}, { deep: true })
function renderChart() {
if (!chartElement.value) {
return
}
if (!chartInstance) {
chartInstance = init(chartElement.value, null, { renderer: 'canvas' })
}
chartInstance.setOption(chartOptions.value, true)
chartInstance.resize()
}
function bindResize() {
if (!chartElement.value) {
return
}
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => {
chartInstance?.resize()
})
resizeObserver.observe(chartElement.value)
}
window.addEventListener('resize', handleResize)
}
function unbindResize() {
resizeObserver?.disconnect()
resizeObserver = null
window.removeEventListener('resize', handleResize)
}
function handleResize() {
chartInstance?.resize()
}
function formatTooltip() { function formatTooltip() {
const rows = normalizedItems.value.map((item) => ( const rows = normalizedItems.value.map((item) => (

View File

@@ -51,7 +51,6 @@
> >
<span class="nav-icon" v-html="item.icon"></span> <span class="nav-icon" v-html="item.icon"></span>
<span class="nav-label">{{ item.displayLabel }}</span> <span class="nav-label">{{ item.displayLabel }}</span>
<span v-if="item.hasNewMessage" class="nav-unread-dot" aria-hidden="true"></span>
<span v-if="item.badge" class="nav-badge">{{ item.badge }}</span> <span v-if="item.badge" class="nav-badge">{{ item.badge }}</span>
</button> </button>
</ElTooltip> </ElTooltip>
@@ -113,9 +112,7 @@
<script setup> <script setup>
import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs' import { ElTooltip } from 'element-plus/es/components/tooltip/index.mjs'
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from 'vue' import { computed, onBeforeUnmount, reactive, ref, watch } from 'vue'
import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
const props = defineProps({ const props = defineProps({
navItems: { type: Array, required: true }, navItems: { type: Array, required: true },
@@ -144,14 +141,6 @@ const props = defineProps({
const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse']) const emit = defineEmits(['navigate', 'openChat', 'logout', 'toggle-collapse'])
const {
hasUnread: documentInboxHasUnread,
refreshDocumentInbox,
startDocumentInboxPolling,
stopDocumentInboxPolling
} = useDocumentCenterInbox()
let inboxInitialRefreshTimer = null
const sidebarMeta = { const sidebarMeta = {
overview: { label: '分析看板' }, overview: { label: '分析看板' },
workbench: { label: '个人工作台' }, workbench: { label: '个人工作台' },
@@ -168,35 +157,10 @@ const decoratedNavItems = computed(() =>
props.navItems.map((item) => ({ props.navItems.map((item) => ({
...item, ...item,
displayLabel: sidebarMeta[item.id]?.label ?? item.label, displayLabel: sidebarMeta[item.id]?.label ?? item.label,
hasNewMessage: item.id === 'documents' ? documentInboxHasUnread.value : false,
badge: sidebarMeta[item.id]?.badge badge: sidebarMeta[item.id]?.badge
})) }))
) )
function clearInboxInitialRefreshTimer() {
if (inboxInitialRefreshTimer && typeof window !== 'undefined') {
window.clearTimeout(inboxInitialRefreshTimer)
inboxInitialRefreshTimer = null
}
}
function scheduleInboxInitialRefresh() {
if (typeof window === 'undefined') {
return
}
clearInboxInitialRefreshTimer()
inboxInitialRefreshTimer = window.setTimeout(() => {
inboxInitialRefreshTimer = null
void refreshDocumentInbox()
}, props.activeView === 'documents' ? 1200 : 6000)
}
onMounted(() => {
scheduleInboxInitialRefresh()
startDocumentInboxPolling()
})
const displayUser = computed(() => ({ const displayUser = computed(() => ({
name: props.currentUser?.name || '系统管理员', name: props.currentUser?.name || '系统管理员',
@@ -290,19 +254,7 @@ watch(
} }
) )
watch(
() => props.activeView,
(activeView, previousView) => {
if (activeView === 'documents' && previousView !== 'documents') {
clearInboxInitialRefreshTimer()
void refreshDocumentInbox({ force: true })
}
}
)
onBeforeUnmount(() => { onBeforeUnmount(() => {
clearInboxInitialRefreshTimer()
stopDocumentInboxPolling()
closeCollapsedUserMenuNow() closeCollapsedUserMenuNow()
}) })
</script> </script>

View File

@@ -1,7 +1,7 @@
<template> <template>
<header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }"> <header class="topbar" :class="{ 'chat-mode': isChat, 'detail-mode': isRequestDetail }">
<div class="title-group"> <div class="title-group">
<div class="eyebrow">{{ eyebrowLabel }}</div> <div class="eyebrow">{{ eyebrowLabel }}</div>
<h1>{{ currentView.title }}</h1> <h1>{{ currentView.title }}</h1>
<p>{{ currentView.desc }}</p> <p>{{ currentView.desc }}</p>
</div> </div>
@@ -40,10 +40,10 @@
</div> </div>
</div> </div>
<div class="custom-range-wrap"> <div class="custom-range-wrap">
<button <button
class="custom-range-btn" class="custom-range-btn"
type="button" type="button"
:class="{ active: isCustomRange }" :class="{ active: isCustomRange }"
:aria-expanded="calendarOpen" :aria-expanded="calendarOpen"
aria-haspopup="dialog" aria-haspopup="dialog"
@@ -77,126 +77,165 @@
<button class="apply-btn" type="button" :disabled="!canApplyCustomRange" @click="applyCustomRange"> <button class="apply-btn" type="button" :disabled="!canApplyCustomRange" @click="applyCustomRange">
应用 应用
</button> </button>
</footer> </footer>
</div> </div>
</div> </div>
<div class="dashboard-switch-wrap">
<EnterpriseSelect
v-model="overviewDashboardValue"
class="dashboard-switch-select"
:options="overviewDashboardOptions"
aria-label="选择看板类型"
size="default"
/>
</div>
</div>
</template>
<template v-else-if="isRequestDetail">
<div class="detail-topbar-actions">
<div v-if="detailKpis.length" class="kpi-chips detail-kpi-chips">
<div
v-for="kpi in detailKpis"
:key="kpi.label"
class="kpi-chip detail-kpi-chip"
:style="{ '--chip-color': kpi.color }"
>
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
</div>
</div>
<div v-if="detailAlerts.length" class="detail-alert-strip">
<span
v-for="alert in detailAlerts"
:key="alert.label"
class="detail-alert-pill"
:class="alert.tone"
>
<i :class="alert.icon || 'mdi mdi-alert-circle-outline'"></i>
<span>{{ alert.label }}</span>
</span>
</div>
</div>
</template>
<template v-else-if="isWorkbench"> <div class="dashboard-switch-wrap">
<div class="topbar-toolset" aria-label="工作台快捷工具"> <EnterpriseSelect
<div class="notification-wrap"> v-model="overviewDashboardValue"
<button class="dashboard-switch-select"
class="topbar-icon-btn notification-btn" :options="overviewDashboardOptions"
type="button" aria-label="选择看板类型"
aria-label="通知" size="default"
:aria-expanded="notificationOpen" />
aria-haspopup="dialog" </div>
@click="notificationOpen = !notificationOpen" </div>
> </template>
<i class="mdi mdi-bell-outline"></i>
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span> <template v-else-if="isRequestDetail">
</button> <div class="detail-topbar-actions">
<div v-if="detailKpis.length" class="kpi-chips detail-kpi-chips">
<div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心"> <div
<header class="notification-head"> v-for="kpi in detailKpis"
<strong>通知</strong> :key="kpi.label"
<button type="button" aria-label="关闭通知" @click="notificationOpen = false"> class="kpi-chip detail-kpi-chip"
<i class="mdi mdi-close"></i> :style="{ '--chip-color': kpi.color }"
</button> >
</header> <span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span>
<div class="notification-tabs" role="tablist" aria-label="通知状态"> <span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
<button </div>
type="button" </div>
role="tab" <div v-if="detailAlerts.length" class="detail-alert-strip">
:aria-selected="notificationTab === 'unread'" <span
:class="{ active: notificationTab === 'unread' }" v-for="alert in detailAlerts"
@click="notificationTab = 'unread'" :key="alert.label"
> class="detail-alert-pill"
未读 {{ unreadNotifications.length }} :class="alert.tone"
</button> >
<button <i :class="alert.icon || 'mdi mdi-alert-circle-outline'"></i>
type="button" <span>{{ alert.label }}</span>
role="tab" </span>
:aria-selected="notificationTab === 'read'" </div>
:class="{ active: notificationTab === 'read' }" </div>
@click="notificationTab = 'read'" </template>
>
已读 {{ readNotifications.length }} <template v-else-if="isWorkbench">
</button> <div class="topbar-toolset" aria-label="工作台快捷工具">
</div> <div class="notification-wrap" :class="{ 'is-open': notificationOpen }">
<button
<div v-if="activeNotifications.length" class="notification-list"> class="topbar-icon-btn notification-btn"
<button type="button"
v-for="item in activeNotifications" aria-label="通知"
:key="item.id" :aria-expanded="notificationOpen"
type="button" aria-haspopup="dialog"
class="notification-row" @click="notificationOpen = !notificationOpen"
@click="openNotification(item)" >
> <i class="mdi mdi-bell-outline"></i>
<span class="notification-dot" :class="item.tone"></span> <span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
<span class="notification-copy"> </button>
<strong>{{ item.title }}</strong>
<small>{{ item.description }}</small> <Transition name="notification-panel">
<em>{{ item.time }}</em> <div v-if="notificationOpen" class="notification-popover" role="dialog" aria-label="通知中心">
</span> <header class="notification-head">
<i class="mdi mdi-chevron-right"></i> <div class="notification-head-brand">
</button> <span class="notification-head-icon" aria-hidden="true">
</div> <i class="mdi mdi-bell-ring-outline"></i>
<div v-else class="notification-empty"> </span>
<i class="mdi mdi-bell-check-outline"></i> <span class="notification-head-copy">
<span>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</span> <strong>通知中心</strong>
</div> <small>{{ unreadNotifications.length ? `${unreadNotifications.length} 条待处理` : '暂无待处理通知' }}</small>
</div> </span>
</div> </div>
<span class="notification-head-actions">
<button class="topbar-icon-btn" type="button" aria-label="帮助"> <button
<i class="mdi mdi-help-circle-outline"></i> class="notification-clear-btn"
</button> type="button"
:disabled="notificationItems.length === 0"
<button class="company-switcher" type="button" aria-label="切换公司"> @click="clearAllNotifications"
<span>{{ displayCompanyName }}</span> >
<i class="mdi mdi-chevron-down"></i> 清空通知
</button> </button>
<button
class="notification-close-btn"
type="button"
aria-label="关闭通知"
@click="notificationOpen = false"
>
<i class="mdi mdi-close"></i>
</button>
</span>
</header>
<div class="notification-tabs" role="tablist" aria-label="通知状态">
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'unread'"
:class="{ active: notificationTab === 'unread' }"
@click="notificationTab = 'unread'"
>
<span>未读</span>
<em>{{ unreadNotifications.length }}</em>
</button>
<button
type="button"
role="tab"
:aria-selected="notificationTab === 'read'"
:class="{ active: notificationTab === 'read' }"
@click="notificationTab = 'read'"
>
<span>已读</span>
<em>{{ readNotifications.length }}</em>
</button>
</div>
<div v-if="activeNotifications.length" class="notification-list">
<button
v-for="item in activeNotifications"
:key="item.id"
type="button"
class="notification-row"
:class="{ unread: item.unread }"
@click="openNotification(item)"
>
<span class="notification-type-icon" :class="item.tone">
<i :class="resolveNotificationIcon(item)"></i>
</span>
<span class="notification-copy">
<span class="notification-title-line">
<strong>{{ item.title }}</strong>
<b v-if="item.badge">{{ item.badge }}</b>
</span>
<small>{{ item.description }}</small>
<span class="notification-meta">
<em>{{ item.category || '系统通知' }}</em>
<time>{{ item.time }}</time>
</span>
</span>
<i class="mdi mdi-chevron-right notification-row-arrow"></i>
</button>
</div>
<div v-else class="notification-empty">
<span class="notification-empty-icon" aria-hidden="true">
<i class="mdi mdi-bell-check-outline"></i>
</span>
<strong>{{ notificationTab === 'unread' ? '暂无未读通知' : '暂无已读通知' }}</strong>
<span>新的单据与待办会在这里汇总展示</span>
</div>
</div>
</Transition>
</div>
<button class="topbar-icon-btn" type="button" aria-label="帮助">
<i class="mdi mdi-help-circle-outline"></i>
</button>
<button class="company-switcher" type="button" aria-label="切换公司">
<span>{{ displayCompanyName }}</span>
<i class="mdi mdi-chevron-down"></i>
</button>
</div> </div>
</template> </template>
@@ -220,24 +259,24 @@
</div> </div>
</template> </template>
<template v-else-if="showDigitalEmployeeWorkRecordKpis"> <template v-else-if="showDigitalEmployeeWorkRecordKpis">
<div class="kpi-chips"> <div class="kpi-chips">
<div <div
v-for="kpi in digitalEmployeeWorkRecordKpis" v-for="kpi in digitalEmployeeWorkRecordKpis"
:key="kpi.label" :key="kpi.label"
class="kpi-chip" class="kpi-chip"
:style="{ '--chip-color': kpi.color }" :style="{ '--chip-color': kpi.color }"
> >
<span class="chip-value">{{ kpi.value }}<small></small></span> <span class="chip-value">{{ kpi.value }}<small></small></span>
<span class="chip-label">{{ kpi.label }}</span> <span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span> <span class="chip-delta" :class="kpi.trend">{{ kpi.delta }} <i :class="kpi.arrow"></i></span>
</div> </div>
</div> </div>
</template> </template>
<template v-else-if="isApproval"> <template v-else-if="isApproval">
<div class="kpi-chips"> <div class="kpi-chips">
<div v-for="kpi in approvalKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }"> <div v-for="kpi in approvalKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
<span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span> <span class="chip-value">{{ kpi.value }}<small>{{ kpi.unit }}</small></span>
<span class="chip-label">{{ kpi.label }}</span> <span class="chip-label">{{ kpi.label }}</span>
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span> <span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
@@ -273,10 +312,12 @@
</header> </header>
</template> </template>
<script setup> <script setup>
import { computed, ref, watch } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue' import { useDocumentCenterInbox } from '../../composables/useDocumentCenterInbox.js'
import { useTopBarNotificationStates } from '../../composables/useTopBarNotificationStates.js'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
const props = defineProps({ const props = defineProps({
currentView: { type: Object, required: true }, currentView: { type: Object, required: true },
@@ -292,102 +333,276 @@ const props = defineProps({
type: Object, type: Object,
default: () => null default: () => null
}, },
requestSummary: { requestSummary: {
type: Object, type: Object,
default: () => null default: () => null
}, },
documentSummary: { documentSummary: {
type: Object, type: Object,
default: () => null default: () => null
}, },
digitalEmployeeSummary: { digitalEmployeeSummary: {
type: Object, type: Object,
default: () => null default: () => null
}, },
workbenchSummary: { workbenchSummary: {
type: Object, type: Object,
default: () => null default: () => null
}, },
companyName: { companyName: {
type: String, type: String,
default: '' default: ''
}, },
detailMode: { detailMode: {
type: Boolean, type: Boolean,
default: false default: false
}, },
detailAlerts: { detailAlerts: {
type: Array, type: Array,
default: () => [] default: () => []
}, },
detailKpis: { detailKpis: {
type: Array, type: Array,
default: () => [] default: () => []
}, },
customRange: { customRange: {
type: Object, type: Object,
default: () => ({ start: '2024-07-06', end: '2024-07-12' }) default: () => ({ start: '2024-07-06', end: '2024-07-12' })
}, },
overviewDashboard: { overviewDashboard: {
type: String, type: String,
default: 'finance' default: 'finance'
} }
}) })
const emit = defineEmits([ const emit = defineEmits([
'update:search', 'update:search',
'update:activeRange', 'update:activeRange',
'update:customRange', 'update:customRange',
'update:overviewDashboard', 'update:overviewDashboard',
'batchApprove', 'batchApprove',
'openChat', 'openChat',
'newApplication', 'newApplication',
'openDocument' 'openDocument',
]) 'navigate'
const isChat = computed(() => props.activeView === 'chat') ])
const isOverview = computed(() => props.activeView === 'overview') const isChat = computed(() => props.activeView === 'chat')
const isWorkbench = computed(() => props.activeView === 'workbench') const isOverview = computed(() => props.activeView === 'overview')
const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder', 'budget'].includes(props.activeView) && props.detailMode) const isWorkbench = computed(() => props.activeView === 'workbench')
const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode) const isRequestDetail = computed(() => ['requests', 'documents', 'audit', 'digitalEmployees', 'receiptFolder', 'budget'].includes(props.activeView) && props.detailMode)
const isRequests = computed(() => props.activeView === 'requests') const isDocuments = computed(() => props.activeView === 'documents' && !props.detailMode)
const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees') const isRequests = computed(() => props.activeView === 'requests')
const isApproval = computed(() => props.activeView === 'approval') const isDigitalEmployees = computed(() => props.activeView === 'digitalEmployees')
const isPolicies = computed(() => props.activeView === 'policies') const isApproval = computed(() => props.activeView === 'approval')
const isEmployees = computed(() => props.activeView === 'employees') const isPolicies = computed(() => props.activeView === 'policies')
const eyebrowLabel = computed(() => ( const isEmployees = computed(() => props.activeView === 'employees')
String(props.currentView?.eyebrow || '').trim() const eyebrowLabel = computed(() => (
|| (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations') String(props.currentView?.eyebrow || '').trim()
)) || (isChat.value ? 'Smart Finance Q&A' : 'Smart Expense Operations')
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司') ))
const topbarNotificationCount = computed(() => { const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
const summary = props.workbenchSummary ?? {} const MAX_NOTIFICATION_ITEMS = 30
const count = Number(summary.unreadNotificationCount ?? 0) const {
return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0 markDocumentInboxRowRead,
}) markDocumentInboxRowsRead,
const notificationOpen = ref(false) notificationRows: documentInboxNotificationRows,
const notificationTab = ref('unread') refreshDocumentInbox,
const notificationItems = computed(() => ( startDocumentInboxPolling,
Array.isArray(props.workbenchSummary?.notifications) stopDocumentInboxPolling
? props.workbenchSummary.notifications } = useDocumentCenterInbox()
: [] let documentInboxInitialRefreshTimer = null
)) const notificationOpen = ref(false)
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread)) const {
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread)) readNotificationIds,
const activeNotifications = computed(() => ( hideNotificationStates,
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value isNotificationHidden,
)) isNotificationRead,
loadNotificationStates,
function openNotification(item) { markNotificationStateRead
notificationOpen.value = false } = useTopBarNotificationStates()
const target = item?.target || {} const notificationTab = ref('unread')
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('openDocument', { function normalizeNotificationId(value) {
claimId: target.id, return String(value || '').trim()
id: target.id || target.claimNo, }
claimNo: target.claimNo
}) function formatNotificationTime(value) {
} const date = new Date(value)
} if (!Number.isFinite(date.getTime())) {
return '最近更新'
}
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
function resolveDocumentNotificationTone(row) {
if (row?.source === 'approval') {
return 'warning'
}
return row?.isUnread ? 'info' : 'success'
}
function resolveDocumentNotificationDescription(row) {
return [
row?.title,
row?.initiatorName ? `发起人 ${row.initiatorName}` : '',
row?.statusLabel ? `状态 ${row.statusLabel}` : ''
].filter(Boolean).join(' · ') || '单据中心有新的单据状态'
}
function resolveWorkbenchNotificationId(item, index) {
return normalizeNotificationId(`workbench:${item?.id || [item?.title, item?.description, item?.time, index].join('|')}`)
}
const documentNotificationItems = computed(() =>
documentInboxNotificationRows.value
.map((row) => {
const id = normalizeNotificationId(`document:${row.documentKey || row.claimId || row.documentNo}`)
if (!id || isNotificationHidden(id)) {
return null
}
const unread = Boolean(row.isUnread) && !isNotificationRead(id)
return {
id,
kind: 'document',
title: `${row.documentTypeLabel || '单据'} ${row.documentNo || row.claimId || '待生成'}`,
description: resolveDocumentNotificationDescription(row),
time: formatNotificationTime(row.updatedAt || row.createdAt),
category: row.sourceLabel || '单据中心',
tone: resolveDocumentNotificationTone({ ...row, isUnread: unread }),
unread,
icon: row.source === 'approval' ? 'mdi mdi-clipboard-text-clock-outline' : 'mdi mdi-file-document-outline',
badge: unread ? '新' : '',
target: {
type: 'document',
id: row.claimId,
claimNo: row.documentNo
},
documentRow: row
}
})
.filter(Boolean)
)
const workbenchNotificationItems = computed(() => (
Array.isArray(props.workbenchSummary?.notifications)
? props.workbenchSummary.notifications.map((item, index) => {
const id = resolveWorkbenchNotificationId(item, index)
if (!id || isNotificationHidden(id)) {
return null
}
return {
...item,
id,
kind: 'workbench',
category: item.category || '个人工作台',
unread: Boolean(item.unread) && !readNotificationIds.value.has(id),
icon: item.icon || resolveNotificationIcon(item)
}
}).filter(Boolean)
: []
))
const notificationItems = computed(() =>
[...documentNotificationItems.value, ...workbenchNotificationItems.value].slice(0, MAX_NOTIFICATION_ITEMS)
)
const unreadNotifications = computed(() => notificationItems.value.filter((item) => item.unread))
const readNotifications = computed(() => notificationItems.value.filter((item) => !item.unread))
const activeNotifications = computed(() => (
notificationTab.value === 'unread' ? unreadNotifications.value : readNotifications.value
))
const topbarNotificationCount = computed(() => {
const count = unreadNotifications.value.length
return count > 0 ? Math.min(count, 99) : 0
})
function clearDocumentInboxInitialRefreshTimer() {
if (documentInboxInitialRefreshTimer && typeof window !== 'undefined') {
window.clearTimeout(documentInboxInitialRefreshTimer)
documentInboxInitialRefreshTimer = null
}
}
function scheduleDocumentInboxInitialRefresh() {
if (typeof window === 'undefined') {
return
}
clearDocumentInboxInitialRefreshTimer()
documentInboxInitialRefreshTimer = window.setTimeout(() => {
documentInboxInitialRefreshTimer = null
void refreshDocumentInbox()
}, props.activeView === 'workbench' ? 1200 : 6000)
}
function resolveNotificationIcon(item) {
if (item?.icon) {
return item.icon
}
if (item?.tone === 'danger') {
return 'mdi mdi-alert-circle-outline'
}
if (item?.tone === 'warning') {
return 'mdi mdi-alert-outline'
}
if (item?.tone === 'success') {
return 'mdi mdi-check-circle-outline'
}
return 'mdi mdi-bell-outline'
}
function markNotificationRead(item) {
if (!item?.id || !item.unread) {
return
}
if (item.kind === 'document' && item.documentRow) {
markDocumentInboxRowRead(item.documentRow)
}
void markNotificationStateRead(item)
}
function clearAllNotifications() {
const currentItems = notificationItems.value
if (!currentItems.length) {
return
}
const documentRows = currentItems
.filter((item) => item.kind === 'document' && item.documentRow)
.map((item) => item.documentRow)
if (documentRows.length) {
markDocumentInboxRowsRead(documentRows)
}
void hideNotificationStates(currentItems)
notificationTab.value = 'unread'
}
function openNotification(item) {
markNotificationRead(item)
notificationOpen.value = false
const target = item?.target || {}
if (target.type === 'document' && (target.id || target.claimNo)) {
emit('openDocument', {
claimId: target.id,
id: target.id || target.claimNo,
claimNo: target.claimNo
})
}
}
const requestKpis = computed(() => { const requestKpis = computed(() => {
const summary = props.requestSummary ?? {} const summary = props.requestSummary ?? {}
@@ -397,10 +612,10 @@ const requestKpis = computed(() => {
const completed = Number(summary.completed ?? 0) const completed = Number(summary.completed ?? 0)
return [ return [
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' }, { label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '草稿', value: draft, delta: '待提交', trend: draft > 0 ? 'down' : 'up', arrow: draft > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' }, { label: '草稿', value: draft, delta: '待提交', trend: draft > 0 ? 'down' : 'up', arrow: draft > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
{ label: '审批中', value: inProgress, delta: '处理中', trend: inProgress > 0 ? 'up' : 'down', arrow: inProgress > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus', color: '#3b82f6' }, { label: '审批中', value: inProgress, delta: '处理中', trend: inProgress > 0 ? 'up' : 'down', arrow: inProgress > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus', color: '#3b82f6' },
{ label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: 'var(--success)' } { label: '已完成', value: completed, delta: '已归档', trend: 'up', arrow: 'mdi mdi-arrow-up' , color: 'var(--success)' }
] ]
}) })
@@ -412,64 +627,64 @@ const documentKpis = computed(() => {
const archived = Number(summary.archived ?? 0) const archived = Number(summary.archived ?? 0)
return [ return [
{ label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' }, { label: '全部单据', value: total, delta: '当前', trend: 'up', arrow: 'mdi mdi-minus', color: 'var(--theme-primary)' },
{ label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' }, { label: '待提交', value: toSubmit, delta: '草稿待办', trend: toSubmit > 0 ? 'down' : 'up', arrow: toSubmit > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#f59e0b' },
{ label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' }, { label: '待我处理', value: toProcess, delta: '审批待办', trend: toProcess > 0 ? 'down' : 'up', arrow: toProcess > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', color: '#3b82f6' },
{ label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: 'var(--success)' } { label: '已归档', value: archived, delta: '归档入账', trend: 'up', arrow: 'mdi mdi-arrow-up', color: 'var(--success)' }
] ]
}) })
const showDigitalEmployeeWorkRecordKpis = computed(() => { const showDigitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {} const summary = props.digitalEmployeeSummary ?? {}
return isDigitalEmployees.value && summary.section === 'workRecords' return isDigitalEmployees.value && summary.section === 'workRecords'
}) })
const digitalEmployeeWorkRecordKpis = computed(() => { const digitalEmployeeWorkRecordKpis = computed(() => {
const summary = props.digitalEmployeeSummary ?? {} const summary = props.digitalEmployeeSummary ?? {}
const total = Number(summary.total ?? 0) const total = Number(summary.total ?? 0)
const succeeded = Number(summary.succeeded ?? 0) const succeeded = Number(summary.succeeded ?? 0)
const failed = Number(summary.failed ?? 0) const failed = Number(summary.failed ?? 0)
return [ return [
{ {
label: '日志总数', label: '日志总数',
value: total, value: total,
delta: '当前', delta: '当前',
trend: 'up', trend: 'up',
arrow: 'mdi mdi-minus', arrow: 'mdi mdi-minus',
color: 'var(--theme-primary)' color: 'var(--theme-primary)'
}, },
{ {
label: '成功数量', label: '成功数量',
value: succeeded, value: succeeded,
delta: total ? `占比 ${Math.round((succeeded / total) * 100)}%` : '等待数据', delta: total ? `占比 ${Math.round((succeeded / total) * 100)}%` : '等待数据',
trend: 'up', trend: 'up',
arrow: succeeded > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus', arrow: succeeded > 0 ? 'mdi mdi-arrow-up' : 'mdi mdi-minus',
color: 'var(--success)' color: 'var(--success)'
}, },
{ {
label: '失败数量', label: '失败数量',
value: failed, value: failed,
delta: failed > 0 ? '需要关注' : '暂无失败', delta: failed > 0 ? '需要关注' : '暂无失败',
trend: failed > 0 ? 'down' : 'up', trend: failed > 0 ? 'down' : 'up',
arrow: failed > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus', arrow: failed > 0 ? 'mdi mdi-arrow-down' : 'mdi mdi-minus',
color: '#ef4444' color: '#ef4444'
} }
] ]
}) })
const chatKpis = [ const chatKpis = [
{ label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' }, { label: '今日已问数', value: 86, unit: '次', meta: '较昨日 +18', trend: 'up', color: 'var(--theme-primary)' },
{ label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' }, { label: '已解决问题', value: 72, unit: '条', meta: '解决率 83.7%', trend: 'up', color: '#3b82f6' },
{ label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' }, { label: '知识命中率', value: '92.3', unit: '%', meta: '较昨日 +2.6%', trend: 'up', color: '#8b5cf6' },
{ label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' } { label: '平均响应时长', value: 2.1, unit: 's', meta: '较昨日 -0.3s', trend: 'down', color: '#f59e0b' }
] ]
const approvalKpis = [ const approvalKpis = [
{ label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' }, { label: '待审批单据', value: 12, unit: '单', meta: '较昨日 +3', trend: 'up', color: 'var(--theme-primary)' },
{ label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' }, { label: '高风险单据', value: 4, unit: '单', meta: '较昨日 +1', trend: 'up', color: '#ef4444' },
{ label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' }, { label: '即将超时', value: 3, unit: '单', meta: '30 分钟内', trend: 'down', color: '#f59e0b' },
{ label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' } { label: '今日已处理', value: 28, unit: '单', meta: '通过率 86%', trend: 'up', color: 'var(--success)' }
] ]
const knowledgeKpis = computed(() => { const knowledgeKpis = computed(() => {
@@ -482,7 +697,7 @@ const knowledgeKpis = computed(() => {
value: String(totalDocuments), value: String(totalDocuments),
meta: '', meta: '',
trend: 'up', trend: 'up',
color: 'var(--theme-primary)' color: 'var(--theme-primary)'
} }
] ]
}) })
@@ -503,7 +718,7 @@ const employeeKpis = computed(() => {
unit: '人', unit: '人',
meta: `覆盖 ${departments} 个部门`, meta: `覆盖 ${departments} 个部门`,
trend: 'up', trend: 'up',
color: 'var(--theme-primary)' color: 'var(--theme-primary)'
}, },
{ {
label: '在职账号', label: '在职账号',
@@ -531,21 +746,21 @@ const employeeKpis = computed(() => {
} }
] ]
}) })
const calendarOpen = ref(false) const calendarOpen = ref(false)
const draftStart = ref(props.customRange.start) const draftStart = ref(props.customRange.start)
const draftEnd = ref(props.customRange.end) const draftEnd = ref(props.customRange.end)
const overviewDashboardOptions = [ const overviewDashboardOptions = [
{ label: '财务看板', value: 'finance' }, { label: '财务看板', value: 'finance' },
{ label: '风险看板', value: 'risk' }, { label: '风险看板', value: 'risk' },
{ label: '数字员工看板', value: 'digitalEmployee' }, { label: '数字员工看板', value: 'digitalEmployee' },
{ label: '系统看板', value: 'system' } { label: '系统看板', value: 'system' }
] ]
const overviewDashboardValue = computed({ const overviewDashboardValue = computed({
get: () => props.overviewDashboard, get: () => props.overviewDashboard,
set: (value) => emit('update:overviewDashboard', value) set: (value) => emit('update:overviewDashboard', value)
}) })
const rangeOptions = computed(() => const rangeOptions = computed(() =>
props.ranges.map((range, index) => ({ props.ranges.map((range, index) => ({
value: range, value: range,
label: String(range) label: String(range)
@@ -575,6 +790,34 @@ watch(
{ deep: true } { deep: true }
) )
watch(
() => props.activeView,
(activeView, previousView) => {
if (activeView === 'workbench' && previousView !== 'workbench') {
clearDocumentInboxInitialRefreshTimer()
void loadNotificationStates()
void refreshDocumentInbox({ force: true })
}
}
)
watch(notificationOpen, (open) => {
if (open) {
void loadNotificationStates()
}
})
onMounted(() => {
void loadNotificationStates()
scheduleDocumentInboxInitialRefresh()
startDocumentInboxPolling()
})
onBeforeUnmount(() => {
clearDocumentInboxInitialRefreshTimer()
stopDocumentInboxPolling()
})
function setRange(range) { function setRange(range) {
emit('update:activeRange', range) emit('update:activeRange', range)
calendarOpen.value = false calendarOpen.value = false
@@ -631,4 +874,4 @@ function buildPresetRangeLabel(label) {
} }
</script> </script>
<style scoped src="../../assets/styles/components/top-bar.css"></style> <style scoped src="../../assets/styles/components/top-bar.css"></style>

View File

@@ -4,6 +4,9 @@ import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims, fetchExpenseCla
import { import {
DOCUMENT_VIEWED_KEYS_CHANGE_EVENT, DOCUMENT_VIEWED_KEYS_CHANGE_EVENT,
countNewDocuments, countNewDocuments,
isNewDocument,
markDocumentViewed,
markDocumentsViewed,
readViewedDocumentKeys, readViewedDocumentKeys,
resolveDocumentNewKey resolveDocumentNewKey
} from '../utils/documentCenterNewState.js' } from '../utils/documentCenterNewState.js'
@@ -24,6 +27,12 @@ let refreshPromise = null
let lastRefreshAt = 0 let lastRefreshAt = 0
let viewedKeysListenerAttached = false let viewedKeysListenerAttached = false
const SOURCE_LABELS = {
owned: '我的单据',
approval: '待我处理',
archive: '归档单据'
}
function normalizeClaimText(...values) { function normalizeClaimText(...values) {
for (const value of values) { for (const value of values) {
const normalized = String(value || '').trim() const normalized = String(value || '').trim()
@@ -35,18 +44,41 @@ function normalizeClaimText(...values) {
return '' return ''
} }
function resolveSortTime(...values) {
for (const value of values) {
const time = Date.parse(String(value || '').trim())
if (Number.isFinite(time)) {
return time
}
}
return 0
}
function buildDocumentInboxRow(claim, source) { function buildDocumentInboxRow(claim, source) {
const request = mapExpenseClaimToRequest(claim) const request = mapExpenseClaimToRequest(claim)
const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id) const claimId = normalizeClaimText(request.claimId, request.id, claim?.id, claim?.claim_id)
const documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no) const documentNo = normalizeClaimText(request.documentNo, request.claimNo, request.id, claim?.claim_no)
const documentKey = normalizeClaimText(claimId, documentNo) const documentKey = normalizeClaimText(claimId, documentNo)
const createdAt = normalizeClaimText(request.createdAt, request.submittedAt, claim?.created_at, claim?.submitted_at)
const updatedAt = normalizeClaimText(request.updatedAt, request.approvedAt, claim?.updated_at, claim?.approved_at)
const documentTypeLabel = normalizeClaimText(request.documentTypeLabel, claim?.document_type_label) || '报销单'
return documentKey return documentKey
? { ? {
source, source,
claimId: claimId || documentKey, claimId: claimId || documentKey,
documentNo, documentNo,
documentKey: `${source}:${documentKey}` documentKey: `${source}:${documentKey}`,
documentTypeLabel,
sourceLabel: SOURCE_LABELS[source] || '单据中心',
title: normalizeClaimText(claim?.title, request.title, request.sceneLabel, request.note) || documentTypeLabel,
initiatorName: normalizeClaimText(request.initiatorName, request.person, claim?.employee_name, claim?.applicant_name),
statusLabel: normalizeClaimText(request.approvalStatus, request.status, claim?.approval_status, claim?.status),
createdAt,
updatedAt,
sortTime: resolveSortTime(updatedAt, createdAt),
rawRequest: request
} }
: null : null
} }
@@ -127,6 +159,25 @@ export function useDocumentCenterInbox() {
const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value)) const unreadCount = computed(() => countNewDocuments(documentRows.value, viewedDocumentKeys.value))
const hasUnread = computed(() => unreadCount.value > 0) const hasUnread = computed(() => unreadCount.value > 0)
const notificationRows = computed(() =>
documentRows.value
.filter((row) => String(row?.source || '').trim() !== 'archive')
.map((row) => ({
...row,
isUnread: isNewDocument(row, viewedDocumentKeys.value)
}))
.sort((left, right) => Number(right.sortTime || 0) - Number(left.sortTime || 0))
)
function markDocumentInboxRowRead(row) {
viewedDocumentKeys.value = markDocumentViewed(row, viewedDocumentKeys.value)
return viewedDocumentKeys.value
}
function markDocumentInboxRowsRead(rows = documentRows.value) {
viewedDocumentKeys.value = markDocumentsViewed(rows, viewedDocumentKeys.value)
return viewedDocumentKeys.value
}
async function refreshDocumentInbox(options = {}) { async function refreshDocumentInbox(options = {}) {
const force = Boolean(options.force) const force = Boolean(options.force)
@@ -191,6 +242,9 @@ export function useDocumentCenterInbox() {
return { return {
hasUnread, hasUnread,
loading, loading,
markDocumentInboxRowRead,
markDocumentInboxRowsRead,
notificationRows,
refreshDocumentInbox, refreshDocumentInbox,
startDocumentInboxPolling, startDocumentInboxPolling,
stopDocumentInboxPolling, stopDocumentInboxPolling,

View File

@@ -39,6 +39,7 @@ const LOCATION_REQUIRED_EXPENSE_TYPES = new Set([
]) ])
const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance']) const SYSTEM_GENERATED_EXPENSE_TYPES = new Set(['travel_allowance'])
const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket']) const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket'])
const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket']) const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket']) const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
@@ -83,6 +84,45 @@ function parseNumber(value) {
return Number.isFinite(nextValue) ? nextValue : 0 return Number.isFinite(nextValue) ? nextValue : 0
} }
function parseOptionalAmount(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null
}
const amount = Number(value)
return Number.isFinite(amount) && amount >= 0 ? amount : null
}
function buildStandardAdjustmentMapFromClaim(claim = {}) {
const flags = Array.isArray(claim?.risk_flags_json)
? claim.risk_flags_json
: Array.isArray(claim?.riskFlags)
? claim.riskFlags
: []
const adjustmentMap = new Map()
flags.forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
return
}
const itemId = String(flag.item_id || flag.itemId || '').trim()
const reimbursableAmount = parseOptionalAmount(flag.reimbursable_amount ?? flag.reimbursableAmount)
if (!itemId || reimbursableAmount === null) {
return
}
adjustmentMap.set(itemId, {
originalAmount: parseOptionalAmount(flag.original_amount ?? flag.originalAmount),
reimbursableAmount,
employeeAbsorbedAmount: parseOptionalAmount(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0,
message: String(flag.message || flag.summary || '').trim()
})
})
return adjustmentMap
}
function toDate(value) { function toDate(value) {
if (!value) { if (!value) {
return null return null
@@ -1272,6 +1312,7 @@ function buildExpenseItems(claim, riskMeta) {
return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType)) return Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(leftType)) - Number(SYSTEM_GENERATED_EXPENSE_TYPES.has(rightType))
}) })
const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim) const travelTimeLabelMap = buildTravelTimeLabelMap(sortedItems, claim)
const standardAdjustmentMap = buildStandardAdjustmentMapFromClaim(claim)
return sortedItems.map((item, index) => { return sortedItems.map((item, index) => {
const invoiceId = String(item?.invoice_id || '').trim() const invoiceId = String(item?.invoice_id || '').trim()
@@ -1286,6 +1327,10 @@ function buildExpenseItems(claim, riskMeta) {
const itemNote = String(item?.item_note || item?.itemNote || '').trim() const itemNote = String(item?.item_note || item?.itemNote || '').trim()
const itemAmount = parseNumber(item?.item_amount) const itemAmount = parseNumber(item?.item_amount)
const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充' const itemAmountDisplay = itemAmount > 0 ? formatAmount(itemAmount) : '待补充'
const standardAdjustment = standardAdjustmentMap.get(id) || null
const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
return { return {
id, id,
@@ -1297,6 +1342,15 @@ function buildExpenseItems(claim, riskMeta) {
itemLocation, itemLocation,
itemNote, itemNote,
itemAmount, itemAmount,
originalItemAmount,
originalAmountDisplay: originalItemAmount > 0 ? formatAmount(originalItemAmount) : itemAmountDisplay,
reimbursableAmount,
reimbursableAmountDisplay: reimbursableAmount > 0 ? formatAmount(reimbursableAmount) : '待补充',
employeeAbsorbedAmount,
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatAmount(employeeAbsorbedAmount) : '',
hasStandardAdjustment: reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount,
standardAdjustmentAccepted: Boolean(standardAdjustment),
standardAdjustmentMessage: standardAdjustment?.message || '',
invoiceId, invoiceId,
isSystemGenerated, isSystemGenerated,
dayLabel: resolveExpenseTimeLabel({ dayLabel: resolveExpenseTimeLabel({
@@ -1336,7 +1390,10 @@ export function mapExpenseClaimToRequest(claim) {
const riskSummary = riskMeta.summary const riskSummary = riskMeta.summary
const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel) const relatedApplication = isApplicationDocument ? null : resolveRelatedApplicationInfo(claim, typeLabel)
const expenseItems = buildExpenseItems(claim, riskMeta) const expenseItems = buildExpenseItems(claim, riskMeta)
const visibleExpenseAmount = expenseItems.reduce((sum, item) => sum + parseNumber(item.itemAmount), 0) const visibleExpenseAmount = expenseItems.reduce((sum, item) => {
const amount = parseOptionalAmount(item.reimbursableAmount) ?? parseNumber(item.itemAmount)
return sum + amount
}, 0)
const amountValue = relatedApplication const amountValue = relatedApplication
? expenseItems.length ? expenseItems.length
? visibleExpenseAmount ? visibleExpenseAmount

View File

@@ -0,0 +1,163 @@
import { ref } from 'vue'
import { fetchNotificationStates, patchNotificationStates } from '../services/notificationStates.js'
const NOTIFICATION_READ_STORAGE_KEY = 'x-financial.topbar.notifications.read'
const NOTIFICATION_HIDDEN_STORAGE_KEY = 'x-financial.topbar.notifications.hidden'
function normalizeNotificationId(value) {
return String(value || '').trim()
}
function readNotificationIdSet(storageKey) {
if (typeof window === 'undefined') {
return new Set()
}
try {
const parsed = JSON.parse(window.localStorage.getItem(storageKey) || '[]')
return new Set(Array.isArray(parsed) ? parsed.map(normalizeNotificationId).filter(Boolean) : [])
} catch {
return new Set()
}
}
function writeNotificationIdSet(storageKey, values) {
if (typeof window === 'undefined') {
return
}
window.localStorage.setItem(storageKey, JSON.stringify(Array.from(values).filter(Boolean)))
}
function mergeRemoteStateIntoSets(states, readIds, hiddenIds) {
for (const item of Array.isArray(states) ? states : []) {
const id = normalizeNotificationId(item?.notification_id || item?.notificationId)
if (!id) {
continue
}
if (item.read_at || item.readAt) {
readIds.add(id)
}
if (item.hidden_at || item.hiddenAt) {
hiddenIds.add(id)
}
}
}
function buildContext(item) {
const target = item?.target || {}
return {
kind: String(item?.kind || '').trim(),
category: String(item?.category || '').trim(),
target_type: String(target?.type || '').trim()
}
}
function buildPatch(item, flags) {
const notificationId = normalizeNotificationId(item?.id || item?.notification_id || item?.notificationId)
if (!notificationId) {
return null
}
return {
notification_id: notificationId,
read: Boolean(flags?.read),
hidden: Boolean(flags?.hidden),
context_json: buildContext(item)
}
}
export function useTopBarNotificationStates() {
const readNotificationIds = ref(readNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY))
const hiddenNotificationIds = ref(readNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY))
const notificationStateSyncing = ref(false)
const notificationStateError = ref('')
function persistLocalSets() {
writeNotificationIdSet(NOTIFICATION_READ_STORAGE_KEY, readNotificationIds.value)
writeNotificationIdSet(NOTIFICATION_HIDDEN_STORAGE_KEY, hiddenNotificationIds.value)
}
function applyLocalPatch(patch) {
if (!patch?.notification_id) {
return
}
if (patch.read) {
readNotificationIds.value.add(patch.notification_id)
}
if (patch.hidden) {
hiddenNotificationIds.value.add(patch.notification_id)
}
readNotificationIds.value = new Set(readNotificationIds.value)
hiddenNotificationIds.value = new Set(hiddenNotificationIds.value)
persistLocalSets()
}
function applyRemoteStates(states) {
const nextReadIds = new Set(readNotificationIds.value)
const nextHiddenIds = new Set(hiddenNotificationIds.value)
mergeRemoteStateIntoSets(states, nextReadIds, nextHiddenIds)
readNotificationIds.value = nextReadIds
hiddenNotificationIds.value = nextHiddenIds
persistLocalSets()
}
async function loadNotificationStates() {
notificationStateSyncing.value = true
notificationStateError.value = ''
try {
applyRemoteStates(await fetchNotificationStates())
} catch (error) {
notificationStateError.value = error?.message || '通知状态同步失败'
} finally {
notificationStateSyncing.value = false
}
}
async function syncNotificationPatches(patches) {
const normalizedPatches = (Array.isArray(patches) ? patches : []).filter(Boolean)
if (!normalizedPatches.length) {
return
}
normalizedPatches.forEach(applyLocalPatch)
notificationStateError.value = ''
try {
applyRemoteStates(await patchNotificationStates(normalizedPatches))
} catch (error) {
notificationStateError.value = error?.message || '通知状态同步失败'
}
}
function isNotificationHidden(id) {
return hiddenNotificationIds.value.has(normalizeNotificationId(id))
}
function isNotificationRead(id) {
return readNotificationIds.value.has(normalizeNotificationId(id))
}
function markNotificationStateRead(item) {
return syncNotificationPatches([buildPatch(item, { read: true })])
}
function hideNotificationStates(items) {
const patches = (Array.isArray(items) ? items : [])
.map((item) => buildPatch(item, { read: true, hidden: true }))
.filter(Boolean)
return syncNotificationPatches(patches)
}
return {
hiddenNotificationIds,
readNotificationIds,
notificationStateSyncing,
notificationStateError,
hideNotificationStates,
isNotificationHidden,
isNotificationRead,
loadNotificationStates,
markNotificationStateRead
}
}

View File

@@ -196,42 +196,48 @@ export function buildExpenseStatItems(summary = {}) {
label: '本月报销笔数', label: '本月报销笔数',
value: summary.monthlyCount ?? 0, value: summary.monthlyCount ?? 0,
unit: '笔', unit: '笔',
tone: 'primary' tone: 'primary',
icon: 'mdi mdi-text-box-check-outline'
}, },
{ {
key: 'monthly-amount', key: 'monthly-amount',
label: '本月报销金额', label: '本月报销金额',
value: summary.monthlyAmountLabel || '¥0', value: summary.monthlyAmountLabel || '¥0',
unit: '', unit: '',
tone: 'amount' tone: 'amount',
icon: 'mdi mdi-cash-multiple'
}, },
{ {
key: 'total-count', key: 'total-count',
label: '累计报销笔数', label: '累计报销笔数',
value: summary.totalCount ?? 0, value: summary.totalCount ?? 0,
unit: '笔', unit: '笔',
tone: 'muted' tone: 'muted',
icon: 'mdi mdi-clipboard-text-outline'
}, },
{ {
key: 'total-amount', key: 'total-amount',
label: '累计报销金额', label: '累计报销金额',
value: summary.totalAmountLabel || '¥0', value: summary.totalAmountLabel || '¥0',
unit: '', unit: '',
tone: 'amount' tone: 'amount',
icon: 'mdi mdi-bank-outline'
}, },
{ {
key: 'in-review', key: 'in-review',
label: '审批中单据', label: '审批中单据',
value: summary.inReviewCount ?? 0, value: summary.inReviewCount ?? 0,
unit: '笔', unit: '笔',
tone: 'warning' tone: 'warning',
icon: 'mdi mdi-timer-sand'
}, },
{ {
key: 'pending-payment', key: 'pending-payment',
label: '待付款单据', label: '待付款单据',
value: summary.pendingPaymentCount ?? 0, value: summary.pendingPaymentCount ?? 0,
unit: '笔', unit: '笔',
tone: 'info' tone: 'info',
icon: 'mdi mdi-credit-card-outline'
} }
] ]
} }

View File

@@ -0,0 +1,38 @@
import { apiRequest } from './api.js'
function normalizeStateItem(item) {
const notificationId = String(item?.notification_id || item?.notificationId || '').trim()
if (!notificationId) {
return null
}
return {
notification_id: notificationId,
read: Boolean(item?.read),
hidden: Boolean(item?.hidden),
context_json: item?.context_json && typeof item.context_json === 'object'
? item.context_json
: {}
}
}
export async function fetchNotificationStates() {
const payload = await apiRequest('/notification-states')
return Array.isArray(payload?.states) ? payload.states : []
}
export async function patchNotificationStates(states = []) {
const normalizedStates = (Array.isArray(states) ? states : [])
.map(normalizeStateItem)
.filter(Boolean)
if (!normalizedStates.length) {
return []
}
const payload = await apiRequest('/notification-states', {
method: 'POST',
body: JSON.stringify({ states: normalizedStates })
})
return Array.isArray(payload?.states) ? payload.states : []
}

View File

@@ -68,6 +68,13 @@ export function updateExpenseClaim(claimId, payload = {}) {
}) })
} }
export function acceptExpenseClaimStandardAdjustment(claimId, payload = {}) {
return apiRequest(`/reimbursements/claims/${encodeURIComponent(String(claimId || '').trim())}/standard-adjustment`, {
method: 'POST',
body: JSON.stringify(payload)
})
}
export function calculateTravelReimbursement(payload = {}) { export function calculateTravelReimbursement(payload = {}) {
return apiRequest('/reimbursements/travel-calculator', { return apiRequest('/reimbursements/travel-calculator', {
method: 'POST', method: 'POST',

View File

@@ -230,18 +230,22 @@ export function isCurrentDirectManagerForRequest(request, user) {
return managerNames.length > 0 && identityIntersects(managerNames, currentNames) return managerNames.length > 0 && identityIntersects(managerNames, currentNames)
} }
export function canAccessAppView(user, viewId) { export function canAccessAppView(user, viewId) {
if (!viewId || !user) { if (!viewId || !user) {
return false return false
} }
if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) { if (!DEFAULT_APP_VIEW_ORDER.includes(viewId)) {
return false return false
} }
if (viewId === 'budget') { if (viewId === 'workbench' && isPlatformAdminUser(user)) {
if (isPlatformAdminUser(user)) { return false
return true }
if (viewId === 'budget') {
if (isPlatformAdminUser(user)) {
return true
} }
const roleCodes = normalizedRoleCodes(user) const roleCodes = normalizedRoleCodes(user)
return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode)) return VIEW_ROLE_RULES.budget.some((roleCode) => roleCodes.includes(roleCode))
@@ -268,7 +272,11 @@ export function filterNavItemsByAccess(navItems, user) {
return navItems.filter((item) => canAccessAppView(user, item.id)) return navItems.filter((item) => canAccessAppView(user, item.id))
} }
export function resolveDefaultAuthorizedRoute(user) { export function resolveDefaultAuthorizedRoute(user) {
const firstVisibleView = getAccessibleViewIds(user)[0] if (isPlatformAdminUser(user) && canAccessAppView(user, 'overview')) {
return { name: `app-${firstVisibleView || 'workbench'}` } return { name: 'app-overview' }
} }
const firstVisibleView = getAccessibleViewIds(user)[0]
return { name: `app-${firstVisibleView || 'workbench'}` }
}

View File

@@ -77,3 +77,26 @@ export function markDocumentViewed(row, viewedKeys, storage = getStorage()) {
writeViewedDocumentKeys(nextKeys, storage) writeViewedDocumentKeys(nextKeys, storage)
return nextKeys return nextKeys
} }
export function markDocumentsViewed(rows, viewedKeys, storage = getStorage()) {
const nextKeys = new Set(viewedKeys)
let changed = false
;(Array.isArray(rows) ? rows : []).forEach((row) => {
if (!isNewDocument(row, nextKeys)) {
return
}
const key = resolveDocumentNewKey(row)
if (key) {
nextKeys.add(key)
changed = true
}
})
if (changed) {
writeViewedDocumentKeys(nextKeys, storage)
}
return nextKeys
}

View File

@@ -0,0 +1,24 @@
function normalizeSortTime(value) {
const time = Number(value)
return Number.isFinite(time) ? time : 0
}
export function compareDocumentRowsByLatestTime(left, right) {
const latestDiff = normalizeSortTime(right?.sortTime) - normalizeSortTime(left?.sortTime)
if (latestDiff !== 0) {
return latestDiff
}
const createdDiff = normalizeSortTime(right?.createdSortTime) - normalizeSortTime(left?.createdSortTime)
if (createdDiff !== 0) {
return createdDiff
}
const rightKey = String(right?.documentKey || right?.documentNo || '').trim()
const leftKey = String(left?.documentKey || left?.documentNo || '').trim()
return rightKey.localeCompare(leftKey, 'zh-CN')
}
export function sortDocumentRowsByLatestTime(rows) {
return (Array.isArray(rows) ? [...rows] : []).sort(compareDocumentRowsByLatestTime)
}

View File

@@ -400,6 +400,22 @@ function normalizeTransportModeOption(value, fallback = '') {
return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback return APPLICATION_TRANSPORT_MODE_OPTIONS.includes(text) ? text : fallback
} }
function resolveModelRefinedTransportMode(ontologyFields = {}, rawText = '', currentFields = {}) {
const currentTransportMode = isApplicationPreviewValueProvided(currentFields.transportMode)
? String(currentFields.transportMode).trim()
: ''
const explicitTransportMode = resolveApplicationTransportMode(rawText)
if (!explicitTransportMode) {
return currentTransportMode
}
const ontologyTransportMode = normalizeTransportModeOption(ontologyFields.transportMode, '')
if (ontologyTransportMode && ontologyTransportMode === explicitTransportMode) {
return ontologyTransportMode
}
return currentTransportMode || explicitTransportMode
}
function normalizeAmountFromOntology(fields = {}, fallback = '') { function normalizeAmountFromOntology(fields = {}, fallback = '') {
const numericAmount = Number(fields.amount || 0) const numericAmount = Number(fields.amount || 0)
if (Number.isFinite(numericAmount) && numericAmount > 0) { if (Number.isFinite(numericAmount) && numericAmount > 0) {
@@ -640,10 +656,7 @@ export function buildModelRefinedApplicationPreview(localPreview = {}, ontology
location: resolveProvidedValue(ontologyFields.location, currentFields.location), location: resolveProvidedValue(ontologyFields.location, currentFields.location),
reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason), reason: resolveProvidedValue(ontologyFields.reason, currentFields.reason),
days: resolveProvidedValue(ontologyFields.days, currentFields.days), days: resolveProvidedValue(ontologyFields.days, currentFields.days),
transportMode: normalizeTransportModeOption( transportMode: resolveModelRefinedTransportMode(ontologyFields, rawText, currentFields),
ontologyFields.transportMode,
currentFields.transportMode
),
amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount), amount: normalizeAmountFromOntology(ontologyFields, currentFields.amount),
grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)), grade: resolveProvidedValue(currentFields.grade, resolveCurrentUserGrade(currentUser)),
applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant), applicant: resolveProvidedValue(ontologyFields.applicant, currentFields.applicant),

View File

@@ -194,10 +194,11 @@ function buildTodoItems(ownedRequests) {
.sort((left, right) => normalizeText(right.due).localeCompare(normalizeText(left.due))) .sort((left, right) => normalizeText(right.due).localeCompare(normalizeText(left.due)))
} }
function resolveProgressStatusTone(approvalKey) { function resolveProgressStatusTone(approvalKey, statusText = '') {
if (approvalKey === 'completed') return 'muted' const status = String(statusText || '').trim()
if (approvalKey === 'pending_payment') return 'warning' if (approvalKey === 'completed' || /完成|结束|通过/i.test(status)) return 'muted'
if (approvalKey === 'supplement' || approvalKey === 'rejected') return 'danger' if (approvalKey === 'pending_payment' || /付款|支付/i.test(status)) return 'warning'
if (approvalKey === 'supplement' || approvalKey === 'rejected' || /退回|驳回|修改/i.test(status)) return 'danger'
return 'success' return 'success'
} }
@@ -243,14 +244,15 @@ function buildProgressItems(ownedRequests) {
const currentStep = steps.find((step) => step.current) const currentStep = steps.find((step) => step.current)
const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据' const title = normalizeText(request?.title || request?.note || request?.sceneLabel) || '费用单据'
const status = normalizeText(request?.approvalStatus || currentStep?.label) || '处理中'
return { return {
id: requestId, id: requestId,
requestId, requestId,
title, title,
expenseTypeLabel: resolveExpenseCategory(request), expenseTypeLabel: resolveExpenseCategory(request),
amount: formatCurrency(request?.amount), amount: formatCurrency(request?.amount),
status: normalizeText(request?.approvalStatus || currentStep?.label) || '处理中', status,
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)), statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status),
updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt), updatedAt: normalizeText(request?.updatedAt || request?.submittedAt || request?.createdAt),
steps, steps,
target: resolveRequestTarget(request), target: resolveRequestTarget(request),
@@ -391,12 +393,14 @@ function buildExpenseProcessingRows(ownedRequests) {
const latestAt = dates[dates.length - 1] || toDate(request?.updatedAt || request?.submittedAt || request?.createdAt) const latestAt = dates[dates.length - 1] || toDate(request?.updatedAt || request?.submittedAt || request?.createdAt)
const stepCount = Array.isArray(request?.progressSteps) ? request.progressSteps.length : 0 const stepCount = Array.isArray(request?.progressSteps) ? request.progressSteps.length : 0
const status = normalizeText(request?.approvalStatus || request?.status) || '处理中'
return { return {
id: requestId || title, id: requestId || title,
requestId, requestId,
title, title,
status: normalizeText(request?.approvalStatus || request?.status) || '处理中', status,
statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey)), statusTone: resolveProgressStatusTone(normalizeText(request?.approvalKey), status),
startedAt: startedAt ? formatDateTimeLabel(startedAt) : '暂无开始时间', startedAt: startedAt ? formatDateTimeLabel(startedAt) : '暂无开始时间',
updatedAt: latestAt ? formatDateTimeLabel(latestAt) : '暂无更新时间', updatedAt: latestAt ? formatDateTimeLabel(latestAt) : '暂无更新时间',
durationLabel: startedAt && latestAt ? formatDurationLabel(latestAt.getTime() - startedAt.getTime()) : '暂无耗时', durationLabel: startedAt && latestAt ? formatDurationLabel(latestAt.getTime() - startedAt.getTime()) : '暂无耗时',

View File

@@ -86,6 +86,7 @@
@batch-approve="toast('已批量通过 23 条审批任务')" @batch-approve="toast('已批量通过 23 条审批任务')"
@new-application="openExpenseApplicationCreate" @new-application="openExpenseApplicationCreate"
@open-document="openWorkbenchDocument" @open-document="openWorkbenchDocument"
@navigate="handleNavigate"
/> />
<FilterBar <FilterBar

View File

@@ -9,9 +9,11 @@
:class="{ active: activeScopeTab === tab.value }" :class="{ active: activeScopeTab === tab.value }"
@click="activeScopeTab = tab.value" @click="activeScopeTab = tab.value"
> >
<span>{{ tab.label }}</span> <span class="scope-tab-label">
<span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数"> {{ tab.label }}
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }} <span v-if="tab.badgeCount > 0" class="scope-tab-badge" aria-label="新增单据数">
{{ tab.badgeCount > 99 ? '99+' : tab.badgeCount }}
</span>
</span> </span>
</button> </button>
</nav> </nav>
@@ -122,7 +124,16 @@
</div> </div>
</div> </div>
<div v-if="[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT].includes(activeScopeTab)" class="document-actions"> <div v-if="showToolbarActions" class="document-actions">
<button
v-if="totalNewDocumentCount > 0"
class="mark-read-btn"
type="button"
@click="markAllDocumentsRead"
>
<i class="mdi mdi-check-all"></i>
<span>一键已读</span>
</button>
<button v-if="activeScopeTab === DOCUMENT_SCOPE_APPLICATION" class="create-request-btn" type="button" @click="emit('create-application')"> <button v-if="activeScopeTab === DOCUMENT_SCOPE_APPLICATION" class="create-request-btn" type="button" @click="emit('create-application')">
<i class="mdi mdi-file-plus-outline"></i> <i class="mdi mdi-file-plus-outline"></i>
<span>发起申请</span> <span>发起申请</span>
@@ -238,7 +249,8 @@ import TableLoadingState from '../components/shared/TableLoadingState.vue'
import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js' import { useMinimumVisibleState } from '../composables/useMinimumVisibleState.js'
import { mapExpenseClaimToRequest } from '../composables/useRequests.js' import { mapExpenseClaimToRequest } from '../composables/useRequests.js'
import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js' import { fetchApprovalExpenseClaims, fetchArchivedExpenseClaims } from '../services/reimbursements.js'
import { countNewDocuments, isNewDocument, markDocumentViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js' import { countNewDocuments, isNewDocument, markDocumentViewed, markDocumentsViewed, readDocumentScope, readViewedDocumentKeys, writeDocumentScope } from '../utils/documentCenterNewState.js'
import { sortDocumentRowsByLatestTime } from '../utils/documentCenterSort.js'
import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js' import { extractDateText, formatDocumentListTime, resolveDocumentSortTime, resolveDocumentStayTimeDisplay } from '../utils/documentCenterTime.js'
import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js' import { excludeArchivedDocumentRows, filterApplicationScopeNewRows, isArchivedDocumentRow, prepareApplicationScopeRows } from '../utils/documentCenterRows.js'
import { normalizeRequestForUi } from '../utils/requestViewModel.js' import { normalizeRequestForUi } from '../utils/requestViewModel.js'
@@ -468,6 +480,17 @@ const scopeTabItems = computed(() =>
badgeCount: scopeNewCountMap.value[tab] || 0 badgeCount: scopeNewCountMap.value[tab] || 0
})) }))
) )
const allReadableDocumentRows = computed(() => [
...nonArchivedRows.value,
...filterApplicationScopeNewRows(applicationScopeRows.value),
...ownedRows.value.filter((row) => row.documentTypeCode === DOCUMENT_TYPE_REIMBURSEMENT),
...approvalRows.value
])
const totalNewDocumentCount = computed(() => countNewDocuments(allReadableDocumentRows.value, viewedDocumentKeys.value))
const showCreateDocumentActions = computed(() =>
[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT].includes(activeScopeTab.value)
)
const showToolbarActions = computed(() => showCreateDocumentActions.value || totalNewDocumentCount.value > 0)
const activeScopeRows = computed(() => { const activeScopeRows = computed(() => {
if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value if (activeScopeTab.value === DOCUMENT_SCOPE_ALL) return nonArchivedRows.value
@@ -513,7 +536,7 @@ const statusFilterLabel = computed(() =>
const filteredRows = computed(() => { const filteredRows = computed(() => {
const keyword = listKeyword.value.trim().toLowerCase() const keyword = listKeyword.value.trim().toLowerCase()
return activeScopeRows.value.filter((row) => { return sortDocumentRowsByLatestTime(activeScopeRows.value.filter((row) => {
const matchesKeyword = !keyword || [ const matchesKeyword = !keyword || [
row.documentNo, row.documentNo,
row.documentTypeLabel, row.documentTypeLabel,
@@ -534,7 +557,7 @@ const filteredRows = computed(() => {
const matchesDateRange = matchesAppliedDateRange(row) const matchesDateRange = matchesAppliedDateRange(row)
return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange return matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange
}) }))
}) })
const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value))) const totalPages = computed(() => Math.max(1, Math.ceil(filteredRows.value.length / pageSize.value)))
@@ -631,6 +654,8 @@ function buildDocumentRow(request, options = {}) {
const claimId = normalized.claimId || normalized.id || documentNo const claimId = normalized.claimId || normalized.id || documentNo
const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt const createdAtSource = normalized.createdAt || normalized.submittedAt || normalized.applyTime || normalized.updatedAt
const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime const updatedAtSource = normalized.updatedAt || normalized.submittedAt || normalized.createdAt || normalized.applyTime
const createdSortTime = resolveDocumentSortTime(createdAtSource)
const updatedSortTime = resolveDocumentSortTime(updatedAtSource)
const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT const documentTypeCode = normalized.documentTypeCode || DOCUMENT_TYPE_REIMBURSEMENT
const documentTypeLabel = const documentTypeLabel =
normalized.documentTypeLabel normalized.documentTypeLabel
@@ -667,7 +692,9 @@ function buildDocumentRow(request, options = {}) {
? false ? false
: isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value), : isNewDocument({ ...normalized, source: options.source || 'owned', claimId, documentNo }, viewedDocumentKeys.value),
updatedAtDisplay: formatDocumentListTime(updatedAtSource), updatedAtDisplay: formatDocumentListTime(updatedAtSource),
sortTime: resolveDocumentSortTime(updatedAtSource) createdSortTime,
updatedSortTime,
sortTime: Math.max(createdSortTime, updatedSortTime)
} }
} }
@@ -729,7 +756,7 @@ function mergeDocumentRows(rows) {
} }
}) })
return Array.from(rowMap.values()).sort((left, right) => right.sortTime - left.sortTime) return sortDocumentRowsByLatestTime(Array.from(rowMap.values()))
} }
function resolveSourcePriority(row) { function resolveSourcePriority(row) {
@@ -831,6 +858,14 @@ function openDocument(row) {
emit('open-document', row.rawRequest || row) emit('open-document', row.rawRequest || row)
} }
function markAllDocumentsRead() {
if (!totalNewDocumentCount.value) {
return
}
viewedDocumentKeys.value = markDocumentsViewed(allReadableDocumentRows.value, viewedDocumentKeys.value)
}
async function loadSupportingRows() { async function loadSupportingRows() {
supportingLoading.value = true supportingLoading.value = true
supportingError.value = '' supportingError.value = ''

View File

@@ -130,16 +130,6 @@
<i class="mdi mdi-robot-outline"></i> <i class="mdi mdi-robot-outline"></i>
<span>{{ uploadingExpenseId ? '识别中' : '智能录入' }}</span> <span>{{ uploadingExpenseId ? '识别中' : '智能录入' }}</span>
</button> </button>
<button
v-if="isEditableRequest"
class="smart-entry-btn secondary"
type="button"
:disabled="actionBusy"
@click="handleAddExpenseItem"
>
<i class="mdi mdi-plus-circle-outline"></i>
<span>{{ creatingExpense ? '新增中' : '增加明细' }}</span>
</button>
</div> </div>
</div> </div>
<div v-if="isApplicationDocument" class="application-detail-facts"> <div v-if="isApplicationDocument" class="application-detail-facts">
@@ -283,7 +273,12 @@
</div> </div>
</template> </template>
<template v-else> <template v-else>
<strong>{{ item.amount }}</strong> <div v-if="item.hasStandardAdjustment" class="expense-adjusted-amount">
<span class="expense-original-amount">{{ item.originalAmountDisplay || item.amount }}</span>
<strong class="expense-reimbursable-amount">{{ item.reimbursableAmountDisplay }}</strong>
<em v-if="item.employeeAbsorbedAmountDisplay">自担 {{ item.employeeAbsorbedAmountDisplay }}</em>
</div>
<strong v-else>{{ item.amount }}</strong>
<span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span> <span v-if="item.tone !== 'ok'" :class="['over-tag', item.tone]">{{ item.status }}</span>
</template> </template>
</td> </td>
@@ -380,9 +375,11 @@
<div class="cell-editor"> <div class="cell-editor">
<textarea <textarea
v-model="expenseEditor.itemNote" v-model="expenseEditor.itemNote"
class="editor-textarea" class="editor-textarea risk-note-editor-textarea"
rows="3" rows="1"
placeholder="如票据存在异常或风险,请补充原因" placeholder="如票据存在异常或风险,请补充原因"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea> ></textarea>
<span>用于说明改签绕行超标票据异常等情况</span> <span>用于说明改签绕行超标票据异常等情况</span>
</div> </div>
@@ -439,7 +436,7 @@
</template> </template>
<tr v-if="!expenseItems.length" class="empty-row"> <tr v-if="!expenseItems.length" class="empty-row">
<td :colspan="expenseTableColumnCount" class="empty-row-cell"> <td :colspan="expenseTableColumnCount" class="empty-row-cell">
当前还没有费用明细点击右上角增加明细继续补充 当前还没有费用明细请通过智能录入上传票据后由系统自动归集
</td> </td>
</tr> </tr>
</tbody> </tbody>
@@ -774,7 +771,7 @@
</div> </div>
<div class="submit-confirm-row"> <div class="submit-confirm-row">
<span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span> <span>{{ isApplicationDocument ? '预计金额' : '报销金额' }}</span>
<strong>{{ request.amountDisplay || expenseTotal }}</strong> <strong>{{ submitConfirmAmountDisplay }}</strong>
</div> </div>
<div v-if="!isApplicationDocument" class="submit-confirm-row"> <div v-if="!isApplicationDocument" class="submit-confirm-row">
<span>费用明细</span> <span>费用明细</span>
@@ -784,20 +781,20 @@
</ConfirmDialog> </ConfirmDialog>
<ConfirmDialog <ConfirmDialog
:open="riskOverrideDialogOpen" :open="riskOverrideDialogOpen"
badge="重大风险" badge="异常说明"
badge-tone="danger" badge-tone="danger"
:title="`当前存在 ${submitRiskWarnings.length} 条重大风险`" :title="`当前存在 ${submitRiskWarnings.length} 条需说明的风险`"
description="如仍需提交审批,请逐条填写每一个重大风险的原因,系统会写入附加说明并用于后续风险统计。" description="请先补充异常说明后提交领导审批;也可以不填写说明,选择按职级最高可报销金额重新计算。"
cancel-text="返回整改" cancel-text="返回整改"
confirm-text="保存原因并继续" confirm-text="按职级标准重算"
busy-text="保存中..." busy-text="处理中..."
confirm-tone="danger" confirm-tone="danger"
confirm-icon="mdi mdi-alert-circle-outline" confirm-icon="mdi mdi-calculator-variant-outline"
:busy="riskOverrideBusy" :busy="riskOverrideBusy"
@close="closeRiskOverrideDialog" @close="closeRiskOverrideDialog"
@confirm="confirmRiskOverrideReasons" @confirm="confirmStandardAdjustment"
> >
<div v-if="currentSubmitRiskWarning" class="risk-override-panel" aria-label="重大风险说明"> <div v-if="currentSubmitRiskWarning" class="risk-override-panel" aria-label="异常说明">
<div class="risk-override-nav"> <div class="risk-override-nav">
<button <button
type="button" type="button"
@@ -827,11 +824,26 @@
<p>{{ currentSubmitRiskWarning.risk }}</p> <p>{{ currentSubmitRiskWarning.risk }}</p>
<textarea <textarea
v-model="riskOverrideReasons[currentSubmitRiskWarning.id]" v-model="riskOverrideReasons[currentSubmitRiskWarning.id]"
class="risk-note-editor-textarea"
rows="1"
maxlength="160" maxlength="160"
placeholder="请说明为什么仍需提交,例如客户指定酒店、会议高峰、协议酒店满房等" placeholder="请说明原因,例如客户指定酒店、会议高峰、协议酒店满房等"
aria-label="违规提交原因" aria-label="异常说明"
@input="resizeExpenseNoteInput"
@keydown.enter="resizeExpenseNoteInput"
></textarea> ></textarea>
</article> </article>
<div class="risk-override-submit-row">
<button
class="risk-override-save-btn"
type="button"
:disabled="riskOverrideBusy"
@click="confirmRiskOverrideReasons"
>
保存说明并继续提交
</button>
<span>不填写说明时系统会按职级最高报销标准重算金额</span>
</div>
</div> </div>
</ConfirmDialog> </ConfirmDialog>
<TravelRequestDeleteDialog :open="deleteDialogOpen" :badge="deleteActionLabel" :title="deleteDialogTitle" :description="deleteDialogDescription" :busy="deleteBusy" @close="closeDeleteDialog" @confirm="confirmDeleteRequest" /> <TravelRequestDeleteDialog :open="deleteDialogOpen" :badge="deleteActionLabel" :title="deleteDialogTitle" :description="deleteDialogDescription" :busy="deleteBusy" @close="closeDeleteDialog" @confirm="confirmDeleteRequest" />

View File

@@ -10,7 +10,9 @@ import TravelRequestDeleteDialog from '../../components/travel/TravelRequestDele
import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue' import StageRiskAdviceCard from '../../components/travel/StageRiskAdviceCard.vue'
import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue' import TravelRequestReturnDialog from '../../components/travel/TravelRequestReturnDialog.vue'
import { import {
acceptExpenseClaimStandardAdjustment,
approveExpenseClaim, approveExpenseClaim,
calculateTravelReimbursement,
createExpenseClaimItem, createExpenseClaimItem,
deleteExpenseClaimItem, deleteExpenseClaimItem,
deleteExpenseClaimItemAttachment, deleteExpenseClaimItemAttachment,
@@ -88,6 +90,13 @@ import {
resolveSubmitConfirmDescription, resolveSubmitConfirmDescription,
resolveSubmitConfirmText resolveSubmitConfirmText
} from './travelRequestDetailSubmitModel.js' } from './travelRequestDetailSubmitModel.js'
import {
buildCurrentStandardAdjustmentMap,
buildStandardAdjustmentPayload as buildStandardAdjustmentPayloadModel,
filterSubmitterResolvedRiskCards as filterSubmitterResolvedRiskCardsModel,
isRiskCardMissingExpenseNote as isRiskCardMissingExpenseNoteModel,
resolveExpenseItemForRiskCard as resolveExpenseItemForRiskCardModel
} from './travelRequestDetailStandardAdjustment.js'
import { import {
buildEmployeeProfileAdviceItems, buildEmployeeProfileAdviceItems,
buildTravelReceiptMaterialPrompts buildTravelReceiptMaterialPrompts
@@ -590,7 +599,6 @@ export default {
const { currentUser } = useSystemState() const { currentUser } = useSystemState()
const editingExpenseId = ref('') const editingExpenseId = ref('')
const savingExpenseId = ref('') const savingExpenseId = ref('')
const creatingExpense = ref(false)
const uploadingExpenseId = ref('') const uploadingExpenseId = ref('')
const deletingAttachmentId = ref('') const deletingAttachmentId = ref('')
const deletingExpenseId = ref('') const deletingExpenseId = ref('')
@@ -898,7 +906,6 @@ export default {
|| returnBusy.value || returnBusy.value
|| approveBusy.value || approveBusy.value
|| payBusy.value || payBusy.value
|| creatingExpense.value
|| smartEntryRecognitionBusy.value || smartEntryRecognitionBusy.value
|| Boolean(uploadingExpenseId.value) || Boolean(uploadingExpenseId.value)
|| Boolean(deletingAttachmentId.value) || Boolean(deletingAttachmentId.value)
@@ -996,9 +1003,16 @@ export default {
} }
const expenseTotal = computed(() => { const expenseTotal = computed(() => {
const total = expenseItems.value.reduce((sum, item) => sum + Number(item.itemAmount || 0), 0) const total = expenseItems.value.reduce((sum, item) => {
const adjustedAmount = Number(item.reimbursableAmount)
const originalAmount = Number(item.itemAmount || 0)
return sum + (Number.isFinite(adjustedAmount) ? adjustedAmount : originalAmount)
}, 0)
return formatCurrency(total) return formatCurrency(total)
}) })
const submitConfirmAmountDisplay = computed(() =>
isApplicationDocument.value ? (request.value.amountDisplay || expenseTotal.value) : expenseTotal.value
)
const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value)) const applicationDetailFactItems = computed(() => buildApplicationDetailFactItems(request.value))
const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value)) const relatedApplicationFactItems = computed(() => buildRelatedApplicationFactItems(request.value))
@@ -1086,7 +1100,7 @@ export default {
return `已选择 ${names.length} 张附件` return `已选择 ${names.length} 张附件`
}) })
const smartEntryUploadBusy = computed(() => const smartEntryUploadBusy = computed(() =>
smartEntryUploadDialogOpen.value && (creatingExpense.value || Boolean(uploadingExpenseId.value)) smartEntryUploadDialogOpen.value && Boolean(uploadingExpenseId.value)
) )
const attachmentPreviewEntries = computed(() => const attachmentPreviewEntries = computed(() =>
expenseItems.value expenseItems.value
@@ -1157,6 +1171,65 @@ export default {
return requestFlags return requestFlags
} }
function resolveCurrentStandardAdjustmentMap() {
return buildCurrentStandardAdjustmentMap(request.value, resolveClaimRiskFlags())
}
function resolveExpenseItemForRiskCard(card) {
return resolveExpenseItemForRiskCardModel(card, expenseItems.value)
}
function filterSubmitterResolvedRiskCards(cards, businessStage) {
const viewerContext = riskViewerContext.value || {}
return filterSubmitterResolvedRiskCardsModel({
cards,
businessStage,
isCurrentApplicant: isCurrentApplicant.value,
isPrivilegedRiskViewer: Boolean(
viewerContext.isAdminViewer
|| viewerContext.isBudgetReviewer
|| viewerContext.isDirectManagerReviewer
|| viewerContext.isFinanceReviewer
|| viewerContext.canViewApprovalRiskAdvice
),
expenseItems: expenseItems.value,
standardAdjustmentMap: resolveCurrentStandardAdjustmentMap()
})
}
function isRiskCardMissingExpenseNote(card) {
return isRiskCardMissingExpenseNoteModel(card, expenseItems.value)
}
async function buildStandardAdjustmentPayload() {
return buildStandardAdjustmentPayloadModel({
warnings: submitRiskWarnings.value,
expenseItems: expenseItems.value,
request: request.value,
calculateTravelReimbursement
})
}
function applyStandardAdjustmentResponse(payload = {}) {
const flags = Array.isArray(payload?.risk_flags_json)
? payload.risk_flags_json
: Array.isArray(payload?.riskFlags)
? payload.riskFlags
: resolveClaimRiskFlags()
riskFlagPreviewSnapshot.value = {
claimId: request.value.claimId,
riskFlags: flags
}
const sourceItems = Array.isArray(payload?.items) && payload.items.length
? payload.items
: expenseItems.value
expenseItems.value = rebuildExpenseItems(sourceItems, {
...request.value,
riskFlags: flags,
risk_flags_json: flags
})
}
function resolveAttachmentDisplayName(item) { function resolveAttachmentDisplayName(item) {
const metadata = resolveAttachmentMeta(item) const metadata = resolveAttachmentMeta(item)
return String(metadata?.file_name || item.attachmentHint || '').trim() return String(metadata?.file_name || item.attachmentHint || '').trim()
@@ -1532,7 +1605,7 @@ export default {
: [] : []
const scopedRiskCards = [ const scopedRiskCards = [
...(hasActionableRiskCards ? [] : summaryRiskCards), ...(hasActionableRiskCards ? [] : summaryRiskCards),
...directRiskCards ...filterSubmitterResolvedRiskCards(directRiskCards, currentBusinessStage)
] ]
const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value) const riskCards = filterRiskCardsForVisibility(scopedRiskCards, riskViewerContext.value)
@@ -1654,7 +1727,8 @@ export default {
const submitRiskWarnings = computed(() => const submitRiskWarnings = computed(() =>
aiAdvice.value.riskCards aiAdvice.value.riskCards
.filter((card) => normalizeRiskTone(card?.tone) === 'high') .filter((card) => ['medium', 'high'].includes(normalizeRiskTone(card?.tone)))
.filter((card) => isRiskCardMissingExpenseNote(card))
.map((card, index) => ({ .map((card, index) => ({
...card, ...card,
id: String(card.id || `submit-risk-${index}`), id: String(card.id || `submit-risk-${index}`),
@@ -1665,7 +1739,6 @@ export default {
const riskOverrideIndexLabel = computed(() => const riskOverrideIndexLabel = computed(() =>
submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : '' submitRiskWarnings.value.length ? `${riskOverrideIndex.value + 1} / ${submitRiskWarnings.value.length}` : ''
) )
const hasRiskOverrideExplanation = computed(() => detailNoteTags.value.includes('#high_risk'))
function resetDetailNote() { function resetDetailNote() {
detailNoteEditor.value = detailNoteSource.value detailNoteEditor.value = detailNoteSource.value
@@ -1724,6 +1797,18 @@ export default {
riskOverrideDialogOpen.value = false riskOverrideDialogOpen.value = false
} }
function resizeExpenseNoteInput(event) {
const target = event?.target
if (!target || typeof window === 'undefined') {
return
}
const style = window.getComputedStyle(target)
const lineHeight = Number.parseFloat(style.lineHeight) || 18
const maxHeight = lineHeight * 3 + 18
target.style.height = 'auto'
target.style.height = `${Math.min(target.scrollHeight, maxHeight)}px`
}
function goToPreviousSubmitRisk() { function goToPreviousSubmitRisk() {
if (!submitRiskWarnings.value.length) { if (!submitRiskWarnings.value.length) {
return return
@@ -1739,17 +1824,6 @@ export default {
riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length riskOverrideIndex.value = (riskOverrideIndex.value + 1) % submitRiskWarnings.value.length
} }
function buildRiskOverrideAppendix() {
return submitRiskWarnings.value
.map((risk, index) => {
const reason = String(riskOverrideReasons[risk.id] || '').trim()
const tags = resolveRiskTags(risk).join(' ')
const title = String(risk.title || risk.label || '重大风险').trim()
return `超标说明:${tags}${index + 1}${title}${reason}`
})
.join('\n')
}
function mergeDetailNoteWithRiskOverride(appendix) { function mergeDetailNoteWithRiskOverride(appendix) {
const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value const baseNote = detailNoteEditor.value.trim() || detailNoteSource.value
return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n') return [baseNote, appendix].map((item) => String(item || '').trim()).filter(Boolean).join('\n')
@@ -1762,28 +1836,91 @@ export default {
const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim()) const missingIndex = submitRiskWarnings.value.findIndex((risk) => !String(riskOverrideReasons[risk.id] || '').trim())
if (missingIndex >= 0) { if (missingIndex >= 0) {
riskOverrideIndex.value = missingIndex riskOverrideIndex.value = missingIndex
toast('请为每一条重大风险填写违规提交原因。') toast('请为每一条风险填写异常说明。')
return return
} }
const appendix = buildRiskOverrideAppendix() const itemNoteGroups = new Map()
const nextNote = mergeDetailNoteWithRiskOverride(appendix) const claimLevelRisks = []
if (nextNote.length > 500) { submitRiskWarnings.value.forEach((risk, index) => {
toast('附加说明最多 500 字,请精简风险原因后再继续提交。') const reason = String(riskOverrideReasons[risk.id] || '').trim()
return const item = resolveExpenseItemForRiskCard(risk)
} if (item?.id) {
const currentGroup = itemNoteGroups.get(item.id) || { item, reasons: [] }
currentGroup.reasons.push(reason)
itemNoteGroups.set(item.id, currentGroup)
} else {
const title = String(risk.title || risk.label || '风险').trim()
claimLevelRisks.push(`异常说明:第${index + 1}${title}${reason}`)
}
})
riskOverrideBusy.value = true riskOverrideBusy.value = true
try { try {
await updateExpenseClaim(request.value.claimId, { await Promise.all(
reason: nextNote [...itemNoteGroups.entries()].map(([itemId, group]) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
return updateExpenseClaimItem(request.value.claimId, itemId, {
item_note: nextNote
})
})
)
itemNoteGroups.forEach((group, itemId) => {
const existingNote = String(group.item?.itemNote || '').trim()
const nextNote = [
existingNote,
...group.reasons.filter((reason) => existingNote !== reason && !existingNote.includes(reason))
].filter(Boolean).join('\n')
applyLocalExpenseItemPatch(itemId, {
itemNote: nextNote
})
}) })
detailNoteEditor.value = nextNote if (claimLevelRisks.length) {
const appendix = claimLevelRisks.join('\n')
const nextNote = mergeDetailNoteWithRiskOverride(appendix)
if (nextNote.length > 500) {
toast('附加说明最多 500 字,请精简风险原因后再继续提交。')
return
}
await updateExpenseClaim(request.value.claimId, {
reason: nextNote
})
detailNoteEditor.value = nextNote
}
riskOverrideDialogOpen.value = false riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true submitConfirmDialogOpen.value = true
toast('违规提交原因已写入附加说明。') toast('异常说明已保存,可继续提交审批。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) { } catch (error) {
toast(error?.message || '风险原因保存失败,请稍后重试。') toast(error?.message || '异常说明保存失败,请稍后重试。')
} finally {
riskOverrideBusy.value = false
}
}
async function confirmStandardAdjustment() {
if (riskOverrideBusy.value) {
return
}
riskOverrideBusy.value = true
try {
const payload = await buildStandardAdjustmentPayload()
if (!payload.risks.length) {
toast('当前风险暂未匹配到可重算的费用明细,请先补充异常说明。')
return
}
const response = await acceptExpenseClaimStandardAdjustment(request.value.claimId, payload)
applyStandardAdjustmentResponse(response)
riskOverrideDialogOpen.value = false
submitConfirmDialogOpen.value = true
toast('已按职级最高报销标准重算实际报销金额。')
emit('request-updated', { claimId: request.value.claimId })
} catch (error) {
toast(error?.message || '按职级标准重算失败,请稍后重试。')
} finally { } finally {
riskOverrideBusy.value = false riskOverrideBusy.value = false
} }
@@ -1811,6 +1948,10 @@ export default {
} }
populateExpenseEditor(item) populateExpenseEditor(item)
void nextTick(() => {
const textarea = document.querySelector('.risk-note-editor-textarea')
resizeExpenseNoteInput({ target: textarea })
})
} }
function validateExpenseEditor() { function validateExpenseEditor() {
@@ -1839,48 +1980,6 @@ export default {
return '' return ''
} }
async function createDraftExpenseItem({ openEditor = true } = {}) {
if (!request.value.claimId) {
toast('当前草稿缺少 claimId暂时无法新增费用明细。')
return null
}
creatingExpense.value = true
try {
const existingIds = new Set(expenseItems.value.map((item) => item.id))
const claim = await createExpenseClaimItem(request.value.claimId, {})
const createdItem = Array.isArray(claim?.items)
? claim.items.find((entry) => !existingIds.has(String(entry?.id || '')))
: null
if (!createdItem) {
throw new Error('新增费用明细失败,请稍后重试。')
}
const nextItem = buildExpenseItemViewModel(createdItem, expenseItems.value.length, request.value)
expenseItems.value = rebuildExpenseItems([...expenseItems.value, nextItem], request.value)
creatingExpense.value = false
if (openEditor) {
startExpenseEdit(nextItem)
toast('已新增一条费用明细,请继续填写。')
}
return nextItem
} catch (error) {
toast(error?.message || '新增费用明细失败,请稍后重试。')
return null
} finally {
creatingExpense.value = false
}
}
async function handleAddExpenseItem() {
if (!isEditableRequest.value || actionBusy.value) {
return
}
await createDraftExpenseItem({ openEditor: true })
}
function triggerSmartEntryUpload() { function triggerSmartEntryUpload() {
if (!isEditableRequest.value || actionBusy.value) { if (!isEditableRequest.value || actionBusy.value) {
return return
@@ -2281,6 +2380,11 @@ export default {
return return
} }
if (submitRiskWarnings.value.length) {
openRiskOverrideDialog()
return
}
submitConfirmDialogOpen.value = true submitConfirmDialogOpen.value = true
} }
@@ -2584,18 +2688,18 @@ export default {
closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog, closeApproveConfirmDialog, closeDeleteDialog, closeAttachmentPreview, closePayConfirmDialog, closeSubmitConfirmDialog,
closeRiskOverrideDialog, closeSmartEntryUploadDialog, closeRiskOverrideDialog, closeSmartEntryUploadDialog,
closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest, closeReturnDialog, confirmApproveRequest, confirmDeleteRequest, confirmSubmitRequest, confirmReturnRequest,
confirmPayRequest, confirmRiskOverrideReasons, confirmSmartEntryUpload, confirmPayRequest, confirmRiskOverrideReasons, confirmStandardAdjustment, confirmSmartEntryUpload,
chooseSmartEntryFile, clearSmartEntryFile, chooseSmartEntryFile, clearSmartEntryFile,
currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion, currentAttachmentPreviewInsight, currentAttachmentPreviewRiskCards, currentProgressRingMotion,
currentSubmitRiskWarning, currentSubmitRiskWarning,
canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen, canEditDetailNote, deleteActionLabel, deleteBusy, deleteDialogDescription, deleteDialogOpen,
deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty, deleteDialogTitle, deletingAttachmentId, deletingExpenseId, detailNote, detailNoteDirty,
detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, creatingExpense, expenseEditor, detailNoteEditor, detailNoteEditorView, detailNoteTags, draftBlockingIssues, editingExpenseId, expenseEditor,
expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput, expenseItems, expenseTableColumnCount, expenseTotal, expenseUploadInput,
expenseTypeOptions: EXPENSE_TYPE_OPTIONS, expenseTypeOptions: EXPENSE_TYPE_OPTIONS,
goToNextSubmitRisk, goToPreviousSubmitRisk, goToNextSubmitRisk, goToPreviousSubmitRisk,
focusExpenseRisk, focusExpenseRisk,
handleAddExpenseItem, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange, handleApproveRequest, handleDeleteRequest, handleExpenseFileChange, handleSmartEntryFileChange,
handleModifyApplication, handleModifyApplication,
handlePayRequest, handlePayRequest,
handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest, handleReturnRequest, handleSubmit, heroFactItems, isApplicationDocument, isDraftRequest, isEditableRequest, isTravelRequest,
@@ -2606,7 +2710,7 @@ export default {
payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem, payBusy, payConfirmDialogOpen, profile, progressSteps, request, leaderOpinion, removeExpenseAttachment, removeExpenseItem,
hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta, hasLeaderApprovalEvents, hasSingleLeaderApprovalEvent, leaderApprovalEvents, leaderApprovalReadonlyMeta,
resolveExpenseRiskIndicatorTitle, resolveExpenseRiskIndicatorTitle,
resetDetailNote, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition, resetDetailNote, resizeExpenseNoteInput, resolveAttachmentDisplayName, resolveAttachmentPreviewTitle, resolveAttachmentRecognition,
resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues, resolveExpenseReasonHelper, resolveExpenseReasonPlaceholder, resolveExpenseRiskState, resolveExpenseIssues,
resolveRiskCardDomId, isHighlightedRiskCard, resolveRiskCardDomId, isHighlightedRiskCard,
returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel, returnBusy, returnDialogDescription, returnDialogOpen, riskOverrideBusy, riskOverrideDialogOpen, riskOverrideIndexLabel,
@@ -2618,7 +2722,7 @@ export default {
showAiAdvicePanel, showApplicationLeaderOpinion, showAiAdvicePanel, showApplicationLeaderOpinion,
showBudgetAnalysis, showStageRiskAdvice, showBudgetAnalysis, showStageRiskAdvice,
showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy, showExpenseRisk, startExpenseEdit, submitActionIcon, submitActionLabel, submitBusy,
submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings, submitConfirmAmountDisplay, submitConfirmDescription, submitConfirmDialogOpen, submitConfirmText, submitRiskWarnings,
triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit triggerExpenseUpload, uploadedExpenseCount, uploadingExpenseId, saveExpenseEdit
} }
} }

View File

@@ -13,7 +13,10 @@ const APPLICATION_TYPE_ALIASES = {
} }
const GENERIC_APPLICATION_TYPES = new Set(['application', 'expense_application']) const GENERIC_APPLICATION_TYPES = new Set(['application', 'expense_application'])
const BLOCKED_APPLICATION_STATUSES = new Set(['draft', 'returned', 'rejected', 'cancelled', 'canceled', 'deleted']) const APPROVED_APPLICATION_STATUSES = new Set(['approved', 'completed'])
const APPROVED_APPLICATION_APPROVAL_KEYS = new Set(['completed'])
const BLOCKED_APPLICATION_LINK_STATUSES = new Set(['draft', 'returned', 'rejected', 'archived', 'cancelled', 'canceled', 'deleted'])
const INACTIVE_REIMBURSEMENT_LINK_STATUSES = new Set(['cancelled', 'canceled', 'deleted'])
const STATUS_LABELS = { const STATUS_LABELS = {
submitted: '审批中', submitted: '审批中',
@@ -53,6 +56,10 @@ function normalizeApplicationStatus(claim) {
return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus) return normalizeLower(claim?.status || claim?.state || claim?.approval_status || claim?.approvalStatus)
} }
function normalizeApprovalKey(claim) {
return normalizeLower(claim?.approvalKey || claim?.approval_key)
}
function normalizeDocumentType(claim) { function normalizeDocumentType(claim) {
return normalizeLower( return normalizeLower(
claim?.document_type_code claim?.document_type_code
@@ -141,6 +148,99 @@ function resolveApplicationDetailPayload(claim) {
return detail && typeof detail === 'object' ? detail : {} return detail && typeof detail === 'object' ? detail : {}
} }
function resolveRiskFlags(claim) {
const flags = claim?.risk_flags_json || claim?.riskFlags || []
return Array.isArray(flags) ? flags : []
}
function createReferenceIndex() {
return {
ids: new Set(),
claimNos: new Set()
}
}
function addApplicationReference(index, idValue, claimNoValue) {
const id = normalizeText(idValue)
if (id) {
index.ids.add(id)
}
const claimNo = normalizeText(claimNoValue).toUpperCase()
if (claimNo) {
index.claimNos.add(claimNo)
}
}
function addApplicationReferencesFromPayload(index, payload) {
if (!payload || typeof payload !== 'object') {
return
}
addApplicationReference(
index,
payload.application_claim_id || payload.applicationClaimId || payload.id,
payload.application_claim_no || payload.applicationClaimNo || payload.claim_no || payload.claimNo
)
}
function collectLinkedApplicationReferences(claim) {
const index = createReferenceIndex()
addApplicationReferencesFromPayload(index, claim?.relatedApplication)
addApplicationReferencesFromPayload(index, claim?.related_application)
resolveRiskFlags(claim).forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
addApplicationReferencesFromPayload(index, flag)
addApplicationReferencesFromPayload(index, flag.application_detail || flag.applicationDetail)
addApplicationReferencesFromPayload(index, flag.review_form_values || flag.reviewFormValues)
addApplicationReferencesFromPayload(index, flag.expense_scene_selection || flag.expenseSceneSelection)
})
return index
}
function hasAnyApplicationReference(index) {
return Boolean(index?.ids?.size || index?.claimNos?.size)
}
function buildLinkedApplicationReferenceIndex(claims) {
const index = createReferenceIndex()
;(Array.isArray(claims) ? claims : []).forEach((claim) => {
if (isExpenseApplicationClaim(claim)) {
return
}
const status = normalizeApplicationStatus(claim)
if (INACTIVE_REIMBURSEMENT_LINK_STATUSES.has(status)) {
return
}
const claimReferences = collectLinkedApplicationReferences(claim)
claimReferences.ids.forEach((id) => index.ids.add(id))
claimReferences.claimNos.forEach((claimNo) => index.claimNos.add(claimNo))
})
return index
}
function isApplicationAlreadyLinked(claim, linkedApplicationReferences) {
if (!linkedApplicationReferences) {
return false
}
const ownReferences = createReferenceIndex()
addApplicationReference(
ownReferences,
claim?.id || claim?.claim_id || claim?.claimId,
claim?.claim_no || claim?.claimNo
)
if (!hasAnyApplicationReference(ownReferences)) {
return false
}
return Array.from(ownReferences.ids).some((id) => linkedApplicationReferences.ids.has(id))
|| Array.from(ownReferences.claimNos).some((claimNo) => linkedApplicationReferences.claimNos.has(claimNo))
}
function toTimestamp(value) { function toTimestamp(value) {
const date = new Date(value) const date = new Date(value)
return Number.isNaN(date.getTime()) ? 0 : date.getTime() return Number.isNaN(date.getTime()) ? 0 : date.getTime()
@@ -265,9 +365,14 @@ export function isClaimOwnedByCurrentUser(claim, currentUser = {}) {
return true return true
} }
export function isUsableRequiredApplicationClaim(claim) { export function isUsableRequiredApplicationClaim(claim, linkedApplicationReferences = null) {
const status = normalizeApplicationStatus(claim) const status = normalizeApplicationStatus(claim)
return !BLOCKED_APPLICATION_STATUSES.has(status) const approvalKey = normalizeApprovalKey(claim)
if (BLOCKED_APPLICATION_LINK_STATUSES.has(status)) {
return false
}
return (APPROVED_APPLICATION_STATUSES.has(status) || APPROVED_APPLICATION_APPROVAL_KEYS.has(approvalKey))
&& !isApplicationAlreadyLinked(claim, linkedApplicationReferences)
} }
export function normalizeRequiredApplicationCandidate(claim) { export function normalizeRequiredApplicationCandidate(claim) {
@@ -331,10 +436,12 @@ export function filterRequiredApplicationCandidates(claimsPayload, expenseType,
? claimsPayload.claims ? claimsPayload.claims
: [] : []
const linkedApplicationReferences = buildLinkedApplicationReferenceIndex(claims)
return claims return claims
.filter((claim) => ( .filter((claim) => (
isExpenseApplicationClaim(claim) isExpenseApplicationClaim(claim)
&& isUsableRequiredApplicationClaim(claim) && isUsableRequiredApplicationClaim(claim, linkedApplicationReferences)
&& isClaimOwnedByCurrentUser(claim, currentUser) && isClaimOwnedByCurrentUser(claim, currentUser)
&& matchesRequiredApplicationExpenseType(claim, expenseType) && matchesRequiredApplicationExpenseType(claim, expenseType)
)) ))

View File

@@ -32,11 +32,24 @@ export const LONG_DISTANCE_TRAVEL_EXPENSE_TYPES = new Set(['train_ticket', 'flig
export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket']) export const ROUTE_DESCRIPTION_EXPENSE_TYPES = new Set(['train_ticket', 'flight_ticket', 'ship_ticket', 'ferry_ticket', 'ride_ticket'])
export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket']) export const HOTEL_DESCRIPTION_EXPENSE_TYPES = new Set(['hotel_ticket'])
export const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/ export const ROUTE_DESCRIPTION_PATTERN = /^[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}\s*-\s*[A-Za-z0-9\u4e00-\u9fa5()·]{2,40}$/
export const STANDARD_ADJUSTMENT_RISK_SOURCE = 'reimbursement_standard_adjustment'
export function parseCurrency(value) { export function parseCurrency(value) {
return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0 return Number.parseFloat(String(value).replace(/[^\d.]/g, '')) || 0
} }
function parseOptionalCurrency(value) {
if (value === null || value === undefined || String(value).trim() === '') {
return null
}
const normalized = String(value).replace(/[^\d.]/g, '')
if (!normalized) {
return null
}
const amount = Number.parseFloat(normalized)
return Number.isFinite(amount) && amount >= 0 ? amount : null
}
export function formatCurrency(value) { export function formatCurrency(value) {
return new Intl.NumberFormat('zh-CN', { return new Intl.NumberFormat('zh-CN', {
style: 'currency', style: 'currency',
@@ -395,6 +408,60 @@ export function resolveExpenseTimeLabel({ id, itemType, isSystemGenerated, reque
return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间' return requestModel?.detailVariant === 'travel' ? '出行时间' : '业务发生时间'
} }
export function buildStandardAdjustmentMap(requestModel = {}) {
const flags = Array.isArray(requestModel?.riskFlags)
? requestModel.riskFlags
: Array.isArray(requestModel?.risk_flags_json)
? requestModel.risk_flags_json
: []
const adjustmentMap = new Map()
flags.forEach((flag) => {
if (!flag || typeof flag !== 'object') {
return
}
if (String(flag.source || '').trim() !== STANDARD_ADJUSTMENT_RISK_SOURCE) {
return
}
const itemId = String(flag.item_id || flag.itemId || '').trim()
if (!itemId) {
return
}
const originalAmount = parseOptionalCurrency(flag.original_amount ?? flag.originalAmount)
const reimbursableAmount = parseOptionalCurrency(flag.reimbursable_amount ?? flag.reimbursableAmount)
if (reimbursableAmount === null) {
return
}
const employeeAbsorbedAmount = parseOptionalCurrency(flag.employee_absorbed_amount ?? flag.employeeAbsorbedAmount) || 0
adjustmentMap.set(itemId, {
originalAmount,
reimbursableAmount,
employeeAbsorbedAmount,
message: String(flag.message || flag.summary || '').trim()
})
})
return adjustmentMap
}
function resolveSourceStandardAdjustment(source, id, requestModel) {
const requestAdjustment = buildStandardAdjustmentMap(requestModel).get(id)
if (requestAdjustment) {
return requestAdjustment
}
const reimbursableAmount = parseOptionalCurrency(source?.reimbursableAmount ?? source?.reimbursable_amount)
if (reimbursableAmount === null) {
return null
}
return {
originalAmount: parseOptionalCurrency(source?.originalItemAmount ?? source?.original_item_amount ?? source?.originalAmount ?? source?.original_amount),
reimbursableAmount,
employeeAbsorbedAmount: parseOptionalCurrency(source?.employeeAbsorbedAmount ?? source?.employee_absorbed_amount) || 0,
message: String(source?.standardAdjustmentMessage || source?.standard_adjustment_message || '').trim()
}
}
export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) { export function buildExpenseItemViewModel(source, index, requestModel, travelTimeLabelMap = new Map()) {
const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other') const itemType = normalizeExpenseType(source?.itemType || source?.item_type || requestModel?.typeCode || 'other')
const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType }) const isSystemGenerated = isSystemGeneratedExpenseItemSource({ ...source, itemType })
@@ -407,7 +474,13 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim() const invoiceId = String(source?.invoiceId ?? source?.invoice_id ?? '').trim()
const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim() const attachmentName = String(source?.attachmentName || source?.attachment_name || extractAttachmentDisplayName(invoiceId)).trim()
const attachments = invoiceId ? [attachmentName || invoiceId] : [] const attachments = invoiceId ? [attachmentName || invoiceId] : []
const standardAdjustment = resolveSourceStandardAdjustment(source, id, requestModel)
const originalItemAmount = standardAdjustment?.originalAmount ?? itemAmount
const reimbursableAmount = standardAdjustment?.reimbursableAmount ?? itemAmount
const employeeAbsorbedAmount = standardAdjustment?.employeeAbsorbedAmount || Math.max(originalItemAmount - reimbursableAmount, 0)
const hasStandardAdjustment = reimbursableAmount >= 0 && reimbursableAmount < originalItemAmount
const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充' const amountDisplay = itemAmount > 0 ? formatCurrency(itemAmount) : '待补充'
const reimbursableAmountDisplay = reimbursableAmount > 0 ? formatCurrency(reimbursableAmount) : '待补充'
const riskText = String(source?.riskText || '').trim() const riskText = String(source?.riskText || '').trim()
const filledAt = formatExpenseFilledTime( const filledAt = formatExpenseFilledTime(
source?.filledAt source?.filledAt
@@ -424,6 +497,15 @@ export function buildExpenseItemViewModel(source, index, requestModel, travelTim
itemLocation, itemLocation,
itemNote, itemNote,
itemAmount, itemAmount,
originalItemAmount,
originalAmountDisplay: originalItemAmount > 0 ? formatCurrency(originalItemAmount) : amountDisplay,
reimbursableAmount,
reimbursableAmountDisplay,
employeeAbsorbedAmount,
employeeAbsorbedAmountDisplay: employeeAbsorbedAmount > 0 ? formatCurrency(employeeAbsorbedAmount) : '',
hasStandardAdjustment,
standardAdjustmentAccepted: Boolean(standardAdjustment),
standardAdjustmentMessage: standardAdjustment?.message || '',
invoiceId, invoiceId,
isSystemGenerated, isSystemGenerated,
time: itemDate || '待补充', time: itemDate || '待补充',

View File

@@ -453,6 +453,7 @@ function buildRiskCardFromPoint({ item, index, point, pointIndex, insight, analy
summary: normalizeText(analysis?.summary), summary: normalizeText(analysis?.summary),
ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'], ruleBasis: insight?.ruleBasis?.length ? insight.ruleBasis : ['系统根据附件识别结果与费用项目规则进行比对。'],
suggestion: buildCardSuggestion(analysis, insight), suggestion: buildCardSuggestion(analysis, insight),
source: 'attachment_analysis',
itemType: normalizeText(item?.itemType), itemType: normalizeText(item?.itemType),
documentType: normalizeText(insight?.documentTypeLabel), documentType: normalizeText(insight?.documentTypeLabel),
visibility_scope: 'submitter', visibility_scope: 'submitter',
@@ -645,6 +646,7 @@ export function buildAttachmentRiskCards({
summary, summary,
ruleBasis, ruleBasis,
suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }), suggestion: resolveClaimRiskSuggestion(flag, { risk, summary }),
source,
risk_domain: flag.risk_domain || flag.riskDomain, risk_domain: flag.risk_domain || flag.riskDomain,
visibility_scope: flag.visibility_scope || flag.visibilityScope, visibility_scope: flag.visibility_scope || flag.visibilityScope,
actionability: flag.actionability actionability: flag.actionability

View File

@@ -0,0 +1,175 @@
import {
STANDARD_ADJUSTMENT_RISK_SOURCE,
buildStandardAdjustmentMap
} from './travelRequestDetailExpenseModel.js'
function normalizeText(value) {
return String(value || '').trim()
}
function normalizeAmount(value) {
const amount = Number(value)
return Number.isFinite(amount) && amount > 0 ? amount : 0
}
export function buildCurrentStandardAdjustmentMap(request = {}, riskFlags = []) {
return buildStandardAdjustmentMap({
...request,
riskFlags,
risk_flags_json: riskFlags
})
}
export function resolveExpenseItemForRiskCard(card, expenseItems = []) {
const itemId = normalizeText(card?.itemId || card?.item_id)
const invoiceId = normalizeText(card?.invoiceId || card?.invoice_id)
const itemIndex = Number(card?.itemIndex || card?.item_index || 0)
return expenseItems.find((item) => normalizeText(item.id) === itemId)
|| expenseItems.find((item) => invoiceId && normalizeText(item.invoiceId) === invoiceId)
|| (itemIndex > 0 ? expenseItems[itemIndex - 1] : null)
|| null
}
export function isRiskCardMissingExpenseNote(card, expenseItems = []) {
const item = resolveExpenseItemForRiskCard(card, expenseItems)
if (!item) {
return true
}
return !normalizeText(item.itemNote)
}
export function hasStandardAdjustmentForItem(item, standardAdjustmentMap = new Map()) {
const itemId = normalizeText(item?.id)
if (!itemId) {
return false
}
return Boolean(item?.standardAdjustmentAccepted || standardAdjustmentMap.has(itemId))
}
function isRiskCardResolvedForSubmitter(card, expenseItems, standardAdjustmentMap) {
if (normalizeText(card?.source) === STANDARD_ADJUSTMENT_RISK_SOURCE) {
return true
}
return hasStandardAdjustmentForItem(
resolveExpenseItemForRiskCard(card, expenseItems),
standardAdjustmentMap
)
}
export function filterSubmitterResolvedRiskCards({
cards = [],
businessStage = 'reimbursement',
isCurrentApplicant = false,
isPrivilegedRiskViewer = false,
expenseItems = [],
standardAdjustmentMap = new Map()
} = {}) {
if (businessStage !== 'reimbursement' || !isCurrentApplicant || isPrivilegedRiskViewer) {
return cards
}
return cards.filter((card) => !isRiskCardResolvedForSubmitter(card, expenseItems, standardAdjustmentMap))
}
function extractRiskCardMoneyValues(card) {
const corpus = [
card?.risk,
card?.summary,
card?.suggestion,
card?.title,
...(Array.isArray(card?.ruleBasis) ? card.ruleBasis : [])
].map(normalizeText).filter(Boolean).join(' ')
return [...corpus.matchAll(/(?:¥|¥)?\s*(\d+(?:,\d{3})*(?:\.\d+)?)\s*元/g)]
.map((match) => Number(String(match[1] || '').replace(/,/g, '')))
.filter((amount) => Number.isFinite(amount) && amount > 0)
}
function resolveParsedStandardAmount(card, item) {
const originalAmount = normalizeAmount(item?.itemAmount)
if (originalAmount <= 0) {
return null
}
const candidates = extractRiskCardMoneyValues(card)
.filter((amount) => amount > 0 && amount < originalAmount)
return candidates.length ? Math.max(...candidates) : null
}
function extractRiskCardNightCount(card) {
const corpus = [card?.risk, card?.summary, card?.suggestion, card?.title]
.map(normalizeText)
.join(' ')
const match = corpus.match(/(\d+)\s*(?:晚|夜|间夜)/)
return match ? Math.max(1, Number(match[1]) || 1) : 1
}
async function resolveTravelStandardAmount({ card, item, request, calculateTravelReimbursement }) {
const itemType = normalizeText(item?.itemType)
if (!['hotel_ticket', 'hotel'].includes(itemType)) {
return null
}
const location = normalizeText(item?.itemLocation || request?.location || request?.sceneTarget)
if (!location || typeof calculateTravelReimbursement !== 'function') {
return null
}
const grade = normalizeText(request?.employeeGrade || request?.profileGrade)
const days = extractRiskCardNightCount(card)
try {
const result = await calculateTravelReimbursement({ days, location, grade })
const hotelAmount = Number(result?.hotel_amount ?? result?.hotelAmount)
return Number.isFinite(hotelAmount) && hotelAmount > 0 ? hotelAmount : null
} catch (error) {
return null
}
}
async function resolveRiskStandardReimbursableAmount({ card, item, request, calculateTravelReimbursement }) {
const originalAmount = normalizeAmount(item?.itemAmount)
if (originalAmount <= 0) {
return 0
}
const parsedAmount = resolveParsedStandardAmount(card, item)
if (parsedAmount !== null) {
return Math.min(originalAmount, parsedAmount)
}
const travelStandardAmount = await resolveTravelStandardAmount({
card,
item,
request,
calculateTravelReimbursement
})
if (travelStandardAmount !== null) {
return Math.min(originalAmount, travelStandardAmount)
}
return originalAmount
}
export async function buildStandardAdjustmentPayload({
warnings = [],
expenseItems = [],
request = {},
calculateTravelReimbursement
} = {}) {
const risks = []
for (const warning of warnings) {
const item = resolveExpenseItemForRiskCard(warning, expenseItems)
if (!item) {
continue
}
const originalAmount = normalizeAmount(item.itemAmount)
const reimbursableAmount = await resolveRiskStandardReimbursableAmount({
card: warning,
item,
request,
calculateTravelReimbursement
})
risks.push({
risk_id: warning.id,
item_id: item.id,
title: warning.title,
risk: warning.risk || warning.summary,
original_amount: originalAmount,
reimbursable_amount: reimbursableAmount
})
}
return { risks }
}

View File

@@ -20,7 +20,7 @@ export function resolveSubmitConfirmDescription({ isApplicationDocument, hasHigh
return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。' return '请确认申请事由、预计金额和申请信息均已核对无误。确认后将提交至直属领导审批。'
} }
if (hasHighRiskWarnings) { if (hasHighRiskWarnings) {
return '系统自动检测存在重大风险,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。' return '系统自动检测存在风险提示,请确认费用明细中的异常说明已按需补充。确认后将进入审批流程。'
} }
return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。' return '系统已在草稿保存和附件识别后完成自动检测,请确认费用明细、附件材料和补充说明均已核对无误。确认后将进入审批流程。'
} }

View File

@@ -7,10 +7,13 @@ import {
canAccessAppView, canAccessAppView,
canDeleteArchivedExpenseClaims, canDeleteArchivedExpenseClaims,
canEditBudgetCenter, canEditBudgetCenter,
filterNavItemsByAccess,
getAccessibleViewIds,
isCurrentDirectManagerForRequest, isCurrentDirectManagerForRequest,
isCurrentRequestApplicant, isCurrentRequestApplicant,
canManageExpenseClaims, canManageExpenseClaims,
canReturnExpenseClaims, canReturnExpenseClaims,
resolveDefaultAuthorizedRoute,
canSwitchBudgetDepartments canSwitchBudgetDepartments
} from '../src/utils/accessControl.js' } from '../src/utils/accessControl.js'
import { canProcessApprovalRequest } from '../src/utils/approvalInbox.js' import { canProcessApprovalRequest } from '../src/utils/approvalInbox.js'
@@ -71,6 +74,26 @@ test('legacy reimbursement approval and archive centers are no longer accessible
assert.equal(canAccessAppView(adminUser, 'documents'), true) assert.equal(canAccessAppView(adminUser, 'documents'), true)
}) })
test('platform admin users do not enter the personal workbench', () => {
const adminUser = { username: 'admin', isAdmin: true, roleCodes: ['manager', 'finance'] }
const employeeUser = { username: 'employee@example.com', roleCodes: [] }
const navItems = [
{ id: 'workbench', label: '个人工作台' },
{ id: 'documents', label: '单据中心' },
{ id: 'overview', label: '分析看板' },
{ id: 'settings', label: '系统设置' }
]
assert.equal(canAccessAppView(adminUser, 'workbench'), false)
assert.equal(canAccessAppView(employeeUser, 'workbench'), true)
assert.equal(getAccessibleViewIds(adminUser).includes('workbench'), false)
assert.deepEqual(resolveDefaultAuthorizedRoute(adminUser), { name: 'app-overview' })
assert.deepEqual(
filterNavItemsByAccess(navItems, adminUser).map((item) => item.id),
['documents', 'overview', 'settings']
)
})
test('budget center is visible to platform admin, budget monitor, and executive roles only', () => { test('budget center is visible to platform admin, budget monitor, and executive roles only', () => {
assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'budget'), true) assert.equal(canAccessAppView({ isAdmin: true, roleCodes: ['manager'] }, 'budget'), true)
assert.equal(canAccessAppView({ username: 'admin', roleCodes: ['manager'] }, 'budget'), true) assert.equal(canAccessAppView({ username: 'admin', roleCodes: ['manager'] }, 'budget'), true)

View File

@@ -5,6 +5,7 @@ import {
countNewDocuments, countNewDocuments,
isNewDocument, isNewDocument,
markDocumentViewed, markDocumentViewed,
markDocumentsViewed,
readDocumentScope, readDocumentScope,
readViewedDocumentKeys, readViewedDocumentKeys,
resolveDocumentNewKey, resolveDocumentNewKey,
@@ -47,6 +48,19 @@ test('document center new state counts unseen documents and persists viewed rows
assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1']) assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1'])
}) })
test('document center new state can mark all unread rows as viewed at once', () => {
const storage = createMemoryStorage()
const rows = [
{ source: 'owned', claimId: 'claim-1' },
{ source: 'approval', claimId: 'claim-2' },
{ source: 'archive', claimId: 'claim-3' }
]
const viewedKeys = markDocumentsViewed(rows, readViewedDocumentKeys(storage), storage)
assert.equal(countNewDocuments(rows, viewedKeys), 0)
assert.deepEqual([...readViewedDocumentKeys(storage)], ['owned:claim-1', 'approval:claim-2'])
})
test('document center archive rows are never marked as new', () => { test('document center archive rows are never marked as new', () => {
const viewedKeys = readViewedDocumentKeys(createMemoryStorage()) const viewedKeys = readViewedDocumentKeys(createMemoryStorage())
const rows = [ const rows = [
@@ -71,6 +85,24 @@ test('document center sidebar inbox shares source scoped document keys', () => {
assert.deepEqual(rows.map((row) => resolveDocumentNewKey(row)), ['approval:claim-1', 'archive:claim-2']) assert.deepEqual(rows.map((row) => resolveDocumentNewKey(row)), ['approval:claim-1', 'archive:claim-2'])
}) })
test('document center inbox rows expose real notification metadata', () => {
const rows = buildDocumentInboxRows({
ownedClaims: [{
id: 'claim-1',
claim_no: 'EXP-1',
title: '差旅报销',
status: 'draft',
created_at: '2026-06-03T09:10:00+08:00'
}]
})
assert.equal(rows[0].documentNo, 'EXP-1')
assert.equal(rows[0].sourceLabel, '我的单据')
assert.equal(rows[0].title, '差旅报销')
assert.equal(rows[0].createdAt, '2026-06-03T09:10:00+08:00')
assert.equal(Number.isFinite(rows[0].sortTime), true)
})
test('document center scope state restores only allowed tabs', () => { test('document center scope state restores only allowed tabs', () => {
const storage = createMemoryStorage() const storage = createMemoryStorage()
const scopes = ['全部', '申请单', '报销单', '审核单', '归档'] const scopes = ['全部', '申请单', '报销单', '审核单', '归档']

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
compareDocumentRowsByLatestTime,
sortDocumentRowsByLatestTime
} from '../src/utils/documentCenterSort.js'
test('document center sorts newest document rows first without mutating input', () => {
const rows = [
{ documentNo: 'AP-001', sortTime: 1000 },
{ documentNo: 'AP-003', sortTime: 3000 },
{ documentNo: 'AP-002', sortTime: 2000 }
]
const sortedRows = sortDocumentRowsByLatestTime(rows)
assert.deepEqual(sortedRows.map((row) => row.documentNo), ['AP-003', 'AP-002', 'AP-001'])
assert.deepEqual(rows.map((row) => row.documentNo), ['AP-001', 'AP-003', 'AP-002'])
})
test('document center sort falls back to created time and stable document keys', () => {
const rows = [
{ documentKey: 'owned:AP-001', documentNo: 'AP-001', sortTime: 1000, createdSortTime: 1000 },
{ documentKey: 'owned:AP-002', documentNo: 'AP-002', sortTime: 1000, createdSortTime: 2000 },
{ documentKey: 'owned:AP-003', documentNo: 'AP-003', sortTime: 1000, createdSortTime: 2000 }
]
const sortedRows = sortDocumentRowsByLatestTime(rows)
assert.deepEqual(sortedRows.map((row) => row.documentNo), ['AP-003', 'AP-002', 'AP-001'])
assert.equal(compareDocumentRowsByLatestTime(rows[1], rows[2]) > 0, true)
})

View File

@@ -68,6 +68,7 @@ test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /excludeArchivedDocumentRows/) assert.match(documentsCenterView, /excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/) assert.match(documentsCenterView, /approvalRows\.value = excludeArchivedDocumentRows/)
assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/) assert.match(documentsCenterView, /const nonArchivedRows = computed\(\(\) => mergeDocumentRows\(\[\.\.\.ownedRows\.value, \.\.\.approvalRows\.value\]\)\)/)
assert.match(documentsCenterView, /import \{ sortDocumentRowsByLatestTime \} from '..\/utils\/documentCenterSort\.js'/)
assert.match(documentsCenterView, /activeScopeTab\.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/) assert.match(documentsCenterView, /activeScopeTab\.value !== DOCUMENT_SCOPE_ARCHIVE && isArchivedDocumentRow\(row\)/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
@@ -92,6 +93,23 @@ test('documents center category tabs map to the intended row sources', () => {
assert.match(documentsCenterView, /return nonArchivedRows\.value/) assert.match(documentsCenterView, /return nonArchivedRows\.value/)
}) })
test('documents center sorts every filtered scope by latest document time before pagination', () => {
assert.match(
documentsCenterView,
/return sortDocumentRowsByLatestTime\(activeScopeRows\.value\.filter\(\(row\) => \{[\s\S]*matchesKeyword && matchesDocumentType && matchesScene && matchesStatus && matchesDateRange[\s\S]*\}\)\)/
)
assert.match(
documentsCenterView,
/const createdSortTime = resolveDocumentSortTime\(createdAtSource\)[\s\S]*const updatedSortTime = resolveDocumentSortTime\(updatedAtSource\)/
)
assert.match(
documentsCenterView,
/createdSortTime,[\s\S]*updatedSortTime,[\s\S]*sortTime: Math\.max\(createdSortTime, updatedSortTime\)/
)
assert.match(documentsCenterView, /return sortDocumentRowsByLatestTime\(Array\.from\(rowMap\.values\(\)\)\)/)
assert.doesNotMatch(documentsCenterView, /right\.sortTime - left\.sortTime/)
})
test('documents center preserves application document type from mapped requests', () => { test('documents center preserves application document type from mapped requests', () => {
assert.match( assert.match(
documentsCenterView, documentsCenterView,
@@ -137,9 +155,10 @@ test('documents center list shows created time and conditional stay time columns
}) })
test('documents center action buttons are scoped to application and reimbursement tabs', () => { test('documents center action buttons are scoped to application and reimbursement tabs', () => {
assert.match(documentsCenterView, /v-if="showToolbarActions" class="document-actions"/)
assert.match( assert.match(
documentsCenterView, documentsCenterView,
/v-if="\[DOCUMENT_SCOPE_APPLICATION, DOCUMENT_SCOPE_REIMBURSEMENT\]\.includes\(activeScopeTab\)"[\s\S]*class="document-actions"/ /const showCreateDocumentActions = computed\(\(\) =>[\s\S]*DOCUMENT_SCOPE_APPLICATION[\s\S]*DOCUMENT_SCOPE_REIMBURSEMENT/
) )
assert.match( assert.match(
documentsCenterView, documentsCenterView,
@@ -156,6 +175,7 @@ test('documents center category tabs render bubble counts for new documents', ()
assert.match(documentsCenterView, /readViewedDocumentKeys/) assert.match(documentsCenterView, /readViewedDocumentKeys/)
assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/) assert.match(documentsCenterView, /const viewedDocumentKeys = ref\(readViewedDocumentKeys\(\)\)/)
assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/) assert.match(documentsCenterView, /v-for="tab in scopeTabItems"/)
assert.match(documentsCenterView, /<span class="scope-tab-label">/)
assert.match(documentsCenterView, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/) assert.match(documentsCenterView, /<span v-if="tab\.badgeCount > 0" class="scope-tab-badge"/)
assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/) assert.match(documentsCenterView, /tab\.badgeCount > 99 \? '99\+' : tab\.badgeCount/)
assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/) assert.match(documentsCenterView, /const scopeNewCountMap = computed\(\(\) => \(\{/)
@@ -178,6 +198,31 @@ test('documents center category tabs render bubble counts for new documents', ()
documentsCenterView, documentsCenterView,
/const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/ /const scopeTabItems = computed\(\(\) =>[\s\S]*badgeCount: scopeNewCountMap\.value\[tab\] \|\| 0/
) )
const scopeTabBadgeBlock = documentListSharedStyles.match(/\.scope-tab-badge\s*\{[^}]*\}/)?.[0] || ''
assert.match(documentListSharedStyles, /\.scope-tab-label\s*\{[\s\S]*align-items:\s*flex-start;[\s\S]*gap:\s*4px;/)
assert.match(scopeTabBadgeBlock, /position:\s*static;/)
assert.match(scopeTabBadgeBlock, /height:\s*14px;/)
assert.match(scopeTabBadgeBlock, /margin-top:\s*-5px;/)
assert.doesNotMatch(scopeTabBadgeBlock, /position:\s*absolute;/)
})
test('documents center can mark all unread documents as read from toolbar', () => {
assert.match(documentsCenterView, /markDocumentsViewed/)
assert.match(
documentsCenterView,
/const allReadableDocumentRows = computed\(\(\) => \[[\s\S]*nonArchivedRows\.value[\s\S]*filterApplicationScopeNewRows\(applicationScopeRows\.value\)[\s\S]*approvalRows\.value/
)
assert.match(documentsCenterView, /const totalNewDocumentCount = computed\(\(\) => countNewDocuments\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)\)/)
assert.match(documentsCenterView, /const showToolbarActions = computed\(\(\) => showCreateDocumentActions\.value \|\| totalNewDocumentCount\.value > 0\)/)
assert.match(
documentsCenterView,
/<button[\s\S]*v-if="totalNewDocumentCount > 0"[\s\S]*class="mark-read-btn"[\s\S]*@click="markAllDocumentsRead"[\s\S]*一键已读/
)
assert.match(
documentsCenterView,
/function markAllDocumentsRead\(\) \{[\s\S]*viewedDocumentKeys\.value = markDocumentsViewed\(allReadableDocumentRows\.value, viewedDocumentKeys\.value\)/
)
assert.match(documentListSharedStyles, /\.mark-read-btn\s*\{[\s\S]*border:\s*1px solid #fecaca;/)
}) })
test('documents center rows show NEW marker until the row is opened', () => { test('documents center rows show NEW marker until the row is opened', () => {

View File

@@ -366,6 +366,39 @@ test('application preview can be refined by ontology model extraction', () => {
assert.equal(refinedPreview.fields.transportMode, '火车') assert.equal(refinedPreview.fields.transportMode, '火车')
}) })
test('application preview ignores model-only transport mode guesses', () => {
const rawText = '\u7533\u8bf7 2026-05-25 \u81f3 2026-05-27 \u53bb\u4e0a\u6d77\u51fa\u5dee3\u5929\uff0c\u670d\u52a1\u9879\u76ee\u90e8\u7f72\uff0c\u9884\u8ba1\u8d39\u75281800\u5143'
const localPreview = buildLocalApplicationPreview(rawText, {
name: '\u674e\u6587\u9759',
grade: 'P5'
})
const refinedPreview = buildModelRefinedApplicationPreview(
localPreview,
{
parse_strategy: 'llm_primary',
entities: [
{ type: 'expense_type', value: '\u5dee\u65c5\u8d39', normalized_value: 'travel' },
{ type: 'location', value: '\u4e0a\u6d77', normalized_value: '\u4e0a\u6d77' },
{ type: 'reason', value: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72', normalized_value: '\u670d\u52a1\u9879\u76ee\u90e8\u7f72' },
{ type: 'transport_mode', value: '\u706b\u8f66', normalized_value: '\u706b\u8f66' },
{ type: 'amount', value: '1800\u5143', normalized_value: '1800' }
],
time_range: {
start: '2026-05-25',
end: '2026-05-27'
},
missing_slots: []
},
rawText,
{ name: '\u674e\u6587\u9759', grade: 'P5' }
)
assert.equal(localPreview.fields.transportMode, '')
assert.equal(refinedPreview.fields.transportMode, '')
assert.ok(refinedPreview.missingFields.includes('\u51fa\u884c\u65b9\u5f0f'))
assert.equal(refinedPreview.readyToSubmit, false)
})
test('application preview precomputes a date range from today when only days are provided', () => { test('application preview precomputes a date range from today when only days are provided', () => {
const preview = buildLocalApplicationPreview( const preview = buildLocalApplicationPreview(
'去北京出差3天支撑国网仿生产环境部署飞机预计费用12000元', '去北京出差3天支撑国网仿生产环境部署飞机预计费用12000元',

View File

@@ -0,0 +1,33 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const modal = readFileSync(
fileURLToPath(new URL('../src/components/business/ExpenseProfileDetailModal.vue', import.meta.url)),
'utf8'
)
const radarChart = readFileSync(
fileURLToPath(new URL('../src/components/charts/RadarChart.vue', import.meta.url)),
'utf8'
)
test('expense profile modal remounts the behavior radar when opened', () => {
assert.match(modal, /destroy-on-close/)
assert.match(modal, /<RadarChart/)
assert.match(modal, /:key="radarRenderKey"/)
assert.match(modal, /const radarRenderKey = ref\(0\)/)
assert.match(modal, /watch\([\s\S]*\(\) => props\.visible[\s\S]*radarRenderKey\.value \+= 1/)
assert.match(modal, /scheduleRadarFrame/)
})
test('radar chart uses the shared echarts lifecycle and enables entrance animation', () => {
assert.match(radarChart, /import \{ useEcharts \} from '\.\.\/\.\.\/composables\/useEcharts\.js'/)
assert.match(radarChart, /useEcharts\(chartElement, chartOptions\)/)
assert.match(radarChart, /animation: true/)
assert.match(radarChart, /animationDuration: 980/)
assert.match(radarChart, /animationDurationUpdate: 760/)
assert.match(radarChart, /animationEasing: 'cubicOut'/)
assert.doesNotMatch(radarChart, /import \{[^}]*init[^}]*\} from 'echarts\/core'/)
})

View File

@@ -0,0 +1,16 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { fileURLToPath } from 'node:url'
const pager = readFileSync(
fileURLToPath(new URL('../src/components/business/ExpenseProfileTagPager.vue', import.meta.url)),
'utf8'
)
test('profile tag pager stretches tag rows to the bottom of the card', () => {
assert.match(pager, /\.profile-tag-pager\s*\{[\s\S]*height:\s*100%;/)
assert.match(pager, /\.profile-tag-pager\s*\{[\s\S]*grid-template-rows:\s*minmax\(0,\s*1fr\) 26px;/)
assert.match(pager, /\.profile-tag-list\s*\{[\s\S]*grid-template-rows:\s*repeat\(5,\s*minmax\(48px,\s*1fr\)\);/)
assert.match(pager, /\.profile-tag-list\s*\{[\s\S]*align-content:\s*stretch;/)
})

View File

@@ -14,12 +14,17 @@ test('expense stats detail modal exposes distribution, processing time and opera
assert.match(modal, /单据处理时间/) assert.match(modal, /单据处理时间/)
assert.match(modal, /系统操作详情/) assert.match(modal, /系统操作详情/)
assert.match(modal, /ElDialog/) assert.match(modal, /ElDialog/)
assert.match(modal, /destroy-on-close/)
assert.match(modal, /ElTag/) assert.match(modal, /ElTag/)
assert.match(modal, /import DonutChart from '\.\.\/charts\/DonutChart\.vue'/) assert.match(modal, /import DonutChart from '\.\.\/charts\/DonutChart\.vue'/)
assert.match(modal, /<DonutChart/) assert.match(modal, /<DonutChart/)
assert.match(modal, /:key="distributionChartRenderKey"/)
assert.match(modal, /:show-legend="false"/) assert.match(modal, /:show-legend="false"/)
assert.match(modal, /distributionChartItems/) assert.match(modal, /distributionChartItems/)
assert.match(modal, /distributionCenterValue/) assert.match(modal, /distributionCenterValue/)
assert.match(modal, /const chartRenderSeq = ref\(0\)/)
assert.match(modal, /const distributionChartRenderKey = computed\(\(\) => `expense-distribution-\$\{chartRenderSeq\.value\}`\)/)
assert.match(modal, /watch\([\s\S]*\(\) => props\.visible[\s\S]*chartRenderSeq\.value \+= 1/)
assert.match(modal, /distributionRows/) assert.match(modal, /distributionRows/)
assert.match(modal, /expense-distribution-summary-list/) assert.match(modal, /expense-distribution-summary-list/)
assert.match(modal, /resolveDistributionColor/) assert.match(modal, /resolveDistributionColor/)

View File

@@ -127,6 +127,7 @@ function testReceiptFolderDetailLayoutAdjustments() {
assert.match(receiptStyles, /\.receipt-association-panel/) assert.match(receiptStyles, /\.receipt-association-panel/)
assert.match(receiptStyles, /\.receipt-preview-box[\s\S]*height: clamp\(380px, 56vh, 640px\)/) assert.match(receiptStyles, /\.receipt-preview-box[\s\S]*height: clamp\(380px, 56vh, 640px\)/)
assert.match(receiptStyles, /\.receipt-all-field-grid/) assert.match(receiptStyles, /\.receipt-all-field-grid/)
assert.match(receiptStyles, /\.receipt-all-field-grid[\s\S]*grid-template-columns: repeat\(auto-fit, minmax\(260px, 1fr\)\)/)
assert.match(receiptStyles, /\.receipt-edit-log-list/) assert.match(receiptStyles, /\.receipt-edit-log-list/)
assert.doesNotMatch(receiptStyles, /\.receipt-detail-toolbar/) assert.doesNotMatch(receiptStyles, /\.receipt-detail-toolbar/)
assert.doesNotMatch(receiptStyles, /\.receipt-side-stack/) assert.doesNotMatch(receiptStyles, /\.receipt-side-stack/)

View File

@@ -8,6 +8,26 @@ const sidebar = readFileSync(
'utf8' 'utf8'
) )
const sidebarStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/sidebar-rail.css', import.meta.url)),
'utf8'
)
const topbar = readFileSync(
fileURLToPath(new URL('../src/components/layout/TopBar.vue', import.meta.url)),
'utf8'
)
const topbarStyles = readFileSync(
fileURLToPath(new URL('../src/assets/styles/components/top-bar.css', import.meta.url)),
'utf8'
)
const appShellRouteView = readFileSync(
fileURLToPath(new URL('../src/views/AppShellRouteView.vue', import.meta.url)),
'utf8'
)
const documentInbox = readFileSync( const documentInbox = readFileSync(
fileURLToPath(new URL('../src/composables/useDocumentCenterInbox.js', import.meta.url)), fileURLToPath(new URL('../src/composables/useDocumentCenterInbox.js', import.meta.url)),
'utf8' 'utf8'
@@ -18,22 +38,83 @@ const documentNewState = readFileSync(
'utf8' 'utf8'
) )
test('sidebar renders a red dot for unread document center rows', () => { const notificationStatesService = readFileSync(
assert.match(sidebar, /useDocumentCenterInbox/) fileURLToPath(new URL('../src/services/notificationStates.js', import.meta.url)),
assert.match(sidebar, /hasUnread: documentInboxHasUnread/) 'utf8'
assert.match(sidebar, /<span v-if="item\.hasNewMessage" class="nav-unread-dot" aria-hidden="true"><\/span>/) )
assert.match(sidebar, /hasNewMessage: item\.id === 'documents' \? documentInboxHasUnread\.value : false/)
assert.match(sidebar, /void refreshDocumentInbox\(\)/) const topbarNotificationStates = readFileSync(
assert.match(sidebar, /startDocumentInboxPolling\(\)/) fileURLToPath(new URL('../src/composables/useTopBarNotificationStates.js', import.meta.url)),
assert.match(sidebar, /stopDocumentInboxPolling\(\)/) 'utf8'
assert.match(sidebar, /\.nav-unread-dot\s*\{[\s\S]*background:\s*#ef4444;/) )
assert.match(sidebar, /\.rail-collapsed \.nav-unread-dot\s*\{[\s\S]*position:\s*absolute;/)
test('sidebar no longer renders document center unread indicators', () => {
assert.doesNotMatch(sidebar, /useDocumentCenterInbox/)
assert.doesNotMatch(sidebar, /hasUnread: documentInboxHasUnread/)
assert.doesNotMatch(sidebar, /hasNewMessage/)
assert.doesNotMatch(sidebar, /nav-label-text/)
assert.doesNotMatch(sidebar, /nav-unread-dot/)
assert.match(sidebar, /<span class="nav-label">\{\{ item\.displayLabel \}\}<\/span>/)
assert.doesNotMatch(sidebarStyles, /\.nav-label-text\s*\{/)
assert.doesNotMatch(sidebarStyles, /\.nav-unread-dot/)
assert.match(sidebarStyles, /\.nav-label\s*\{[\s\S]*overflow:\s*hidden;[\s\S]*text-overflow:\s*ellipsis;/)
})
test('topbar bell owns document center unread notifications', () => {
assert.match(topbar, /useDocumentCenterInbox/)
assert.match(topbar, /useTopBarNotificationStates/)
assert.match(topbar, /notificationRows: documentInboxNotificationRows/)
assert.match(topbar, /const documentNotificationItems = computed/)
assert.match(topbar, /title: `\$\{row\.documentTypeLabel \|\| '单据'\} \$\{row\.documentNo \|\| row\.claimId \|\| '待生成'\}`/)
assert.match(topbar, /description: resolveDocumentNotificationDescription\(row\)/)
assert.match(topbar, /const unread = Boolean\(row\.isUnread\) && !isNotificationRead\(id\)/)
assert.match(topbar, /markDocumentInboxRowRead\(item\.documentRow\)/)
assert.match(topbar, /markNotificationStateRead\(item\)/)
assert.match(topbar, /markDocumentInboxRowsRead\(documentRows\)/)
assert.match(topbar, /hideNotificationStates\(currentItems\)/)
assert.match(topbar, /loadNotificationStates\(\)/)
assert.match(topbar, /const topbarNotificationCount = computed\(\(\) => \{[\s\S]*const count = unreadNotifications\.value\.length/)
assert.doesNotMatch(topbar, /document-center-unread/)
assert.doesNotMatch(topbar, /target: \{ type: 'documents-center' \}/)
assert.doesNotMatch(topbar, /emit\('navigate', 'documents'\)/)
assert.match(appShellRouteView, /@navigate="handleNavigate"/)
assert.match(topbar, /startDocumentInboxPolling\(\)/)
assert.match(topbar, /stopDocumentInboxPolling\(\)/)
assert.match(topbar, /class="notification-clear-btn"/)
assert.match(topbar, /function clearAllNotifications\(\)/)
assert.match(topbar, /function markNotificationRead\(item\)/)
assert.match(topbar, /class="notification-type-icon" :class="item\.tone"/)
assert.match(topbarStyles, /\.notification-head-copy\s*\{[\s\S]*display:\s*grid;/)
assert.match(topbarStyles, /\.notification-head-actions\s*\{[\s\S]*display:\s*inline-flex;/)
assert.match(topbarStyles, /\.notification-popover\s*\{[\s\S]*max-height:\s*min\(520px,\s*calc\(100vh - 96px\)\);/)
assert.match(topbarStyles, /\.notification-list\s*\{[\s\S]*max-height:\s*min\(336px,\s*calc\(100vh - 226px\)\);[\s\S]*overflow-y:\s*auto;/)
assert.match(topbarStyles, /\.notification-copy small\s*\{[\s\S]*-webkit-line-clamp:\s*2;/)
assert.match(topbarStyles, /@media \(max-width: 640px\)[\s\S]*\.notification-popover\s*\{[\s\S]*position:\s*fixed;/)
assert.match(topbarStyles, /\.notification-tabs button em\s*\{[\s\S]*border-radius:\s*4px;/)
assert.match(topbarStyles, /\.notification-row\s*\{[\s\S]*grid-template-columns:\s*34px minmax\(0,\s*1fr\) 16px;/)
assert.doesNotMatch(topbarStyles, /\.notification-dot/)
})
test('topbar notification state is persisted through backend API with local fallback', () => {
assert.match(notificationStatesService, /apiRequest\('\/notification-states'\)/)
assert.match(notificationStatesService, /apiRequest\('\/notification-states',\s*\{[\s\S]*method:\s*'POST'/)
assert.match(topbarNotificationStates, /fetchNotificationStates/)
assert.match(topbarNotificationStates, /patchNotificationStates/)
assert.match(topbarNotificationStates, /NOTIFICATION_READ_STORAGE_KEY/)
assert.match(topbarNotificationStates, /NOTIFICATION_HIDDEN_STORAGE_KEY/)
assert.match(topbarNotificationStates, /applyRemoteStates/)
assert.match(topbarNotificationStates, /markNotificationStateRead/)
assert.match(topbarNotificationStates, /hideNotificationStates/)
}) })
test('document inbox reuses document center viewed-key state', () => { test('document inbox reuses document center viewed-key state', () => {
assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/) assert.match(documentInbox, /DOCUMENT_VIEWED_KEYS_CHANGE_EVENT/)
assert.match(documentInbox, /readViewedDocumentKeys/) assert.match(documentInbox, /readViewedDocumentKeys/)
assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/) assert.match(documentInbox, /countNewDocuments\(documentRows\.value, viewedDocumentKeys\.value\)/)
assert.match(documentInbox, /const notificationRows = computed/)
assert.match(documentInbox, /isUnread: isNewDocument\(row, viewedDocumentKeys\.value\)/)
assert.match(documentInbox, /function markDocumentInboxRowRead\(row\)/)
assert.match(documentInbox, /function markDocumentInboxRowsRead\(rows = documentRows\.value\)/)
assert.match(documentInbox, /fetchExpenseClaims/) assert.match(documentInbox, /fetchExpenseClaims/)
assert.match(documentInbox, /fetchApprovalExpenseClaims/) assert.match(documentInbox, /fetchApprovalExpenseClaims/)
assert.match(documentInbox, /fetchArchivedExpenseClaims/) assert.match(documentInbox, /fetchArchivedExpenseClaims/)

View File

@@ -254,7 +254,7 @@ test('guided reimbursement requires application selection for travel and enterta
reason: '客户招待沟通项目', reason: '客户招待沟通项目',
location: '武汉', location: '武汉',
amount: 600, amount: 600,
status: 'submitted', status: 'approved',
created_at: '2026-05-21T08:00:00Z' created_at: '2026-05-21T08:00:00Z'
}, },
{ {
@@ -265,6 +265,44 @@ test('guided reimbursement requires application selection for travel and enterta
reason: '草稿出差申请', reason: '草稿出差申请',
status: 'draft' status: 'draft'
}, },
{
id: 'app-submitted',
claim_no: 'AP-202605-005',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '审批中的出差申请',
status: 'submitted'
},
{
id: 'app-archived-stale-key',
claim_no: 'AP-202605-007',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '已归档申请单',
status: 'archived',
approvalKey: 'completed'
},
{
id: 'app-linked',
claim_no: 'AP-202605-006',
employee_name: '张小青',
expense_type: 'travel_application',
reason: '已生成报销草稿的出差申请',
status: 'approved'
},
{
id: 're-linked-draft',
claim_no: 'RE-202605-006',
employee_name: '张小青',
expense_type: 'travel',
reason: '已关联申请单的报销草稿',
status: 'draft',
risk_flags_json: [{
source: 'application_link',
application_claim_id: 'app-linked',
application_claim_no: 'AP-202605-006'
}]
},
{ {
id: 'app-other-user', id: 'app-other-user',
claim_no: 'AP-202605-004', claim_no: 'AP-202605-004',

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