feat(web): 优化差旅详情、风险建议卡片与文档中心交互
- 拆分阶段风险建议卡片样式到独立文件 - 完善差旅申请审批对话框与详情视图交互 - 调整文档中心列表共享样式与状态筛选 - 同步应用外壳、视图初始化与系统状态 composables
This commit is contained in:
@@ -31,16 +31,27 @@
|
||||
class="shared-confirm-btn cancel"
|
||||
:disabled="busy"
|
||||
@click="handleCancel"
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shared-confirm-btn confirm"
|
||||
:class="confirmTone"
|
||||
:disabled="busy"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
>
|
||||
{{ cancelText }}
|
||||
</button>
|
||||
<button
|
||||
v-if="secondaryText"
|
||||
type="button"
|
||||
class="shared-confirm-btn secondary"
|
||||
:class="secondaryTone"
|
||||
:disabled="busy"
|
||||
@click="$emit('secondary')"
|
||||
>
|
||||
<i v-if="secondaryIcon" :class="secondaryIcon"></i>
|
||||
<span>{{ secondaryText }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="shared-confirm-btn confirm"
|
||||
:class="confirmTone"
|
||||
:disabled="busy || confirmDisabled"
|
||||
@click="$emit('confirm')"
|
||||
>
|
||||
<i v-if="confirmIcon" :class="busy ? 'mdi mdi-loading mdi-spin' : confirmIcon"></i>
|
||||
<span>{{ busy ? busyText : confirmText }}</span>
|
||||
</button>
|
||||
@@ -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;
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="detail-card-head employee-risk-head">
|
||||
<div class="employee-risk-title-wrap">
|
||||
<h3 class="detail-card-title-with-icon">
|
||||
<i class="mdi mdi-account-search-outline"></i>
|
||||
<i class="mdi mdi-file-document-alert-outline"></i>
|
||||
<span>{{ stageTitle }}</span>
|
||||
</h3>
|
||||
<span :class="['employee-risk-tone-pill', decisionTone]">{{ decisionBadgeLabel }}</span>
|
||||
@@ -11,45 +11,39 @@
|
||||
</div>
|
||||
|
||||
<div class="employee-risk-body">
|
||||
<section :class="['employee-risk-ai-note', decisionTone]">
|
||||
<div class="employee-risk-ai-main">
|
||||
<span>AI 审核建议</span>
|
||||
<section :class="['employee-risk-decision-panel', decisionTone]">
|
||||
<div class="employee-risk-decision-main">
|
||||
<span>综合审核结论</span>
|
||||
<strong>{{ decisionTitle }}</strong>
|
||||
<p>{{ decisionDescription }}</p>
|
||||
</div>
|
||||
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list">
|
||||
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p>
|
||||
</div>
|
||||
<div class="employee-risk-action">
|
||||
<span>建议动作</span>
|
||||
<div class="employee-risk-decision-action">
|
||||
<span>建议结论</span>
|
||||
<strong :class="decisionTone">{{ decisionAction }}</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="employee-risk-profile-section">
|
||||
<section class="employee-risk-profile-section" aria-label="单据风险依据">
|
||||
<div class="employee-risk-section-head">
|
||||
<span>{{ stageBasisTitle }}</span>
|
||||
<small>{{ stageBasisHint }}</small>
|
||||
</div>
|
||||
|
||||
<div class="employee-risk-profile-list">
|
||||
<section
|
||||
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
|
||||
<article
|
||||
v-for="item in compactEvidenceItems"
|
||||
:key="item.code"
|
||||
:class="['employee-risk-profile', item.tone]"
|
||||
:class="['employee-risk-evidence-row', item.tone]"
|
||||
>
|
||||
<div class="employee-risk-profile-title">
|
||||
<div class="employee-risk-evidence-title">
|
||||
<span>{{ item.label }}</span>
|
||||
<strong :class="item.tone">{{ item.status }}</strong>
|
||||
<strong>{{ item.status }}</strong>
|
||||
</div>
|
||||
<ul v-if="item.evidence.length" class="employee-risk-evidence-list">
|
||||
<li v-for="basis in item.evidence" :key="basis">
|
||||
{{ basis }}
|
||||
</li>
|
||||
<ul v-if="item.evidence.length">
|
||||
<li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
|
||||
</ul>
|
||||
<p v-else class="employee-risk-muted">暂无显著贡献项。</p>
|
||||
</section>
|
||||
</article>
|
||||
</div>
|
||||
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据风险依据。</p>
|
||||
</section>
|
||||
</div>
|
||||
</article>
|
||||
@@ -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) {
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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-profile-title strong.normal {
|
||||
background: #ecfdf5;
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong.medium {
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong.high {
|
||||
background: #fef2f2;
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-body {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note,
|
||||
.employee-risk-profile {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note > span,
|
||||
.employee-risk-ai-main > span,
|
||||
.employee-risk-section-head span {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note strong,
|
||||
.employee-risk-ai-main strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
align-items: start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.employee-risk-ai-main {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.high {
|
||||
border-color: #fecaca;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.medium strong {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note.high strong {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-ai-note p {
|
||||
margin: 0;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-advice-list {
|
||||
display: grid;
|
||||
grid-column: 1 / -1;
|
||||
gap: 4px;
|
||||
margin-top: -2px;
|
||||
}
|
||||
|
||||
.employee-risk-action {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.employee-risk-action span {
|
||||
flex: 0 0 auto;
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-action strong {
|
||||
min-width: 0;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
line-height: 1.5;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.employee-risk-action strong.medium {
|
||||
color: #c2410c;
|
||||
}
|
||||
|
||||
.employee-risk-action strong.high {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.employee-risk-advice-list p {
|
||||
margin: 0;
|
||||
padding-left: 8px;
|
||||
border-left: 2px solid #cbd5e1;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.employee-risk-profile-section {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.employee-risk-section-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.employee-risk-section-head small {
|
||||
color: #94a3b8;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.employee-risk-profile-list {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.employee-risk-profile {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: 142px minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.employee-risk-profile.medium {
|
||||
border-color: #fed7aa;
|
||||
background: #fffaf4;
|
||||
}
|
||||
|
||||
.employee-risk-profile.high {
|
||||
border-color: #fecaca;
|
||||
background: #fff7f7;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-height: 22px;
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-profile-title strong {
|
||||
width: 48px;
|
||||
height: 20px;
|
||||
flex: 0 0 48px;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-list {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.employee-risk-evidence-list li {
|
||||
min-width: 0;
|
||||
color: #475569;
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.employee-risk-muted {
|
||||
margin: 0;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.employee-risk-ai-note,
|
||||
.employee-risk-profile {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.employee-risk-title-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style scoped src="../../assets/styles/components/stage-risk-advice-card.css"></style>
|
||||
|
||||
@@ -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')"
|
||||
>
|
||||
<section v-if="riskConfirmRequired" class="approval-risk-confirm-panel" aria-label="风险说明确认">
|
||||
<div class="approval-risk-confirm-head">
|
||||
<span><i class="mdi mdi-alert-decagram-outline"></i>风险说明确认</span>
|
||||
<strong>{{ riskConfirmItems.length }} 项需核对</strong>
|
||||
</div>
|
||||
<ul v-if="riskConfirmItems.length" class="approval-risk-confirm-list">
|
||||
<li v-for="item in riskConfirmItems" :key="item.id || item.title">
|
||||
<em :class="item.tone">{{ item.label }}</em>
|
||||
<div>
|
||||
<strong>{{ item.title }}</strong>
|
||||
<span>{{ item.description }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<label class="approval-risk-confirm-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="riskConfirmed"
|
||||
:disabled="busy"
|
||||
@change="handleRiskConfirmedChange"
|
||||
/>
|
||||
<span>我已核对风险说明、异常原因和佐证材料,确认继续当前审批动作。</span>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<label class="approval-opinion-field">
|
||||
<span>
|
||||
{{ opinionTitle }}
|
||||
@@ -53,19 +79,132 @@ const props = defineProps({
|
||||
opinion: { type: String, default: '' },
|
||||
opinionPlaceholder: { type: String, default: '' },
|
||||
opinionHint: { type: String, default: '' },
|
||||
opinionRequired: { type: Boolean, default: false }
|
||||
opinionRequired: { type: Boolean, default: false },
|
||||
riskConfirmRequired: { type: Boolean, default: false },
|
||||
riskConfirmed: { type: Boolean, default: false },
|
||||
riskConfirmItems: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'confirm', 'update:opinion'])
|
||||
const emit = defineEmits(['close', 'confirm', 'update:opinion', 'update:risk-confirmed'])
|
||||
|
||||
const currentOpinion = computed(() => String(props.opinion || ''))
|
||||
const confirmDisabled = computed(() => (
|
||||
(props.riskConfirmRequired && !props.riskConfirmed)
|
||||
|| (props.opinionRequired && !currentOpinion.value.trim())
|
||||
))
|
||||
|
||||
function handleOpinionInput(event) {
|
||||
emit('update:opinion', event.target.value)
|
||||
}
|
||||
|
||||
function handleRiskConfirmedChange(event) {
|
||||
emit('update:risk-confirmed', Boolean(event.target.checked))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.approval-risk-confirm-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border: 1px solid #fed7aa;
|
||||
border-radius: 4px;
|
||||
background: #fff7ed;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-head span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
color: #9a3412;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-head strong {
|
||||
color: #c2410c;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list li {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(251, 146, 60, 0.28);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list em {
|
||||
min-width: 46px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: #fff7ed;
|
||||
color: #c2410c;
|
||||
font-style: normal;
|
||||
font-size: 11px;
|
||||
font-weight: 850;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list em.high {
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list strong {
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-list span {
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-check {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
color: #334155;
|
||||
font-size: 12px;
|
||||
font-weight: 750;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.approval-risk-confirm-check input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin-top: 2px;
|
||||
accent-color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.approval-opinion-field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user