feat: 扩展风险规则体系、审批动态路由与预算中心列表化改造

- 新增 25+ 条风险规则(预算/报销/申请/通用类),完善风险规则模拟与反馈发布机制
- 引入费用审批动态路由、平台风险分级、预审与风险阶段管理
- 预算中心列表化改造,优化票据夹仪表盘与数字员工工作看板
- 新增 Hermes 风险线索收集器、Agent 链路追踪中心
- 扩展数字员工能力库(18 个领域 Skill)与交通费用自动预估
- 完善报销申请快速预览、权限控制与前端测试覆盖
This commit is contained in:
caoxiaozhu
2026-06-01 17:07:14 +08:00
parent 7989f3a159
commit 92444e7eae
285 changed files with 25075 additions and 2986 deletions

View File

@@ -80,6 +80,64 @@
</div>
</section>
<section class="budget-report-editor-panel">
<div class="budget-report-section-head">
<strong>预算构成编辑</strong>
<span>{{ report.periodType || '预算' }} · 可直接调整</span>
</div>
<div class="budget-editor-table" role="table" aria-label="预算构成编辑表">
<div class="budget-editor-row head" role="row">
<span role="columnheader">费用类型</span>
<span role="columnheader">编制金额</span>
<span role="columnheader">提醒</span>
<span role="columnheader">告警</span>
<span role="columnheader">风险</span>
<span role="columnheader">预算说明</span>
</div>
<div
v-for="row in draftRows"
:key="row.key"
class="budget-editor-row"
role="row"
>
<strong role="cell">{{ row.name }}</strong>
<label role="cell">
<span>金额</span>
<input v-model.number="row.budgetAmount" type="number" min="0" step="1000" />
</label>
<label role="cell">
<span>提醒</span>
<input v-model.number="row.reminderThreshold" type="number" min="0" max="100" step="1" />
</label>
<label role="cell">
<span>告警</span>
<input v-model.number="row.alertThreshold" type="number" min="0" max="100" step="1" />
</label>
<label role="cell">
<span>风险</span>
<input v-model.number="row.riskThreshold" type="number" min="0" max="100" step="1" />
</label>
<textarea v-model="row.note" role="cell" rows="2" />
</div>
</div>
<footer class="budget-editor-footer">
<div>
<span>当前编制总额</span>
<strong>{{ editableTotalDisplay }}</strong>
<small>{{ draftStatusText }}</small>
</div>
<button type="button" class="budget-editor-secondary" @click="applyRecommendedBudget">
应用建议
</button>
<button type="button" class="budget-editor-primary" @click="generateBudgetDraft">
生成预算草案
</button>
</footer>
</section>
<section class="budget-report-action-panel">
<div>
<strong>编制建议</strong>
@@ -91,7 +149,7 @@
</template>
<script setup>
import { computed } from 'vue'
import { computed, reactive, ref, watch } from 'vue'
import DonutChart from '../charts/DonutChart.vue'
@@ -102,6 +160,52 @@ const props = defineProps({
}
})
const draftRows = reactive([])
const draftStatus = ref('editing')
const formatAmount = (value) =>
`¥${Number(value || 0).toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: 0
})}`
function resetDraftRows() {
draftRows.splice(
0,
draftRows.length,
...((props.report.editableDraft?.rows || props.report.items || []).map((item) => ({
key: item.key,
name: item.name,
budgetAmount: Number(item.budgetAmount ?? item.recommendedBudget ?? 0),
reminderThreshold: Number(item.reminderThreshold ?? 70),
alertThreshold: Number(item.alertThreshold ?? 80),
riskThreshold: Number(item.riskThreshold ?? 90),
note: String(item.note || item.suggestion || '')
})))
)
draftStatus.value = 'editing'
}
watch(() => props.report, resetDraftRows, { immediate: true })
const editableTotalDisplay = computed(() =>
formatAmount(draftRows.reduce((sum, item) => sum + Number(item.budgetAmount || 0), 0))
)
const draftStatusText = computed(() =>
draftStatus.value === 'generated'
? '已生成本轮预算草案,后续可提交高级财务审核'
: '调整后可生成预算草案'
)
function applyRecommendedBudget() {
resetDraftRows()
}
function generateBudgetDraft() {
draftStatus.value = 'generated'
}
const summaryCards = computed(() => [
{
label: '上季度预算',
@@ -142,6 +246,7 @@ const summaryCards = computed(() => [
.budget-report-head,
.budget-report-main,
.budget-report-detail-panel,
.budget-report-editor-panel,
.budget-report-action-panel,
.budget-report-summary-card {
border: 1px solid #dbe4ee;
@@ -280,6 +385,137 @@ const summaryCards = computed(() => [
padding: 14px;
}
.budget-report-editor-panel {
padding: 14px;
}
.budget-editor-table {
display: grid;
gap: 8px;
}
.budget-editor-row {
display: grid;
grid-template-columns: minmax(64px, .7fr) minmax(118px, .95fr) repeat(3, minmax(68px, .55fr)) minmax(220px, 1.6fr);
gap: 8px;
align-items: center;
min-width: 0;
}
.budget-editor-row.head {
min-height: 34px;
padding: 0 8px;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 850;
}
.budget-editor-row:not(.head) {
padding: 8px;
border: 1px solid #edf1f6;
border-radius: 4px;
background: #fbfdff;
}
.budget-editor-row > strong {
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.budget-editor-row label {
min-width: 0;
display: grid;
gap: 3px;
}
.budget-editor-row label span {
display: none;
}
.budget-editor-row input,
.budget-editor-row textarea {
width: 100%;
min-width: 0;
border: 1px solid #dbe4ee;
border-radius: 4px;
background: #fff;
color: #1f2937;
font-size: 12px;
font-weight: 700;
}
.budget-editor-row input {
height: 32px;
padding: 0 8px;
}
.budget-editor-row textarea {
min-height: 42px;
padding: 7px 8px;
resize: vertical;
line-height: 1.45;
}
.budget-editor-row input:focus,
.budget-editor-row textarea:focus {
border-color: rgba(58, 124, 165, .46);
outline: 3px solid var(--theme-focus-ring, rgba(58, 124, 165, .12));
}
.budget-editor-footer {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #edf1f6;
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.budget-editor-footer div {
margin-right: auto;
display: grid;
gap: 2px;
}
.budget-editor-footer span,
.budget-editor-footer small {
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.budget-editor-footer strong {
color: #0f172a;
font-size: 17px;
line-height: 1.2;
font-weight: 900;
}
.budget-editor-primary,
.budget-editor-secondary {
min-height: 32px;
border-radius: 4px;
padding: 0 12px;
font-size: 12px;
font-weight: 850;
}
.budget-editor-primary {
border: 0;
background: var(--theme-primary-active);
color: #fff;
}
.budget-editor-secondary {
border: 1px solid #d7e0ea;
background: #fff;
color: #334155;
}
.budget-report-expense-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
@@ -437,9 +673,30 @@ const summaryCards = computed(() => [
@media (max-width: 900px) {
.budget-report-summary-grid,
.budget-report-main,
.budget-report-expense-list {
.budget-report-expense-list,
.budget-editor-row {
grid-template-columns: 1fr;
}
.budget-editor-row.head {
display: none;
}
.budget-editor-row label span {
display: block;
color: #64748b;
font-size: 11px;
font-weight: 700;
}
.budget-editor-footer {
align-items: stretch;
flex-direction: column;
}
.budget-editor-footer div {
margin-right: 0;
}
}
@media (prefers-reduced-motion: reduce) {

View File

@@ -1,622 +0,0 @@
<template>
<article class="detail-card panel employee-risk-profile-card">
<div class="employee-risk-head">
<div>
<h3 class="detail-card-title-with-icon">
<i class="mdi mdi-account-search-outline"></i>
<span>风险审核画像</span>
</h3>
<p>{{ subtitle }}</p>
</div>
<span v-if="!loading && !error" :class="['profile-level-pill', levelTone]">
{{ levelLabel }}
</span>
</div>
<div v-if="loading" class="employee-risk-state">
<i class="mdi mdi-loading mdi-spin"></i>
<span>正在读取画像快照</span>
</div>
<div v-else-if="error" class="employee-risk-state error">
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ error }}</span>
</div>
<div v-else-if="emptyReason" class="employee-risk-state">
<i class="mdi mdi-database-search-outline"></i>
<span>{{ emptyReason }}</span>
</div>
<div v-else class="employee-risk-body">
<div class="employee-risk-summary">
<div>
<span>审核优先级</span>
<strong>{{ reviewScore }}</strong>
</div>
<div>
<span>计算窗口</span>
<strong>{{ profile?.window_days || 90 }} </strong>
</div>
<div>
<span>同组样本</span>
<strong>{{ profile?.peer_group?.sample_size || 0 }} </strong>
</div>
<div>
<span>更新时间</span>
<strong>{{ calculatedAtText }}</strong>
</div>
</div>
<div v-if="tags.length" class="employee-risk-tags">
<span>特征标签</span>
<div>
<span
v-for="tag in tags"
:key="tag.code"
:class="['employee-risk-tag', tagTone(tag)]"
:title="tag.reason"
>
{{ tag.display_label || tag.label }}
<strong>{{ tag.score }}</strong>
</span>
</div>
</div>
<div v-if="radarDimensions.length" class="employee-risk-radar">
<div class="employee-risk-radar-head">
<span>行为雷达</span>
<small>分数越高表示该行为特征越明显</small>
</div>
<div class="employee-risk-radar-layout">
<svg class="employee-risk-radar-chart" viewBox="0 0 104 104" aria-hidden="true">
<polygon
v-for="ring in radarRings"
:key="ring.scale"
class="employee-risk-radar-ring"
:points="ring.points"
/>
<line
v-for="axis in radarAxes"
:key="axis.key"
class="employee-risk-radar-axis"
x1="52"
y1="52"
:x2="axis.x"
:y2="axis.y"
/>
<polygon class="employee-risk-radar-area" :points="radarPolygonPoints" />
<circle
v-for="point in radarValuePoints"
:key="point.key"
class="employee-risk-radar-point"
:cx="point.x"
:cy="point.y"
r="2"
/>
</svg>
<ul class="employee-risk-radar-list">
<li v-for="item in radarDimensions" :key="item.code">
<span>{{ item.label }}</span>
<strong>{{ item.score }}</strong>
</li>
</ul>
</div>
</div>
<div class="employee-risk-profile-list">
<section v-for="item in profiles" :key="item.profile_type" class="employee-risk-profile">
<div class="employee-risk-profile-title">
<span>{{ item.profile_label }}</span>
<strong :class="profileLevelClass(item.level)">{{ item.score }}</strong>
</div>
<ul v-if="item.top_contributors?.length" class="employee-risk-evidence-list">
<li v-for="basis in item.top_contributors.slice(0, 3)" :key="basis.code">
<span>{{ basis.label }}</span>
<strong>{{ formatBasisValue(basis) }}</strong>
</li>
</ul>
<p v-else class="employee-risk-muted">暂无显著贡献项</p>
</section>
</div>
<div v-if="suggestions.length" class="employee-risk-suggestions">
<span>审核建议</span>
<ul>
<li v-for="item in suggestions" :key="item.type || item.message">
{{ item.message }}
<strong v-if="item.recommended_upper">建议上限 {{ item.recommended_upper }}{{ item.unit || '' }}</strong>
</li>
</ul>
</div>
</div>
</article>
</template>
<script>
import { computed } from 'vue'
export default {
name: 'EmployeeProfileRiskCard',
props: {
profile: {
type: Object,
default: null
},
loading: {
type: Boolean,
default: false
},
error: {
type: String,
default: ''
}
},
setup(props) {
const profiles = computed(() => Array.isArray(props.profile?.profiles) ? props.profile.profiles : [])
const tags = computed(() => Array.isArray(props.profile?.profile_tags) ? props.profile.profile_tags.slice(0, 6) : [])
const radarDimensions = computed(() => Array.isArray(props.profile?.radar?.dimensions) ? props.profile.radar.dimensions : [])
const suggestions = computed(() => Array.isArray(props.profile?.review_suggestions) ? props.profile.review_suggestions : [])
const emptyReason = computed(() => String(props.profile?.empty_reason || '').trim())
const reviewScore = computed(() => Number(props.profile?.review_priority_score || 0))
const level = computed(() => String(props.profile?.review_priority_level || 'normal').trim())
const levelLabel = computed(() => String(props.profile?.review_priority_label || '正常').trim())
const levelTone = computed(() => profileLevelClass(level.value))
const subtitle = computed(() => {
if (props.loading) {
return '读取员工近期费用和流程质量画像。'
}
if (props.error || emptyReason.value) {
return '当前画像不可用,审批时按单据事实继续核对。'
}
const windowDays = props.profile?.window_days || 90
const sampleSize = props.profile?.peer_group?.sample_size || 0
return `${windowDays} 天窗口,同组样本 ${sampleSize} 人,用于辅助复核费用节奏和材料质量。`
})
const calculatedAtText = computed(() => formatDateTime(props.profile?.calculated_at))
const radarRings = computed(() => [0.25, 0.5, 0.75, 1].map((scale) => ({
scale,
points: radarDimensions.value.map((_, index) => radarPoint(index, radarDimensions.value.length, scale)).join(' ')
})))
const radarAxes = computed(() => radarDimensions.value.map((item, index) => ({
key: item.code,
...radarPointObject(index, radarDimensions.value.length, 1)
})))
const radarValuePoints = computed(() => radarDimensions.value.map((item, index) => ({
key: item.code,
...radarPointObject(index, radarDimensions.value.length, Number(item.score || 0) / 100)
})))
const radarPolygonPoints = computed(() => radarValuePoints.value.map((point) => `${point.x},${point.y}`).join(' '))
function formatDateTime(value) {
const normalized = String(value || '').trim()
if (!normalized) {
return '暂无'
}
const date = new Date(normalized)
if (Number.isNaN(date.getTime())) {
return normalized.slice(0, 16)
}
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 profileLevelClass(value) {
const normalized = String(value || '').trim()
if (normalized === 'escalation') {
return 'high'
}
if (normalized === 'review') {
return 'medium'
}
if (normalized === 'watch') {
return 'watch'
}
return 'normal'
}
function radarPoint(index, total, scale) {
const point = radarPointObject(index, total, scale)
return `${point.x},${point.y}`
}
function radarPointObject(index, total, scale) {
if (!total) {
return { x: 52, y: 52 }
}
const angle = (-90 + (360 / total) * index) * (Math.PI / 180)
const radius = 42 * Math.max(0, Math.min(1, scale))
return {
x: Number((52 + Math.cos(angle) * radius).toFixed(2)),
y: Number((52 + Math.sin(angle) * radius).toFixed(2))
}
}
function tagTone(tag) {
const polarity = String(tag?.polarity || '').trim()
if (polarity === 'positive') {
return 'positive'
}
if (Number(tag?.score || 0) >= 80 || polarity === 'risk') {
return 'risk'
}
return 'behavior'
}
function formatBasisValue(basis) {
const value = basis?.value
const unit = String(basis?.unit || '').trim()
if (value == null || value === '') {
return basis?.score != null ? `${basis.score}` : ''
}
if (unit === '占比') {
const ratio = Number(value)
return Number.isFinite(ratio) ? `${Math.round(ratio * 100)}%` : String(value)
}
return `${value}${unit && unit !== '比例' ? unit : ''}`
}
return {
calculatedAtText,
emptyReason,
formatBasisValue,
levelLabel,
levelTone,
profileLevelClass,
profiles,
radarAxes,
radarDimensions,
radarPolygonPoints,
radarRings,
radarValuePoints,
reviewScore,
subtitle,
suggestions,
tags,
tagTone
}
}
}
</script>
<style scoped>
.employee-risk-profile-card {
display: grid;
gap: 14px;
}
.employee-risk-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.employee-risk-head p {
margin: 6px 0 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
}
.profile-level-pill {
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 0 9px;
border-radius: 999px;
font-size: 11px;
font-weight: 850;
white-space: nowrap;
}
.profile-level-pill.normal,
.employee-risk-profile-title strong.normal {
background: #ecfdf5;
color: #047857;
}
.profile-level-pill.watch,
.employee-risk-profile-title strong.watch {
background: #eff6ff;
color: #2563eb;
}
.profile-level-pill.medium,
.employee-risk-profile-title strong.medium {
background: #fff7ed;
color: #c2410c;
}
.profile-level-pill.high,
.employee-risk-profile-title strong.high {
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-state {
min-height: 78px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
color: #64748b;
font-size: 13px;
font-weight: 750;
}
.employee-risk-state.error {
border-color: #fecaca;
background: #fef2f2;
color: #b91c1c;
}
.employee-risk-body {
display: grid;
gap: 12px;
}
.employee-risk-summary {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.employee-risk-summary > div {
min-width: 0;
display: grid;
gap: 4px;
padding: 9px 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.employee-risk-summary span,
.employee-risk-suggestions > span {
color: #64748b;
font-size: 10px;
font-weight: 850;
}
.employee-risk-summary strong {
min-width: 0;
color: #0f172a;
font-size: 13px;
font-weight: 850;
overflow-wrap: anywhere;
}
.employee-risk-tags {
display: grid;
gap: 8px;
}
.employee-risk-tags > span,
.employee-risk-radar-head span {
color: #64748b;
font-size: 10px;
font-weight: 850;
}
.employee-risk-tags > div {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.employee-risk-tag {
min-height: 24px;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 0 8px;
border: 1px solid #e2e8f0;
border-radius: 999px;
background: #fff;
color: #334155;
font-size: 11px;
font-weight: 850;
}
.employee-risk-tag strong {
color: inherit;
font-size: 10px;
}
.employee-risk-tag.risk {
border-color: #fed7aa;
background: #fff7ed;
color: #c2410c;
}
.employee-risk-tag.behavior {
border-color: #bfdbfe;
background: #eff6ff;
color: #2563eb;
}
.employee-risk-tag.positive {
border-color: #bbf7d0;
background: #f0fdf4;
color: #15803d;
}
.employee-risk-radar {
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.employee-risk-radar-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.employee-risk-radar-head small {
color: #94a3b8;
font-size: 10px;
font-weight: 700;
}
.employee-risk-radar-layout {
display: grid;
grid-template-columns: 112px minmax(0, 1fr);
gap: 12px;
align-items: center;
}
.employee-risk-radar-chart {
width: 112px;
height: 112px;
}
.employee-risk-radar-ring {
fill: none;
stroke: #e2e8f0;
stroke-width: 0.75;
}
.employee-risk-radar-axis {
stroke: #e2e8f0;
stroke-width: 0.75;
}
.employee-risk-radar-area {
fill: rgba(37, 99, 235, 0.16);
stroke: #2563eb;
stroke-width: 1.8;
}
.employee-risk-radar-point {
fill: #2563eb;
}
.employee-risk-radar-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 6px 10px;
margin: 0;
padding: 0;
list-style: none;
}
.employee-risk-radar-list li {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #475569;
font-size: 11px;
font-weight: 750;
}
.employee-risk-radar-list strong {
color: #0f172a;
font-size: 12px;
font-weight: 900;
}
.employee-risk-profile-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.employee-risk-profile {
min-width: 0;
display: grid;
gap: 8px;
padding: 10px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
}
.employee-risk-profile-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #0f172a;
font-size: 13px;
font-weight: 850;
}
.employee-risk-profile-title strong {
min-width: 36px;
height: 22px;
display: inline-grid;
place-items: center;
border-radius: 999px;
font-size: 12px;
font-weight: 900;
}
.employee-risk-evidence-list,
.employee-risk-suggestions ul {
display: grid;
gap: 6px;
margin: 0;
padding: 0;
list-style: none;
}
.employee-risk-evidence-list li,
.employee-risk-suggestions li {
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: #475569;
font-size: 12px;
line-height: 1.45;
}
.employee-risk-evidence-list strong,
.employee-risk-suggestions strong {
color: #0f172a;
white-space: nowrap;
}
.employee-risk-muted {
margin: 0;
color: #94a3b8;
font-size: 12px;
}
.employee-risk-suggestions {
display: grid;
gap: 7px;
padding: 10px;
border-radius: 8px;
background: #f8fafc;
}
@media (max-width: 960px) {
.employee-risk-summary,
.employee-risk-profile-list {
grid-template-columns: 1fr;
}
.employee-risk-radar-layout {
grid-template-columns: 1fr;
}
.employee-risk-radar-chart {
justify-self: center;
}
}
</style>

View File

@@ -0,0 +1,722 @@
<template>
<article :class="['detail-card panel employee-risk-profile-card', `is-${decisionTone}`]">
<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>
<span>{{ stageTitle }}</span>
</h3>
<span :class="['employee-risk-tone-pill', decisionTone]">{{ decisionBadgeLabel }}</span>
</div>
</div>
<div class="employee-risk-body">
<section :class="['employee-risk-ai-note', decisionTone]">
<div class="employee-risk-ai-main">
<span>AI 审核建议</span>
<strong>{{ decisionTitle }}</strong>
<p>{{ decisionDescription }}</p>
</div>
<div class="employee-risk-action">
<span>建议动作</span>
<strong :class="decisionTone">{{ decisionAction }}</strong>
</div>
<div v-if="compactAdviceItems.length" class="employee-risk-advice-list">
<p v-for="item in compactAdviceItems" :key="item">{{ item }}</p>
</div>
</section>
<section class="employee-risk-profile-section">
<div class="employee-risk-section-head">
<span>{{ stageBasisTitle }}</span>
<small>{{ stageBasisHint }}</small>
</div>
<div class="employee-risk-profile-list">
<section
v-for="item in compactEvidenceItems"
:key="item.code"
:class="['employee-risk-profile', item.tone]"
>
<div class="employee-risk-profile-title">
<span>{{ item.label }}</span>
<strong :class="item.tone">{{ 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>
<p v-else class="employee-risk-muted">暂无显著贡献项</p>
</section>
</div>
</section>
</div>
</article>
</template>
<script>
import { computed } from 'vue'
export default {
name: 'StageRiskAdviceCard',
props: {
request: {
type: Object,
default: () => ({})
},
expenseItems: {
type: Array,
default: () => []
},
aiAdvice: {
type: Object,
default: () => ({})
},
isApplicationDocument: {
type: Boolean,
default: false
}
},
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))
.filter((card) => ['medium', 'high'].includes(normalizeTone(card?.tone)))
)
const highRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'high'))
const mediumRiskCards = computed(() => currentRiskCards.value.filter((card) => normalizeTone(card?.tone) === 'medium'))
const materialIssues = computed(() => props.isApplicationDocument ? [] : resolveReimbursementMaterialIssues(currentItems.value))
const sceneIssues = computed(() => resolveSceneIssues(requestModel.value, currentItems.value, props.isApplicationDocument))
const decisionTone = computed(() => {
if (highRiskCards.value.length) {
return 'high'
}
if (mediumRiskCards.value.length || materialIssues.value.length || sceneIssues.value.length) {
return 'medium'
}
return 'normal'
})
const stageTitle = 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 decisionBadgeLabel = computed(() => {
if (decisionTone.value === 'high') {
return '高风险'
}
if (decisionTone.value === 'medium') {
return '需关注'
}
return '可审批'
})
const decisionDescription = computed(() => {
const riskCount = currentRiskCards.value.length
if (riskCount) {
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}已识别 ${riskCount} 个需核对风险点,审批人应优先查看中高风险依据。`
}
if (materialIssues.value.length || sceneIssues.value.length) {
return `${props.isApplicationDocument ? '当前申请' : '当前报销'}存在材料或业务说明不完整,建议补齐后再继续处理。`
}
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)
}))
})
function buildApplicationEvidence() {
const budgetCards = currentRiskCards.value.filter((card) => /预算|余额|占用|超预算/.test(cardText(card)))
const amountCards = currentRiskCards.value.filter((card) => /金额|标准|阈值|超标/.test(cardText(card)))
return [
evidenceItem('apply_amount', '申请金额与科目', amountCards.length ? '需复核' : '正常', amountCards.length ? highestTone(amountCards) : 'normal', [
`申请科目:${displayValue(requestModel.value.typeLabel || requestModel.value.sceneLabel, '待确认')}`,
`申请金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
...riskTexts(amountCards)
]),
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', [
`申请事由:${displayValue(requestModel.value.reason, '待补充')}`,
`申请目的地/发生地点:${displayValue(requestModel.value.location || requestModel.value.sceneTarget, '待补充')}`,
`申请时间:${displayValue(requestModel.value.period || requestModel.value.occurredDisplay, '待补充')}`,
...sceneIssues.value.map((item) => `当前缺少:${item}`)
]),
evidenceItem('apply_risk', '申请规则命中', currentRiskCards.value.length ? '有风险' : '无异常', decisionTone.value, (
currentRiskCards.value.length ? riskTexts(currentRiskCards.value) : ['申请环节未命中中高风险规则。']
))
]
}
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 needAttachmentItems = currentItems.value.filter((item) => !item?.isSystemGenerated)
const uploadedCount = needAttachmentItems.filter((item) => String(item?.invoiceId || '').trim()).length
return [
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', [
`报销金额:${displayValue(requestModel.value.amountDisplay || requestModel.value.amount, '待确认')}`,
`费用明细:${currentItems.value.length} 条,明细合计 ${formatCurrency(totalItemAmount(currentItems.value))}`,
...riskTexts(amountCards)
]),
evidenceItem('reimburse_route', '行程/时间/地点', routeCards.length || sceneIssues.value.length ? '需核对' : '已匹配', routeCards.length ? highestTone(routeCards) : sceneIssues.value.length ? 'medium' : 'normal', [
`报销事由:${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) : ['报销环节未命中中高风险规则。']
))
]
}
function evidenceItem(code, label, status, tone, evidence) {
return {
code,
label,
status,
tone,
evidence: uniqueTexts(evidence).filter(Boolean).slice(0, 5)
}
}
return {
adviceItems,
compactAdviceItems,
compactEvidenceItems,
decisionBadgeLabel,
decisionTone,
decisionDescription,
decisionAction,
decisionTitle,
stageBasisHint,
stageBasisTitle,
stageEvidenceItems,
stageTitle
}
}
}
function resolveDecision(tone, isApplicationDocument) {
const subject = isApplicationDocument ? '申请' : '报销'
const map = {
normal: {
title: `当前${subject}未发现中高风险阻断项`,
action: `可按权限继续审批${isApplicationDocument ? ',系统会按预算结果决定是否跳过预算复核。' : ',后续进入财务或付款流程。'}`,
advice: [`按当前${subject}信息、预算/票据结果和审批权限继续处理。`, '如审批人掌握额外业务背景,可在审批意见中补充。']
},
medium: {
title: `当前${subject}存在中风险,建议核对后处理`,
action: isApplicationDocument ? '建议核对预算占用、申请事由和金额依据后再通过。' : '建议核对票据、金额和业务说明后再通过。',
advice: ['请优先核对橙色风险项对应的业务说明、金额和材料。', '信息补齐或说明充分后,再决定通过或退回。']
},
high: {
title: `当前${subject}存在高风险,不建议直接通过`,
action: isApplicationDocument ? '建议退回补充申请依据,或要求预算管理者复核。' : '建议退回补充票据、行程说明或超标原因。',
advice: ['请优先处理红色高风险项,核对命中规则和业务佐证。', '若属于真实业务例外,应要求申请人补充原因和证明材料。']
}
}
return map[tone] || map.normal
}
function isAbnormalEvidence(item) {
const tone = normalizeTone(item?.tone)
const status = String(item?.status || '').trim()
if (tone === 'medium' || tone === 'high') {
return true
}
return !['正常', '未命中', '已说明', '完整', '已匹配', '无异常'].includes(status)
}
function matchesCurrentStage(card, isApplicationDocument) {
const businessStage = resolveCardBusinessStage(card)
if (businessStage) {
return isApplicationDocument
? businessStage === 'expense_application'
: businessStage === 'reimbursement'
}
const text = cardText(card)
if (isApplicationDocument) {
return !/报销|附件|单据|发票|票据|OCR|识别|付款|支付/.test(text) || /申请|预算|额度|事前|预估|审批/.test(text)
}
return !/申请环节|事前申请|预算申请/.test(text)
}
function resolveCardBusinessStage(card = {}) {
const candidates = [
card.businessStage,
card.business_stage,
card.controlStage,
card.control_stage
]
for (const candidate of candidates) {
const stage = normalizeBusinessStage(candidate)
if (stage) {
return stage
}
}
return ''
}
function normalizeBusinessStage(value) {
const stage = String(value || '').trim().toLowerCase()
if ([
'expense_application',
'application',
'apply',
'pre_apply',
'pre_application',
'budget_application'
].includes(stage)) {
return 'expense_application'
}
if ([
'reimbursement',
'expense_reimbursement',
'claim',
'expense_claim',
'expense_report'
].includes(stage)) {
return 'reimbursement'
}
return ''
}
function resolveReimbursementMaterialIssues(items) {
return items
.filter((item) => !item?.isSystemGenerated && !String(item?.invoiceId || '').trim())
.map((item) => `未上传票据:${item.name || item.category || item.desc || '未命名明细'}`)
}
function resolveSceneIssues(request, items, isApplicationDocument) {
const missing = []
if (isMissing(request.reason)) {
missing.push(isApplicationDocument ? '申请事由' : '报销事由')
}
if (isMissing(request.location) || isMissing(request.sceneTarget)) {
missing.push(isApplicationDocument ? '申请地点/目的地' : '报销地点/目的地')
}
if (isMissing(request.period) || isMissing(request.occurredDisplay)) {
missing.push(isApplicationDocument ? '申请发生时间' : '报销发生时间')
}
if (!isApplicationDocument) {
const itemMissingReasonCount = items.filter((item) => isMissing(item?.itemReason || item?.desc)).length
if (itemMissingReasonCount) {
missing.push(`${itemMissingReasonCount} 条报销明细缺少事由`)
}
}
return missing
}
function normalizeTone(value) {
const tone = String(value || '').trim().toLowerCase()
if (tone === 'high') return 'high'
if (tone === 'medium') return 'medium'
return 'normal'
}
function highestTone(cards, fallback = 'normal') {
if (!cards.length) return fallback
if (cards.some((card) => normalizeTone(card?.tone) === 'high')) return 'high'
if (cards.some((card) => normalizeTone(card?.tone) === 'medium')) return 'medium'
return fallback
}
function riskTexts(cards) {
return cards
.map((card) => String(card?.risk || card?.summary || card?.title || '').trim())
.filter(Boolean)
.slice(0, 4)
}
function cardText(card) {
return [
card?.label,
card?.title,
card?.risk,
card?.summary,
card?.suggestion,
...(Array.isArray(card?.ruleBasis) ? card.ruleBasis : [])
]
.map((item) => String(item || '').trim())
.join(' ')
}
function displayValue(value, fallback) {
const text = String(value || '').trim()
return isMissing(text) ? fallback : text
}
function isMissing(value) {
const text = String(value || '').trim()
return !text || ['待补充', '暂无', '无', 'null', 'undefined'].includes(text)
}
function totalItemAmount(items) {
return items.reduce((sum, item) => sum + safeNumber(item?.itemAmount), 0)
}
function safeNumber(value) {
const amount = Number(value)
return Number.isFinite(amount) ? amount : 0
}
function formatCurrency(value) {
return `${safeNumber(value).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} 元`
}
function uniqueTexts(values) {
return [...new Set(values.map((item) => String(item || '').trim()).filter(Boolean))]
}
</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) minmax(220px, 38%);
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 {
display: flex;
align-items: flex-start;
gap: 8px;
min-width: 0;
padding: 8px 10px;
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #fff;
}
.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;
}
.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>