feat: 新增员工行为画像算法与费用风险标签体系
后端新增员工行为画像算法模块,支持标签规则引擎和评分计算, 完善员工模型、银行信息、序列化和导入逻辑,优化报销审批流 和工作流常量,增强 Hermes 同步和知识同步能力,前端新增费 用画像详情弹窗、雷达图和风险卡片组件,完善登录页和工作台 样式,优化文档中心和归档中心交互,补充单元测试。
This commit is contained in:
590
web/src/components/business/ExpenseProfileDetailModal.vue
Normal file
590
web/src/components/business/ExpenseProfileDetailModal.vue
Normal file
@@ -0,0 +1,590 @@
|
||||
<template>
|
||||
<ElDialog
|
||||
:model-value="visible"
|
||||
append-to-body
|
||||
align-center
|
||||
width="min(1040px, calc(100vw - 48px))"
|
||||
:show-close="false"
|
||||
:lock-scroll="true"
|
||||
:destroy-on-close="false"
|
||||
class="expense-profile-dialog"
|
||||
modal-class="expense-profile-dialog-overlay"
|
||||
body-class="expense-profile-dialog-body"
|
||||
transition="expense-profile-dialog-zoom"
|
||||
aria-labelledby="expense-profile-modal-title"
|
||||
@update:model-value="handleVisibleChange"
|
||||
>
|
||||
<template #header>
|
||||
<header class="profile-dialog-header">
|
||||
<div class="profile-dialog-title-block">
|
||||
<span class="profile-dialog-eyebrow">Expense Behavior Profile</span>
|
||||
<h2 id="expense-profile-modal-title">{{ userName }}的费用画像详情</h2>
|
||||
<p>基于近 30 天费用操作、AI 协作、提单效率和预审质量形成。</p>
|
||||
</div>
|
||||
|
||||
<ElButton
|
||||
class="profile-dialog-close"
|
||||
text
|
||||
aria-label="关闭费用画像详情"
|
||||
@click="emitClose"
|
||||
>
|
||||
<i class="mdi mdi-close"></i>
|
||||
</ElButton>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<section class="profile-dialog-content" aria-label="费用画像分析">
|
||||
<section class="profile-summary-grid" aria-label="画像核心指标">
|
||||
<article v-for="metric in metrics" :key="metric.key" class="profile-summary-item">
|
||||
<span>{{ metric.label }}</span>
|
||||
<strong>{{ metric.value }}<small>{{ metric.unit }}</small></strong>
|
||||
<em>{{ metric.hint }}</em>
|
||||
</article>
|
||||
</section>
|
||||
|
||||
<div class="profile-analysis-grid">
|
||||
<section class="profile-panel profile-tags-panel" aria-label="画像标签">
|
||||
<div class="profile-section-title">
|
||||
<div>
|
||||
<span>画像标签</span>
|
||||
<small>按分数和业务解释排序</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-tag-list">
|
||||
<article
|
||||
v-for="tag in tags"
|
||||
:key="tag.code"
|
||||
:class="['profile-tag-item', `profile-tag-item--${tag.tone}`]"
|
||||
>
|
||||
<div class="profile-tag-copy">
|
||||
<strong>{{ tag.displayLabel || tag.label }}</strong>
|
||||
<span>{{ tag.reason }}</span>
|
||||
</div>
|
||||
<ElTag
|
||||
class="profile-score-tag"
|
||||
:type="resolveProfileTagType(tag.tone)"
|
||||
effect="light"
|
||||
>
|
||||
{{ tag.score }}
|
||||
</ElTag>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="profile-panel profile-radar-panel" aria-label="行为雷达图">
|
||||
<div class="profile-section-title">
|
||||
<div>
|
||||
<span>行为雷达</span>
|
||||
<small>使用项目图表组件组织,分数越高特征越明显</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-radar-layout">
|
||||
<RadarChart
|
||||
class="profile-radar-chart"
|
||||
:items="radarDimensions"
|
||||
label="费用画像评分"
|
||||
/>
|
||||
|
||||
<ul class="profile-radar-list">
|
||||
<li v-for="dimension in radarDimensions" :key="dimension.code">
|
||||
<span>{{ dimension.label }}</span>
|
||||
<strong>{{ dimension.score }}</strong>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="profile-panel profile-operation-panel" aria-label="最近 5 次操作内容">
|
||||
<div class="profile-section-title">
|
||||
<div>
|
||||
<span>最近 5 次操作内容</span>
|
||||
<small>用于理解画像标签的近期行为依据</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="profile-operation-list">
|
||||
<article v-for="operation in operations" :key="operation.id" class="profile-operation-row">
|
||||
<time>{{ operation.time }}</time>
|
||||
<div class="profile-operation-copy">
|
||||
<strong>{{ operation.action }}</strong>
|
||||
<span>{{ operation.target }} · {{ operation.channel }}</span>
|
||||
</div>
|
||||
<ElTag
|
||||
class="profile-operation-status"
|
||||
:type="resolveOperationStatusType(operation.tone)"
|
||||
effect="light"
|
||||
>
|
||||
{{ operation.status }}
|
||||
</ElTag>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<template #footer>
|
||||
<footer class="profile-dialog-footer">
|
||||
<span>画像仅用于辅助分析,不作为自动审批或处罚依据。</span>
|
||||
<ElButton class="profile-dialog-explain" type="primary" @click="emitExplain">
|
||||
<i class="mdi mdi-robot-outline"></i>
|
||||
<span>让 AI 解读画像</span>
|
||||
</ElButton>
|
||||
</footer>
|
||||
</template>
|
||||
</ElDialog>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ElButton, ElDialog, ElTag } from 'element-plus'
|
||||
|
||||
import RadarChart from '../charts/RadarChart.vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: { type: Boolean, default: false },
|
||||
userName: { type: String, default: '同事' },
|
||||
metrics: { type: Array, default: () => [] },
|
||||
tags: { type: Array, default: () => [] },
|
||||
radarDimensions: { type: Array, default: () => [] },
|
||||
operations: { type: Array, default: () => [] }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'explain'])
|
||||
|
||||
function emitClose() {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
function emitExplain() {
|
||||
emit('explain')
|
||||
}
|
||||
|
||||
function handleVisibleChange(value) {
|
||||
if (!value) {
|
||||
emitClose()
|
||||
}
|
||||
}
|
||||
|
||||
function resolveProfileTagType(tone) {
|
||||
const normalized = String(tone || '').trim()
|
||||
|
||||
if (['warning', 'risk', 'amber'].includes(normalized)) {
|
||||
return 'warning'
|
||||
}
|
||||
if (['danger', 'high'].includes(normalized)) {
|
||||
return 'danger'
|
||||
}
|
||||
return 'primary'
|
||||
}
|
||||
|
||||
function resolveOperationStatusType(tone) {
|
||||
const normalized = String(tone || '').trim()
|
||||
|
||||
if (['success', 'positive', 'emerald'].includes(normalized)) {
|
||||
return 'success'
|
||||
}
|
||||
if (['warning', 'risk', 'amber'].includes(normalized)) {
|
||||
return 'warning'
|
||||
}
|
||||
if (['danger', 'high'].includes(normalized)) {
|
||||
return 'danger'
|
||||
}
|
||||
return 'info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:global(.expense-profile-dialog-overlay) {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(15, 23, 42, 0.34), rgba(15, 23, 42, 0.4)),
|
||||
rgba(15, 23, 42, 0.36);
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog.el-dialog) {
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(148, 163, 184, 0.34);
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.2);
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog .el-dialog__header),
|
||||
:global(.expense-profile-dialog .expense-profile-dialog-body),
|
||||
:global(.expense-profile-dialog .el-dialog__footer) {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-active),
|
||||
:global(.expense-profile-dialog-zoom-leave-active) {
|
||||
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1);
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
|
||||
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
|
||||
transform-origin: center center;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog) {
|
||||
animation: expenseProfileDialogIn 240ms cubic-bezier(0.2, 0, 0, 1) both;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
|
||||
animation: expenseProfileDialogOut 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||
}
|
||||
|
||||
:global(.expense-profile-dialog-zoom-enter-from),
|
||||
:global(.expense-profile-dialog-zoom-leave-to) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.profile-dialog-header,
|
||||
.profile-dialog-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 16px 18px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.profile-dialog-header {
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.profile-dialog-footer {
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.profile-dialog-title-block {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.profile-dialog-eyebrow,
|
||||
.profile-section-title small {
|
||||
color: #64748b;
|
||||
font-size: 10px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.profile-dialog-header h2 {
|
||||
margin: 3px 0 4px;
|
||||
color: #0f172a;
|
||||
font-size: 19px;
|
||||
line-height: 1.25;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-dialog-header p,
|
||||
.profile-dialog-footer span {
|
||||
margin: 0;
|
||||
color: #64748b;
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.profile-dialog-close {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
color: #334155;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.profile-dialog-close:hover {
|
||||
background: #eef4fb;
|
||||
color: var(--theme-primary-active);
|
||||
}
|
||||
|
||||
.profile-dialog-content {
|
||||
max-height: min(660px, calc(100vh - 190px));
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.profile-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-summary-item,
|
||||
.profile-panel,
|
||||
.profile-tag-item {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.profile-summary-item {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.profile-summary-item span,
|
||||
.profile-operation-copy span,
|
||||
.profile-operation-row time {
|
||||
color: #64748b;
|
||||
font-size: 11.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.profile-summary-item strong {
|
||||
color: #0f172a;
|
||||
font-size: 18px;
|
||||
line-height: 1.15;
|
||||
font-weight: 850;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.profile-summary-item small {
|
||||
margin-left: 2px;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.profile-summary-item em {
|
||||
overflow: hidden;
|
||||
color: #94a3b8;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 650;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-analysis-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(360px, 0.85fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.profile-panel {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.profile-section-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.profile-section-title > div {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.profile-section-title span {
|
||||
color: #0f172a;
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-tag-list,
|
||||
.profile-operation-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.profile-tag-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 10px;
|
||||
transition:
|
||||
border-color 180ms var(--ease),
|
||||
background-color 180ms var(--ease);
|
||||
}
|
||||
|
||||
.profile-tag-item:hover {
|
||||
border-color: rgba(var(--theme-primary-rgb, 58, 124, 165), 0.24);
|
||||
background: #fbfdff;
|
||||
}
|
||||
|
||||
.profile-tag-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.profile-tag-copy strong,
|
||||
.profile-operation-copy strong {
|
||||
overflow: hidden;
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-tag-copy span {
|
||||
overflow: hidden;
|
||||
color: #64748b;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.4;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.profile-score-tag,
|
||||
.profile-operation-status {
|
||||
border-radius: 4px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-radar-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 1fr) minmax(120px, 0.72fr);
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.profile-radar-chart {
|
||||
height: 260px;
|
||||
}
|
||||
|
||||
.profile-radar-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.profile-radar-list li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
color: #475569;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.profile-radar-list strong {
|
||||
color: #0f172a;
|
||||
font-size: 13px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.profile-operation-row {
|
||||
display: grid;
|
||||
grid-template-columns: 88px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid #e8eef5;
|
||||
}
|
||||
|
||||
.profile-operation-row:first-child {
|
||||
border-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.profile-operation-copy {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.profile-operation-status {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.profile-dialog-explain {
|
||||
min-height: 32px;
|
||||
border-radius: 4px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.profile-dialog-explain i {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
@keyframes expenseProfileDialogIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale3d(0.94, 0.94, 1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes expenseProfileDialogOut {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: scale3d(1, 1, 1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: scale3d(0.96, 0.96, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
:global(.expense-profile-dialog.el-dialog) {
|
||||
width: calc(100vw - 24px) !important;
|
||||
}
|
||||
|
||||
.profile-summary-grid,
|
||||
.profile-analysis-grid,
|
||||
.profile-radar-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.profile-dialog-content {
|
||||
max-height: calc(100vh - 170px);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 560px) {
|
||||
.profile-dialog-header,
|
||||
.profile-dialog-footer {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.profile-dialog-footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.profile-operation-row {
|
||||
grid-template-columns: 1fr;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.profile-operation-status {
|
||||
justify-self: start;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:global(.expense-profile-dialog-zoom-enter-active .expense-profile-dialog),
|
||||
:global(.expense-profile-dialog-zoom-leave-active .expense-profile-dialog) {
|
||||
animation-duration: 1ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -9,8 +9,7 @@
|
||||
|
||||
<article class="panel assistant-hero" :style="{ '--assistant-bg-image': `url(${homepageBackground})` }">
|
||||
<div class="assistant-copy">
|
||||
<h1>你的专属 <span>AI 财务助手</span></h1>
|
||||
<p>智能理解财务业务,提供数据洞察与方案建议,高效处理日常事务</p>
|
||||
<h1>嗨,{{ displayUserName }},我是您的 <span>AI 费用助手</span></h1>
|
||||
|
||||
<input
|
||||
ref="fileInputRef"
|
||||
@@ -92,10 +91,10 @@
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="capability-grid" aria-label="AI 财务助手能力">
|
||||
<div :class="['capability-grid', capabilityGridClass]" aria-label="AI 财务助手能力">
|
||||
<button
|
||||
v-for="item in assistantCapabilities"
|
||||
:key="item.title"
|
||||
v-for="item in visibleAssistantCapabilities"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
class="capability-card panel"
|
||||
:class="`capability-card--${item.tone}`"
|
||||
@@ -226,7 +225,9 @@
|
||||
<button
|
||||
type="button"
|
||||
class="detail-action"
|
||||
@click="openPromptAssistant('查看我的费用画像详情,并总结 AI 使用、提单效率和预审通过率。')"
|
||||
aria-haspopup="dialog"
|
||||
:aria-expanded="expenseProfileModalOpen"
|
||||
@click="openExpenseProfileModal"
|
||||
>
|
||||
<span>查看详情</span>
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
@@ -255,12 +256,24 @@
|
||||
</article>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<ExpenseProfileDetailModal
|
||||
:visible="expenseProfileModalOpen"
|
||||
:user-name="displayUserName"
|
||||
:metrics="expenseProfileModalMetrics"
|
||||
:tags="expenseProfileTags"
|
||||
:radar-dimensions="expenseProfileRadarDimensions"
|
||||
:operations="expenseProfileOperations"
|
||||
@close="closeExpenseProfileModal"
|
||||
@explain="explainExpenseProfile"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import PanelHead from '../shared/PanelHead.vue'
|
||||
import ExpenseProfileDetailModal from './ExpenseProfileDetailModal.vue'
|
||||
import WorkbenchListIcon from '../shared/WorkbenchListIcon.vue'
|
||||
import homepageBackground from '../../assets/homepage_backgraound.png'
|
||||
import { useSystemState } from '../../composables/useSystemState.js'
|
||||
@@ -268,6 +281,9 @@ import { useToast } from '../../composables/useToast.js'
|
||||
import {
|
||||
assistantCapabilities,
|
||||
buildExpenseStatItems,
|
||||
expenseProfileOperations,
|
||||
expenseProfileRadarDimensions,
|
||||
expenseProfileTags,
|
||||
progressItems,
|
||||
progressSteps,
|
||||
quickPromptItems,
|
||||
@@ -296,15 +312,45 @@ const selectedFiles = ref([])
|
||||
const pendingAction = ref('')
|
||||
const latestExpenseConversation = ref(null)
|
||||
const hasLocalExpenseSnapshot = ref(false)
|
||||
const expenseProfileModalOpen = ref(false)
|
||||
const MAX_ATTACHMENTS = 10
|
||||
const SESSION_TYPE_EXPENSE = 'expense'
|
||||
const SESSION_TYPE_KNOWLEDGE = 'knowledge'
|
||||
const FINANCIAL_CAPABILITY_KEYS = new Set(['budget-planning', 'finance-analysis'])
|
||||
const FINANCIAL_CAPABILITY_ROLE_CODES = new Set(['budget_monitor', 'executive', 'admin'])
|
||||
const FINANCIAL_CAPABILITY_ROLE_LABELS = new Set(['预算监控员', '高级财务人员', '管理员'])
|
||||
|
||||
const hasExpenseConversation = computed(() =>
|
||||
Boolean(latestExpenseConversation.value?.conversation_id || latestExpenseConversation.value?.conversationId)
|
||||
|| hasLocalExpenseSnapshot.value
|
||||
)
|
||||
const displayUserName = computed(() => {
|
||||
const user = currentUser.value || {}
|
||||
return String(user.name || user.username || '同事').trim() || '同事'
|
||||
})
|
||||
const expenseActionLabel = computed(() => (hasExpenseConversation.value ? '继续报销' : '新建报销'))
|
||||
const currentRoleCodes = computed(() => {
|
||||
const user = currentUser.value || {}
|
||||
const rawCodes = Array.isArray(user.roleCodes)
|
||||
? user.roleCodes
|
||||
: Array.isArray(user.role_codes)
|
||||
? user.role_codes
|
||||
: []
|
||||
return new Set(rawCodes.map((code) => String(code || '').trim().toLowerCase()).filter(Boolean))
|
||||
})
|
||||
const canViewFinancialCapabilities = computed(() => {
|
||||
const user = currentUser.value || {}
|
||||
const roleLabel = String(user.role || '').trim()
|
||||
return Boolean(user.isAdmin)
|
||||
|| FINANCIAL_CAPABILITY_ROLE_LABELS.has(roleLabel)
|
||||
|| Array.from(currentRoleCodes.value).some((code) => FINANCIAL_CAPABILITY_ROLE_CODES.has(code))
|
||||
})
|
||||
const visibleAssistantCapabilities = computed(() =>
|
||||
assistantCapabilities.filter((item) => canViewFinancialCapabilities.value || !FINANCIAL_CAPABILITY_KEYS.has(item.key))
|
||||
)
|
||||
const capabilityGridClass = computed(() =>
|
||||
canViewFinancialCapabilities.value ? 'capability-grid--privileged' : 'capability-grid--standard'
|
||||
)
|
||||
const expenseStatItems = computed(() => buildExpenseStatItems(props.workbenchSummary))
|
||||
const visibleExpenseStatItems = computed(() => {
|
||||
const preferredKeys = ['monthly-amount', 'monthly-count', 'in-review', 'pending-payment']
|
||||
@@ -318,6 +364,12 @@ const visibleUsageProfileMetrics = computed(() => {
|
||||
.map((key) => usageProfileMetrics.find((item) => item.key === key))
|
||||
.filter(Boolean)
|
||||
})
|
||||
const expenseProfileModalMetrics = computed(() => {
|
||||
const preferredKeys = ['stay-duration', 'ai-usage', 'auto-pass-rate', 'audit-duration']
|
||||
return preferredKeys
|
||||
.map((key) => usageProfileMetrics.find((item) => item.key === key))
|
||||
.filter(Boolean)
|
||||
})
|
||||
const visibleTodoItems = computed(() => todoItems.slice(0, 5))
|
||||
const visibleProgressItems = computed(() => progressItems.slice(0, 5))
|
||||
const todoAlertCount = computed(() => visibleTodoItems.value.length)
|
||||
@@ -417,6 +469,19 @@ function openPromptAssistant(prompt) {
|
||||
})
|
||||
}
|
||||
|
||||
function openExpenseProfileModal() {
|
||||
expenseProfileModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeExpenseProfileModal() {
|
||||
expenseProfileModalOpen.value = false
|
||||
}
|
||||
|
||||
function explainExpenseProfile() {
|
||||
closeExpenseProfileModal()
|
||||
openPromptAssistant('请根据我的费用画像标签、行为雷达和最近 5 次操作,解释我的费用使用特点和可以优化的地方。')
|
||||
}
|
||||
|
||||
function handleWorkbenchEnter(event) {
|
||||
if (event.isComposing) {
|
||||
return
|
||||
|
||||
154
web/src/components/charts/RadarChart.vue
Normal file
154
web/src/components/charts/RadarChart.vue
Normal 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>
|
||||
@@ -152,7 +152,7 @@ const {
|
||||
} = useDocumentCenterInbox()
|
||||
|
||||
const sidebarMeta = {
|
||||
overview: { label: '财务总览' },
|
||||
overview: { label: '分析看板' },
|
||||
workbench: { label: '个人工作台' },
|
||||
documents: { label: '单据中心' },
|
||||
budget: { label: '预算中心' },
|
||||
@@ -185,7 +185,7 @@ const displayUser = computed(() => ({
|
||||
avatar: props.currentUser?.avatar || '管'
|
||||
}))
|
||||
|
||||
const displayCompanyName = computed(() => props.companyName || 'X-Financial')
|
||||
const displayCompanyName = computed(() => props.companyName || '易财费控')
|
||||
const collapseTooltipContent = computed(() => (props.collapsed ? '展开侧边栏' : '折叠侧边栏'))
|
||||
const userTooltipContent = computed(() => [displayUser.value.name, displayUser.value.role].filter(Boolean).join(' · '))
|
||||
|
||||
|
||||
@@ -98,14 +98,20 @@
|
||||
</template>
|
||||
|
||||
<template v-else-if="isWorkbench">
|
||||
<div class="kpi-chips">
|
||||
<div v-for="kpi in workbenchKpis" :key="kpi.label" class="kpi-chip" :style="{ '--chip-color': kpi.color }">
|
||||
<span class="chip-value">
|
||||
{{ kpi.value }}<small v-if="kpi.unit">{{ kpi.unit }}</small>
|
||||
</span>
|
||||
<span class="chip-label">{{ kpi.label }}</span>
|
||||
<span class="chip-delta" :class="kpi.trend">{{ kpi.meta }}</span>
|
||||
</div>
|
||||
<div class="topbar-toolset" aria-label="工作台快捷工具">
|
||||
<button class="topbar-icon-btn notification-btn" type="button" aria-label="通知">
|
||||
<i class="mdi mdi-bell-outline"></i>
|
||||
<span v-if="topbarNotificationCount" class="notification-badge">{{ topbarNotificationCount }}</span>
|
||||
</button>
|
||||
|
||||
<button class="topbar-icon-btn" type="button" aria-label="帮助">
|
||||
<i class="mdi mdi-help-circle-outline"></i>
|
||||
</button>
|
||||
|
||||
<button class="company-switcher" type="button" aria-label="切换公司">
|
||||
<span>{{ displayCompanyName }}</span>
|
||||
<i class="mdi mdi-chevron-down"></i>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -206,9 +212,9 @@ const props = defineProps({
|
||||
type: Object,
|
||||
default: () => null
|
||||
},
|
||||
workbenchSummary: {
|
||||
type: Object,
|
||||
default: () => null
|
||||
companyName: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
detailMode: {
|
||||
type: Boolean,
|
||||
@@ -247,48 +253,11 @@ const isLogs = computed(() => props.activeView === 'logs' && !props.logDetailMod
|
||||
const isApproval = computed(() => props.activeView === 'approval')
|
||||
const isPolicies = computed(() => props.activeView === 'policies')
|
||||
const isEmployees = computed(() => props.activeView === 'employees')
|
||||
|
||||
const workbenchKpis = computed(() => {
|
||||
const summary = props.workbenchSummary ?? {}
|
||||
const monthlyCount = Number(summary.monthlyCount ?? 0)
|
||||
const returnCount = Number(summary.returnCount ?? 0)
|
||||
const highRiskCount = Number(summary.highRiskCount ?? 0)
|
||||
const monthlyAmountLabel = String(summary.monthlyAmountLabel || '¥0')
|
||||
|
||||
return [
|
||||
{
|
||||
label: '本月报销笔数',
|
||||
value: monthlyCount,
|
||||
unit: '笔',
|
||||
meta: '本月累计',
|
||||
trend: monthlyCount > 0 ? 'up' : 'down',
|
||||
color: 'var(--theme-primary)'
|
||||
},
|
||||
{
|
||||
label: '本月报销总金额',
|
||||
value: monthlyAmountLabel,
|
||||
unit: '',
|
||||
meta: '本月累计',
|
||||
trend: monthlyCount > 0 ? 'up' : 'down',
|
||||
color: '#3b82f6'
|
||||
},
|
||||
{
|
||||
label: '退单次数',
|
||||
value: returnCount,
|
||||
unit: '次',
|
||||
meta: '累计退回',
|
||||
trend: returnCount > 0 ? 'down' : 'up',
|
||||
color: '#f59e0b'
|
||||
},
|
||||
{
|
||||
label: '高危风险次数',
|
||||
value: highRiskCount,
|
||||
unit: '次',
|
||||
meta: highRiskCount > 0 ? '本月需关注' : '本月无高危',
|
||||
trend: highRiskCount > 0 ? 'down' : 'up',
|
||||
color: '#ef4444'
|
||||
}
|
||||
]
|
||||
const displayCompanyName = computed(() => String(props.companyName || '远光软件股份有限公司').trim() || '远光软件股份有限公司')
|
||||
const topbarNotificationCount = computed(() => {
|
||||
const summary = props.documentSummary ?? {}
|
||||
const count = Number(summary.toProcess ?? summary.toSubmit ?? 8)
|
||||
return Number.isFinite(count) && count > 0 ? Math.min(count, 99) : 0
|
||||
})
|
||||
|
||||
const requestKpis = computed(() => {
|
||||
|
||||
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