diff --git a/web/src/assets/styles/components/document-list-shared.css b/web/src/assets/styles/components/document-list-shared.css index 530e3d2..444cbc3 100644 --- a/web/src/assets/styles/components/document-list-shared.css +++ b/web/src/assets/styles/components/document-list-shared.css @@ -507,7 +507,8 @@ td small { .doc-kind-tag, .type-tag, -.status-tag { +.status-tag, +.risk-level-tag { display: inline-flex; align-items: center; justify-content: center; @@ -612,6 +613,49 @@ td small { color: #475569; } +.risk-level-tags { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + flex-wrap: wrap; +} + +.risk-level-tag { + min-height: 24px; + padding: 0 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; + color: #64748b; + font-size: 12px; + font-weight: 850; +} + +.risk-level-tag.high { + border-color: #fecaca; + background: #fef2f2; + color: #dc2626; +} + +.risk-level-tag.medium { + border-color: #fed7aa; + background: #fff7ed; + color: #c2410c; +} + +.risk-level-tag.low { + border-color: #bfdbfe; + background: #eff6ff; + color: #2563eb; +} + +.risk-level-tag.none { + border-color: #e2e8f0; + background: #f8fafc; + color: #64748b; +} + .list-foot { display: grid; grid-template-columns: 1fr auto 1fr; diff --git a/web/src/assets/styles/components/stage-risk-advice-card.css b/web/src/assets/styles/components/stage-risk-advice-card.css new file mode 100644 index 0000000..fd89b0a --- /dev/null +++ b/web/src/assets/styles/components/stage-risk-advice-card.css @@ -0,0 +1,302 @@ +.employee-risk-profile-card { + display: grid; + gap: 10px; + padding: 12px 14px; +} + +.employee-risk-head { + margin-bottom: 0; +} + +.employee-risk-title-wrap { + min-width: 0; + display: flex; + align-items: center; + gap: 10px; +} + +.detail-card h3 { + margin: 0; + color: #0f172a; + font-size: 14px; + font-weight: 850; +} + +.detail-card-head h3 { + margin-bottom: 0; +} + +.detail-card-title-with-icon { + display: inline-flex; + align-items: center; + gap: 6px; + line-height: 1.5; +} + +.detail-card-title-with-icon i { + margin-top: 1px; + color: #334155; + font-size: 16px; + line-height: 1; +} + +.employee-risk-tone-pill { + height: 22px; + display: inline-flex; + align-items: center; + padding: 0 8px; + border: 1px solid #dbe4ee; + border-radius: 4px; + background: #f8fafc; + color: #475569; + font-size: 11px; + font-weight: 850; + white-space: nowrap; +} + +.employee-risk-tone-pill.normal { + border-color: #bbf7d0; + background: #f0fdf4; + color: #047857; +} + +.employee-risk-tone-pill.medium { + border-color: #fed7aa; + background: #fff7ed; + color: #c2410c; +} + +.employee-risk-tone-pill.high { + border-color: #fecaca; + background: #fef2f2; + color: #b91c1c; +} + +.employee-risk-body { + display: grid; + gap: 10px; +} + +.employee-risk-decision-panel { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(220px, 32%); + align-items: stretch; + gap: 12px; + padding: 12px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; +} + +.employee-risk-decision-panel.medium { + border-color: #fed7aa; + background: #fff7ed; +} + +.employee-risk-decision-panel.high { + border-color: #fecaca; + background: #fef2f2; +} + +.employee-risk-decision-main { + min-width: 0; + display: grid; + gap: 4px; +} + +.employee-risk-decision-main > span, +.employee-risk-decision-action span { + color: #64748b; + font-size: 10px; + font-weight: 850; + line-height: 1.5; +} + +.employee-risk-decision-main strong { + min-width: 0; + color: #0f172a; + font-size: 13px; + font-weight: 850; + overflow-wrap: anywhere; +} + +.employee-risk-decision-panel.medium .employee-risk-decision-main strong { + color: #c2410c; +} + +.employee-risk-decision-panel.high .employee-risk-decision-main strong { + color: #b91c1c; +} + +.employee-risk-decision-main p { + margin: 0; + color: #334155; + font-size: 12px; + line-height: 1.5; +} + +.employee-risk-decision-action { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + justify-content: center; + gap: 5px; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #fff; +} + +.employee-risk-decision-action strong { + min-width: 0; + color: #0f172a; + font-size: 12px; + font-weight: 800; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.employee-risk-decision-action strong.medium { + color: #c2410c; +} + +.employee-risk-decision-action strong.high { + color: #b91c1c; +} + +.employee-risk-profile-section { + display: grid; + gap: 8px; + padding: 10px 12px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #fff; +} + +.employee-risk-section-head { + min-width: 0; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.employee-risk-section-head span { + color: #0f172a; + font-size: 12px; + font-weight: 850; +} + +.employee-risk-section-head small { + min-width: 0; + color: #64748b; + font-size: 11px; + line-height: 1.5; + text-align: right; +} + +.employee-risk-profile-list { + display: grid; + grid-template-columns: 1fr; + gap: 8px; +} + +.employee-risk-evidence-row { + min-width: 0; + display: grid; + gap: 5px; + padding: 8px; + border: 1px solid #e2e8f0; + border-radius: 4px; + background: #f8fafc; +} + +.employee-risk-evidence-row.medium { + border-color: #fed7aa; + background: #fffbf5; +} + +.employee-risk-evidence-row.high { + border-color: #fecaca; + background: #fff7f7; +} + +.employee-risk-evidence-title { + min-height: 20px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + color: #0f172a; + font-size: 11px; + font-weight: 850; +} + +.employee-risk-evidence-title span { + min-width: 0; + overflow-wrap: anywhere; +} + +.employee-risk-evidence-title strong { + height: 20px; + flex: 0 0 auto; + display: inline-grid; + place-items: center; + padding: 0 6px; + border-radius: 4px; + background: #eef2f7; + color: #475569; + font-size: 10px; + font-weight: 900; + white-space: nowrap; +} + +.employee-risk-evidence-row.medium .employee-risk-evidence-title strong { + background: #ffedd5; + color: #c2410c; +} + +.employee-risk-evidence-row.high .employee-risk-evidence-title strong { + background: #fee2e2; + color: #b91c1c; +} + +.employee-risk-evidence-row ul { + display: grid; + gap: 3px; + margin: 0; + padding: 0; + list-style: none; + align-content: start; +} + +.employee-risk-evidence-row li { + min-width: 0; + color: #475569; + font-size: 11px; + line-height: 1.45; + overflow-wrap: anywhere; + white-space: normal; +} + +.employee-risk-muted { + margin: 0; + color: #94a3b8; + font-size: 11px; +} + +@media (max-width: 960px) { + .employee-risk-decision-panel { + grid-template-columns: 1fr; + } + + .employee-risk-title-wrap, + .employee-risk-section-head { + flex-wrap: wrap; + } + + .employee-risk-section-head small { + text-align: left; + } +} diff --git a/web/src/assets/styles/views/documents-center-view.css b/web/src/assets/styles/views/documents-center-view.css index 2b4605b..b84bc28 100644 --- a/web/src/assets/styles/views/documents-center-view.css +++ b/web/src/assets/styles/views/documents-center-view.css @@ -24,7 +24,7 @@ .col-title { width: 16%; } .col-amount { width: 9%; } .col-node { width: 12%; } -.col-status { width: 8%; } +.col-risk { width: 8%; } .col-updated { width: 9%; } .new-document-badge { diff --git a/web/src/assets/styles/views/travel-request-detail-view.css b/web/src/assets/styles/views/travel-request-detail-view.css index 43d7ab3..27c93a3 100644 --- a/web/src/assets/styles/views/travel-request-detail-view.css +++ b/web/src/assets/styles/views/travel-request-detail-view.css @@ -2059,36 +2059,45 @@ gap: 12px; } -.risk-override-nav { +.risk-override-card-shell { display: grid; - grid-template-columns: 34px minmax(0, 1fr) 34px; - align-items: center; - gap: 8px; + grid-template-columns: 28px minmax(0, 1fr) 28px; + align-items: stretch; + gap: 6px; } -.risk-override-nav span { +.risk-override-side-nav { + width: 28px; + min-height: 76px; + display: inline-flex; + align-items: center; + justify-content: center; + align-self: stretch; + border: 1px solid #dbe3ee; + border-radius: 4px; + background: #f8fafc; + color: #475569; + font-size: 16px; +} + +.risk-override-side-nav:not(:disabled):hover { + border-color: #b8c5d6; + background: #eef4fb; + color: #1e293b; +} + +.risk-override-side-nav:disabled { + cursor: not-allowed; + opacity: .48; +} + +.risk-override-index { display: inline-flex; justify-content: center; color: #64748b; font-size: 12px; font-weight: 850; -} - -.risk-override-nav-btn { - width: 34px; - height: 34px; - display: inline-flex; - align-items: center; - justify-content: center; - border: 1px solid #e2e8f0; - border-radius: 4px; - background: #fff; - color: #475569; -} - -.risk-override-nav-btn:disabled { - cursor: not-allowed; - opacity: .48; + line-height: 1; } .risk-override-card { @@ -2135,6 +2144,26 @@ line-height: 1.6; } +.risk-override-notes { + display: grid; + gap: 6px; + padding-top: 8px; + border-top: 1px dashed #cbd5e1; +} + +.risk-override-notes span { + color: #64748b; + font-size: 11px; + font-weight: 850; +} + +.risk-override-notes strong { + color: #0f172a; + font-size: 12px; + font-weight: 780; + line-height: 1.55; +} + .risk-override-guidance { display: grid; gap: 4px; diff --git a/web/src/components/shared/ConfirmDialog.vue b/web/src/components/shared/ConfirmDialog.vue index 98d199a..a2047f0 100644 --- a/web/src/components/shared/ConfirmDialog.vue +++ b/web/src/components/shared/ConfirmDialog.vue @@ -31,16 +31,27 @@ class="shared-confirm-btn cancel" :disabled="busy" @click="handleCancel" - > - {{ cancelText }} - - + + @@ -60,18 +71,22 @@ const props = defineProps({ badgeTone: { type: String, default: 'info' }, title: { type: String, required: true }, description: { type: String, default: '' }, - cancelText: { type: String, default: '取消' }, - confirmText: { type: String, default: '确认' }, + cancelText: { type: String, default: '取消' }, + secondaryText: { type: String, default: '' }, + secondaryTone: { type: String, default: 'warning' }, + secondaryIcon: { type: String, default: '' }, + confirmText: { type: String, default: '确认' }, busyText: { type: String, default: '处理中...' }, confirmTone: { type: String, default: 'primary' }, confirmIcon: { type: String, default: '' }, + confirmDisabled: { type: Boolean, default: false }, busy: { type: Boolean, default: false }, closeOnMask: { type: Boolean, default: true }, size: { type: String, default: 'default' }, actionsAlign: { type: String, default: 'end' } }) -const emit = defineEmits(['close', 'cancel', 'confirm']) +const emit = defineEmits(['close', 'cancel', 'secondary', 'confirm']) const instance = getCurrentInstance() const titleId = computed(() => `shared-confirm-title-${instance?.uid || 'dialog'}`) @@ -213,15 +228,33 @@ function handleCancel() { transition: transform 160ms ease, box-shadow 160ms ease, border-color 160ms ease, background 160ms ease; } -.shared-confirm-btn.cancel { - border: 1px solid #d7e0ea; - background: rgba(255, 255, 255, 0.92); - color: #475569; -} - -.shared-confirm-btn.confirm { - border: 1px solid transparent; - color: #fff; +.shared-confirm-btn.cancel { + border: 1px solid #d7e0ea; + background: rgba(255, 255, 255, 0.92); + color: #475569; +} + +.shared-confirm-btn.secondary { + border: 1px solid rgba(245, 158, 11, 0.28); + background: rgba(255, 251, 235, 0.92); + color: #b45309; +} + +.shared-confirm-btn.secondary.primary { + border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.28); + background: var(--theme-primary-soft); + color: var(--theme-primary-active); +} + +.shared-confirm-btn.secondary.danger { + border-color: rgba(var(--danger-rgb), 0.24); + background: var(--danger-soft); + color: var(--danger-hover); +} + +.shared-confirm-btn.confirm { + border: 1px solid transparent; + color: #fff; } .shared-confirm-btn.confirm.primary { @@ -238,10 +271,15 @@ function handleCancel() { border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.3); color: var(--theme-primary-active); } - -.shared-confirm-btn.confirm:hover:not(:disabled) { - transform: translateY(-1px); -} + +.shared-confirm-btn.secondary:hover:not(:disabled) { + border-color: rgba(245, 158, 11, 0.42); + background: rgba(254, 243, 199, 0.96); +} + +.shared-confirm-btn.confirm:hover:not(:disabled) { + transform: translateY(-1px); +} .shared-confirm-btn:disabled { cursor: not-allowed; diff --git a/web/src/components/travel/StageRiskAdviceCard.vue b/web/src/components/travel/StageRiskAdviceCard.vue index b39a14d..2f0fb48 100644 --- a/web/src/components/travel/StageRiskAdviceCard.vue +++ b/web/src/components/travel/StageRiskAdviceCard.vue @@ -3,7 +3,7 @@

- + {{ stageTitle }}

{{ decisionBadgeLabel }} @@ -11,45 +11,39 @@
-
-
- AI 审核建议 +
+
+ 综合审核结论 {{ decisionTitle }}

{{ decisionDescription }}

-
-

{{ item }}

-
-
- 建议动作 +
+ 建议结论 {{ decisionAction }}
-
+
{{ stageBasisTitle }} {{ stageBasisHint }}
- -
-
+
-
+
{{ item.label }} - {{ item.status }} + {{ item.status }}
-
    -
  • - {{ basis }} -
  • +
      +
    • {{ basis }}
    -

    暂无显著贡献项。

    -
+
+

当前未识别到需要重点展示的单据风险依据。

@@ -81,6 +75,7 @@ export default { setup(props) { const requestModel = computed(() => props.request || {}) const currentItems = computed(() => Array.isArray(props.expenseItems) ? props.expenseItems : []) + // 只消费语义层归一后的风险卡片,展示层不新增业务风险字段。 const currentRiskCards = computed(() => (Array.isArray(props.aiAdvice?.riskCards) ? props.aiAdvice.riskCards : []) .filter((card) => matchesCurrentStage(card, props.isApplicationDocument)) @@ -88,6 +83,7 @@ export default { ) const highRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'high')) const mediumRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'medium')) + const riskExplanationItems = computed(() => uniqueTexts(currentRiskCards.value.flatMap((card) => riskExplanationTexts([card])))) const materialIssues = computed(() => props.isApplicationDocument ? [] : resolveReimbursementMaterialIssues(currentItems.value)) const sceneIssues = computed(() => resolveSceneIssues(requestModel.value, currentItems.value, props.isApplicationDocument)) const decisionTone = computed(() => { @@ -100,14 +96,19 @@ export default { return 'normal' }) const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议') - const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请环节风险依据' : '报销环节风险依据') + const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单风险依据' : '报销单风险依据') const stageBasisHint = computed(() => ( props.isApplicationDocument - ? '只展示本次申请可能影响预算和审批的风险。' - : '只展示本次报销可能影响票据、金额和付款的风险。' + ? '仅展示申请单本身的金额、预算触发、事由和规则命中依据。' + : '仅展示报销单本身的票据、金额、行程和规则命中依据。' )) const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title) - const decisionAction = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).action) + const decisionAction = computed(() => { + if (!props.isApplicationDocument && riskExplanationItems.value.length && ['medium', 'high'].includes(decisionTone.value)) { + return '请核对已补充说明是否覆盖风险点,再决定通过或退回补充。' + } + return resolveDecision(decisionTone.value, props.isApplicationDocument).action + }) const decisionBadgeLabel = computed(() => { if (decisionTone.value === 'high') { return '高风险' @@ -120,6 +121,9 @@ export default { const decisionDescription = computed(() => { const riskCount = currentRiskCards.value.length if (riskCount) { + if (!props.isApplicationDocument && riskExplanationItems.value.length) { + return `当前报销已识别 ${riskCount} 个需核对风险点,用户已补充异常说明,审批人应核对说明与票据佐证是否充分。` + } return `${props.isApplicationDocument ? '当前申请' : '当前报销'}已识别 ${riskCount} 个需核对风险点,审批人应优先查看中高风险依据。` } if (materialIssues.value.length || sceneIssues.value.length) { @@ -127,24 +131,13 @@ export default { } return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。` }) - const adviceItems = computed(() => { - const fromRiskCards = currentRiskCards.value - .map((card) => String(card?.suggestion || card?.risk || '').trim()) - .filter(Boolean) - return uniqueTexts(fromRiskCards.length ? fromRiskCards : resolveDecision(decisionTone.value, props.isApplicationDocument).advice).slice(0, 4) - }) - const compactAdviceItems = computed(() => adviceItems.value.slice(0, 2)) - const stageEvidenceItems = computed(() => ( props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence() )) const compactEvidenceItems = computed(() => { const abnormalItems = stageEvidenceItems.value.filter((item) => isAbnormalEvidence(item)) const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value - return sourceItems.slice(0, 3).map((item) => ({ - ...item, - evidence: item.evidence.slice(0, 2) - })) + return sourceItems.map((item) => ({ ...item })) }) function buildApplicationEvidence() { @@ -156,7 +149,7 @@ export default { `申请金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`, ...riskTexts(amountCards) ]), - evidenceItem('apply_budget', '预算影响', budgetCards.length ? '需复核' : '未命中', budgetCards.length ? highestTone(budgetCards) : 'normal', ( + evidenceItem('apply_budget', '预算触发规则', budgetCards.length ? '需复核' : '未命中', budgetCards.length ? highestTone(budgetCards) : 'normal', ( budgetCards.length ? riskTexts(budgetCards) : ['当前申请暂未命中预算余额或预算占用类中高风险。'] )), evidenceItem('apply_scene', '申请事由与场景', sceneIssues.value.length ? '待补充' : '已说明', sceneIssues.value.length ? 'medium' : 'normal', [ @@ -172,32 +165,39 @@ export default { } function buildReimbursementEvidence() { - const attachmentCards = currentRiskCards.value.filter((card) => /附件|票据|发票|OCR|识别|单据/.test(cardText(card))) - const amountCards = currentRiskCards.value.filter((card) => /金额|标准|阈值|超标|不一致/.test(cardText(card))) - const routeCards = currentRiskCards.value.filter((card) => /城市|行程|住宿|交通|出差|地点|日期|时间/.test(cardText(card))) + const riskGroups = classifyReimbursementRiskCards(currentRiskCards.value) + const attachmentCards = riskGroups.attachment + const amountCards = riskGroups.amount + const routeCards = riskGroups.route + const otherCards = riskGroups.other const needAttachmentItems = currentItems.value.filter((item) => !item?.isSystemGenerated) const uploadedCount = needAttachmentItems.filter((item) => String(item?.invoiceId || '').trim()).length - return [ + const evidenceItems = [ evidenceItem('reimburse_attachment', '票据与附件', materialIssues.value.length || attachmentCards.length ? '需核对' : '完整', materialIssues.value.length || attachmentCards.length ? highestTone(attachmentCards, 'medium') : 'normal', [ `需附件明细 ${needAttachmentItems.length} 条,已关联 ${uploadedCount} 条,未上传 ${materialIssues.value.length} 条。`, ...materialIssues.value.slice(0, 3), ...riskTexts(attachmentCards) ]), evidenceItem('reimburse_amount', '报销金额与明细', amountCards.length ? '需复核' : '正常', amountCards.length ? highestTone(amountCards) : 'normal', [ + ...riskTexts(amountCards), + ...riskExplanationTexts(amountCards), `报销金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`, - `费用明细:${currentItems.value.length} 条,明细合计 ${formatCurrency(totalItemAmount(currentItems.value))}。`, - ...riskTexts(amountCards) + `费用明细:${currentItems.value.length} 条,明细合计 ${formatCurrency(totalItemAmount(currentItems.value))}。` ]), evidenceItem('reimburse_route', '行程/时间/地点', routeCards.length || sceneIssues.value.length ? '需核对' : '已匹配', routeCards.length ? highestTone(routeCards) : sceneIssues.value.length ? 'medium' : 'normal', [ + ...riskTexts(routeCards), + ...riskExplanationTexts(routeCards), `报销事由:${displayValue(requestModel.value.reason, '待补充')}`, `报销地点/目的地:${displayValue(requestModel.value.location || requestModel.value.sceneTarget, '待补充')}`, - ...sceneIssues.value.map((item) => `当前缺少:${item}`), - ...riskTexts(routeCards) - ]), - evidenceItem('reimburse_risk', '报销规则命中', currentRiskCards.value.length ? '有风险' : '无异常', decisionTone.value, ( - currentRiskCards.value.length ? riskTexts(currentRiskCards.value) : ['报销环节未命中中高风险规则。'] - )) + ...sceneIssues.value.map((item) => `当前缺少:${item}`) + ]) ] + if (otherCards.length || (!amountCards.length && !routeCards.length && !attachmentCards.length)) { + evidenceItems.push(evidenceItem('reimburse_risk', '其他规则命中', otherCards.length ? '有风险' : '无异常', otherCards.length ? highestTone(otherCards) : decisionTone.value, ( + otherCards.length ? riskTexts(otherCards) : ['报销环节未命中中高风险规则。'] + ))) + } + return evidenceItems } function evidenceItem(code, label, status, tone, evidence) { @@ -211,8 +211,6 @@ export default { } return { - adviceItems, - compactAdviceItems, compactEvidenceItems, decisionBadgeLabel, decisionTone, @@ -232,18 +230,15 @@ function resolveDecision(tone, isApplicationDocument) { const map = { normal: { title: `当前${subject}未发现中高风险阻断项`, - action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`, - advice: [`按当前${subject}信息、预算/票据结果和审批权限继续处理。`, '如审批人掌握额外业务背景,可在审批意见中补充。'] + action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}` }, medium: { title: `当前${subject}存在中风险,建议核对后处理`, - action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。', - advice: ['请优先核对橙色风险项对应的业务说明、金额和材料。', '信息补齐或说明充分后,再决定通过或退回。'] + action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。' }, high: { title: `当前${subject}存在高风险,不建议直接通过`, - action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。', - advice: ['请优先处理红色高风险项,核对命中规则和业务佐证。', '若属于真实业务例外,应要求申请人补充原因和证明材料。'] + action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。' } } return map[tone] || map.normal @@ -258,6 +253,40 @@ function isAbnormalEvidence(item) { return !['正常', '未命中', '已说明', '完整', '已匹配', '无异常'].includes(status) } +function classifyReimbursementRiskCards(cards = []) { + const groups = { + attachment: [], + amount: [], + route: [], + other: [] + } + for (const card of cards) { + const text = cardText(card) + if (isAmountRiskText(text)) { + groups.amount.push(card) + } else if (isRouteRiskText(text)) { + groups.route.push(card) + } else if (isAttachmentRiskText(text)) { + groups.attachment.push(card) + } else { + groups.other.push(card) + } + } + return groups +} + +function isAmountRiskText(value) { + return /金额|标准|阈值|超标|超出|报销标准|住宿标准|差标|费用上限/.test(String(value || '')) +} + +function isRouteRiskText(value) { + return /多城市|中转|多地|改签|城市|行程|交通|出差|地点|日期|时间|目的地|起始地|返回|火车|高铁|机票|航班/.test(String(value || '')) +} + +function isAttachmentRiskText(value) { + return /附件|票据|发票|OCR|识别|单据/.test(String(value || '')) +} + function matchesCurrentStage(card, isApplicationDocument) { const businessStage = resolveCardBusinessStage(card) if (businessStage) { @@ -367,11 +396,33 @@ function highestTone(cards, fallback = 'normal') { function riskTexts(cards) { return cards - .map((card) => String(card?.risk || card?.summary || card?.title || '').trim()) + .map((card) => stripEmbeddedExplanationText(card?.risk || card?.summary || card?.title || '')) .filter(Boolean) .slice(0, 4) } +function riskExplanationTexts(cards) { + return uniqueTexts(cards.flatMap((card) => { + const summary = String(card?.relatedExplanationSummary || '').trim() + const entries = Array.isArray(card?.relatedExplanations) + ? card.relatedExplanations.map((item) => String(item?.text || item?.note || '').trim()) + : [] + if (summary) { + return [`已补充异常说明:${summary}`] + } + return entries.map((item) => item ? `已补充异常说明:${item}` : '') + })).slice(0, 4) +} + +function stripEmbeddedExplanationText(value) { + return String(value || '') + .trim() + .replace(/,?用户已在相关费用明细补充异常说明[^。;;]*[。;;]?/g, '') + .replace(/,?用户已在费用明细补充异常说明[^。;;]*[。;;]?/g, '') + .replace(/[,,;;。]+$/g, '') + .trim() +} + function cardText(card) { return [ card?.label, @@ -413,327 +464,4 @@ function uniqueTexts(values) { } - + diff --git a/web/src/components/travel/TravelRequestApprovalDialog.vue b/web/src/components/travel/TravelRequestApprovalDialog.vue index ebb00e5..b480ead 100644 --- a/web/src/components/travel/TravelRequestApprovalDialog.vue +++ b/web/src/components/travel/TravelRequestApprovalDialog.vue @@ -10,10 +10,36 @@ :busy-text="busyText" confirm-tone="primary" confirm-icon="mdi mdi-check-circle-outline" + :confirm-disabled="confirmDisabled" :busy="busy" @close="emit('close')" @confirm="emit('confirm')" > +
+
+ 风险说明确认 + {{ riskConfirmItems.length }} 项需核对 +
+
    +
  • + {{ item.label }} +
    + {{ item.title }} + {{ item.description }} +
    +
  • +
+ +
+