feat: 新增员工行为画像算法与费用风险标签体系
后端新增员工行为画像算法模块,支持标签规则引擎和评分计算, 完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流 和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费 用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台 样式,优化文档中心和归档中心交互,补充单元测试。
This commit is contained in:
622
web/src/components/travel/EmployeeProfileRiskCard.vue
Normal file
622
web/src/components/travel/EmployeeProfileRiskCard.vue
Normal file
@@ -0,0 +1,622 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user