feat: 新增员工行为画像算法与费用风险标签体系

后端新增员工行为画像算法模块,支持标签规则引擎和评分计算,
完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流
和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费
用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台
样式,优化文档中心和归档中心交互,补充单元测试。
This commit is contained in:
caoxiaozhu
2026-05-28 12:09:49 +08:00
parent 04cd6d0f81
commit 8a4a777be7
96 changed files with 9835 additions and 704 deletions

View File

@@ -0,0 +1,154 @@
<template>
<div class="radar-chart">
<Radar :data="chartData" :options="chartOptions" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { Radar } from 'vue-chartjs'
import {
Chart as ChartJS,
Filler,
Legend,
LineElement,
PointElement,
RadialLinearScale,
Tooltip
} from 'chart.js'
import { useThemeColors } from '../../composables/useThemeColors.js'
ChartJS.register(RadialLinearScale, PointElement, LineElement, Filler, Tooltip, Legend)
const props = defineProps({
items: { type: Array, required: true },
label: { type: String, default: '行为评分' },
max: { type: Number, default: 100 }
})
const themeColors = useThemeColors()
const normalizedItems = computed(() =>
props.items.map((item) => ({
code: String(item.code || item.label || '').trim(),
label: String(item.label || item.code || '').trim(),
score: clampScore(item.score)
}))
)
const chartData = computed(() => {
const primary = themeColors.value.chartPrimary
return {
labels: normalizedItems.value.map((item) => item.label),
datasets: [
{
label: props.label,
data: normalizedItems.value.map((item) => item.score),
borderColor: primary,
backgroundColor: toRgba(primary, 0.16),
pointBackgroundColor: '#ffffff',
pointBorderColor: primary,
pointBorderWidth: 2,
pointRadius: 3,
pointHoverRadius: 4,
borderWidth: 2,
fill: true,
tension: 0.18
}
]
}
})
const chartOptions = computed(() => ({
responsive: true,
maintainAspectRatio: false,
animation: {
duration: 760,
easing: 'easeOutQuart'
},
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: 'rgba(255, 255, 255, 0.98)',
titleColor: '#0f172a',
bodyColor: '#475569',
borderColor: 'rgba(148, 163, 184, 0.28)',
borderWidth: 1,
cornerRadius: 4,
padding: 10,
displayColors: false,
callbacks: {
label: (context) => `${context.dataset.label}: ${context.parsed.r}`
}
}
},
scales: {
r: {
min: 0,
max: props.max,
beginAtZero: true,
ticks: {
display: false,
stepSize: 25
},
grid: {
color: 'rgba(148, 163, 184, 0.22)',
circular: false
},
angleLines: {
color: 'rgba(148, 163, 184, 0.18)'
},
pointLabels: {
color: '#475569',
padding: 8,
font: {
size: 11,
weight: '700'
}
}
}
}
}))
function clampScore(value) {
const score = Number(value || 0)
if (!Number.isFinite(score)) {
return 0
}
return Math.max(0, Math.min(props.max, score))
}
function toRgba(color, alpha) {
const normalized = String(color || '').trim()
const hex = normalized.replace('#', '')
if (/^[\da-f]{3}$/i.test(hex)) {
const [r, g, b] = hex.split('').map((part) => parseInt(part + part, 16))
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
if (/^[\da-f]{6}$/i.test(hex)) {
const r = parseInt(hex.slice(0, 2), 16)
const g = parseInt(hex.slice(2, 4), 16)
const b = parseInt(hex.slice(4, 6), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
if (normalized.startsWith('rgb(')) {
return normalized.replace('rgb(', 'rgba(').replace(')', `, ${alpha})`)
}
return `rgba(58, 124, 165, ${alpha})`
}
</script>
<style scoped>
.radar-chart {
position: relative;
width: 100%;
min-width: 0;
height: 260px;
}
</style>