feat(web): 工作台 AI 模式与差旅/风险建议交互优化

- 新增 PersonalWorkbenchAiMode 组件、AI 侧边栏与 orb 机器人视觉资源
- 新增 aiApplicationDraftModel / aiExpenseDraftModel / aiWorkbenchConversationStore
  及业务准入 aiSidebarBusinessAccess,支撑 AI 模式下的申请与报销草稿
- 顶栏、侧边栏、工作台样式重构,适配 AI 模式切换与响应式布局
- 同步 steward plan/off_topic、差旅报销引导流、风险建议卡片等测试
This commit is contained in:
caoxiaozhu
2026-06-18 22:12:24 +08:00
parent a6674a1e76
commit 0cde1f8990
65 changed files with 8011 additions and 1608 deletions

View File

@@ -18,32 +18,45 @@
<p>{{ decisionDescription }}</p>
</div>
<div class="employee-risk-decision-action">
<span>建议结论</span>
<strong :class="decisionTone">{{ decisionAction }}</strong>
<span>是否建议通过</span>
<strong :class="decisionTone">{{ decisionBadgeLabel }}</strong>
<p>{{ decisionAction }}</p>
</div>
</section>
<section class="employee-risk-profile-section" aria-label="单据风险依据">
<dl class="employee-risk-review-summary" aria-label="审核建议摘要">
<div
v-for="item in reviewSummaryItems"
:key="item.key"
:class="['employee-risk-review-item', item.tone]"
>
<dt>{{ item.label }}</dt>
<dd>{{ item.value }}</dd>
</div>
</dl>
<section class="employee-risk-profile-section" aria-label="单据关键依据">
<div class="employee-risk-section-head">
<span>{{ stageBasisTitle }}</span>
<small>{{ stageBasisHint }}</small>
</div>
<div v-if="compactEvidenceItems.length" class="employee-risk-profile-list">
<article
v-for="item in compactEvidenceItems"
<details
v-for="(item, index) in compactEvidenceItems"
:key="item.code"
:class="['employee-risk-evidence-row', item.tone]"
:open="index === 0"
>
<div class="employee-risk-evidence-title">
<summary class="employee-risk-evidence-title">
<span>{{ item.label }}</span>
<strong>{{ item.status }}</strong>
</div>
</summary>
<ul v-if="item.evidence.length">
<li v-for="basis in item.evidence" :key="basis">{{ basis }}</li>
</ul>
</article>
</details>
</div>
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据风险依据</p>
<p v-else class="employee-risk-muted">当前未识别到需要重点展示的单据依据</p>
</section>
</div>
</article>
@@ -95,12 +108,12 @@ export default {
}
return 'normal'
})
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : '报销审核建议')
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单风险依据' : '报销单风险依据')
const stageTitle = computed(() => props.isApplicationDocument ? '申请审核建议' : 'AI建议')
const stageBasisTitle = computed(() => props.isApplicationDocument ? '申请单关键依据' : '报销单关键依据')
const stageBasisHint = computed(() => (
props.isApplicationDocument
? '仅展示申请单本身的金额、预算触发、事由和规则命中依据。'
: '仅展示报销单本身的票据、金额、行程和规则命中依据。'
? '默认只展开最关键的申请依据,其他细节点开查看。'
: '默认只展开最关键的报销依据,其他细节点开查看。'
))
const decisionTitle = computed(() => resolveDecision(decisionTone.value, props.isApplicationDocument).title)
const decisionAction = computed(() => {
@@ -111,25 +124,26 @@ export default {
})
const decisionBadgeLabel = computed(() => {
if (decisionTone.value === 'high') {
return '高风险'
return '不通过'
}
if (decisionTone.value === 'medium') {
return '需关注'
return '待补充'
}
return '可审批'
return '可通过'
})
const decisionDescription = computed(() => {
const riskCount = currentRiskCards.value.length
const subject = props.isApplicationDocument ? '申请' : '报销'
if (riskCount) {
if (!props.isApplicationDocument && riskExplanationItems.value.length) {
return `当前报销已识别 ${riskCount} 个需核对风险点,用户已补充异常说明,审批人应核对说明与票据佐证是否充分`
return `当前${subject}识别 ${riskCount} 个需核对风险点,已补充说明但仍建议先核对票据与行程`
}
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}识别 ${riskCount} 个需核对风险点,审批人应优先查看高风险依据。`
return `当前${subject}识别 ${riskCount} 个需核对风险点,优先查看高风险依据。`
}
if (materialIssues.value.length || sceneIssues.value.length) {
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}存在材料或业务说明不完整,建议补齐后再继续处理。`
return `当前${subject}存在材料或业务说明不完整,建议补齐后再处理。`
}
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}未发现中高风险阻断项,可结合当前环节权限按流程处理。`
return `当前${subject}未发现中高风险阻断项,可按流程继续处理。`
})
const stageEvidenceItems = computed(() => (
props.isApplicationDocument ? buildApplicationEvidence() : buildReimbursementEvidence()
@@ -139,6 +153,38 @@ export default {
const sourceItems = abnormalItems.length ? abnormalItems : stageEvidenceItems.value
return sourceItems.map((item) => ({ ...item }))
})
const stageRiskFactSummary = computed(() => buildStageRiskFactSummary({
isApplicationDocument: props.isApplicationDocument,
riskCount: currentRiskCards.value.length,
highCount: highRiskCards.value.length,
mediumCount: mediumRiskCards.value.length,
materialIssueCount: materialIssues.value.length,
sceneIssueCount: sceneIssues.value.length
}))
const stageReviewBasisSummary = computed(() => buildStageReviewBasisSummary(
compactEvidenceItems.value,
props.isApplicationDocument
))
const reviewSummaryItems = computed(() => [
{
key: 'fact',
label: '风险概览',
tone: decisionTone.value,
value: stageRiskFactSummary.value
},
{
key: 'basis',
label: '重点依据',
tone: decisionTone.value,
value: stageReviewBasisSummary.value
},
{
key: 'action',
label: '审核建议',
tone: decisionTone.value,
value: decisionAction.value
}
])
function buildApplicationEvidence() {
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
@@ -217,28 +263,68 @@ export default {
decisionDescription,
decisionAction,
decisionTitle,
reviewSummaryItems,
stageBasisHint,
stageBasisTitle,
stageEvidenceItems,
stageReviewBasisSummary,
stageRiskFactSummary,
stageTitle
}
}
}
function buildStageRiskFactSummary({
isApplicationDocument,
riskCount = 0,
highCount = 0,
mediumCount = 0,
materialIssueCount = 0,
sceneIssueCount = 0
} = {}) {
const subject = isApplicationDocument ? '申请单' : '报销单'
if (riskCount > 0) {
return `${subject}识别 ${riskCount} 个需核对风险点,高风险 ${highCount} 个,中风险 ${mediumCount} 个。`
}
const issueCount = materialIssueCount + sceneIssueCount
if (issueCount > 0) {
return `${subject}暂无中高风险命中,但仍有 ${issueCount} 个材料或业务说明项需要补齐。`
}
return `${subject}未识别到中高风险阻断项。`
}
function buildStageReviewBasisSummary(evidenceItems = [], isApplicationDocument = false) {
const abnormalLabels = evidenceItems
.filter((item) => isAbnormalEvidence(item))
.map((item) => String(item?.label || '').trim())
.filter(Boolean)
if (abnormalLabels.length) {
return `重点核对${abnormalLabels.join('、')}`
}
return isApplicationDocument
? '重点看申请金额、预算触发和事由是否一致。'
: '重点看票据、金额、行程和附件是否一致。'
}
function resolveDecision(tone, isApplicationDocument) {
const subject = isApplicationDocument ? '申请' : '报销'
const map = {
normal: {
title: `当前${subject}未发现中高风险阻断项`,
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`
title: '建议通过',
action: isApplicationDocument
? '可按权限继续审核,系统会按预算结果决定是否进入下一步。'
: '可按权限继续审批,后续进入财务或付款流程。'
},
medium: {
title: `当前${subject}存在中风险,建议核对后处理`,
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。'
title: '建议补充后通过',
action: isApplicationDocument
? '建议补充预算占用、申请事由和金额依据后再通过。'
: '建议补充票据、金额或业务说明后再通过。'
},
high: {
title: `当前${subject}存在高风险,不建议直接通过`,
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。'
title: '不建议通过',
action: isApplicationDocument
? '建议退回补充申请依据,或要求预算管理者复核。'
: '建议退回补充票据、行程说明或超标原因。'
}
}
return map[tone] || map.normal

View File

@@ -278,6 +278,7 @@
<div
v-if="message.role === 'assistant' && !message.reviewPayload && !message.queryPayload && message.suggestedActions?.length"
class="message-suggested-actions"
:class="{ 'compact-guidance-actions': message.assistantVariant === 'compact_guidance' }"
>
<button
v-for="action in message.suggestedActions"