Files
X-Financial/web/src/components/business/ExpenseProfileDetailModal.vue
2026-06-03 17:31:12 +08:00

799 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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
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">User Behavior Profile</span>
<h2 id="expense-profile-modal-title">{{ userName }}的用户画像详情</h2>
<p>基于真实费用操作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="用户画像分析">
<div v-if="profileStatusText" :class="['profile-dialog-alert', profileStatusTone]">
<i :class="loading ? 'mdi mdi-loading mdi-spin' : 'mdi mdi-information-outline'"></i>
<span>{{ profileStatusText }}</span>
</div>
<section v-if="metrics.length" 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>
<ExpenseProfileTagPager v-if="tags.length" :tags="tags" :visible="visible" />
<p v-else class="profile-panel-empty">暂无可展示的画像标签</p>
</section>
<section class="profile-panel profile-radar-panel" aria-label="行为雷达图">
<div class="profile-section-title profile-radar-title">
<div>
<span>行为雷达</span>
<small>{{ currentRadarView.description }}</small>
</div>
<ElSelect
v-model="selectedRadarView"
class="profile-radar-view-select"
size="small"
aria-label="切换行为雷达视角"
>
<ElOption
v-for="option in radarViewOptions"
:key="option.value"
:label="option.shortLabel"
:value="option.value"
/>
</ElSelect>
</div>
<div v-if="filteredRadarDimensions.length" class="profile-radar-layout">
<RadarChart
:key="radarRenderKey"
class="profile-radar-chart"
:items="filteredRadarDimensions"
:label="`${currentRadarView.shortLabel}评分`"
/>
</div>
<p v-else class="profile-panel-empty profile-radar-empty">暂无可展示的雷达维度</p>
<div :class="['profile-behavior-tags', { 'is-empty': !filteredBehaviorTags.length }]" :aria-hidden="!filteredBehaviorTags.length" aria-label="行为标签">
<span class="profile-behavior-tags-title">行为标签</span>
<div v-if="filteredBehaviorTags.length" class="profile-behavior-tag-list">
<span
v-for="tag in filteredBehaviorTags"
:key="`behavior-${tag.code}`"
:class="[
'profile-behavior-tag',
`profile-behavior-tag--${tag.tone}`,
`profile-behavior-tag--accent-${tag.colorIndex || 0}`
]"
>
{{ tag.label || tag.displayLabel }}
</span>
</div>
</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 v-if="operations.length" 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>
<p v-else class="profile-panel-empty">暂无最近操作记录</p>
</section>
</section>
<template #footer>
<footer class="profile-dialog-footer">
<span>画像仅用于辅助分析不作为自动审批或处罚依据</span>
</footer>
</template>
</ElDialog>
</template>
<script setup>
import { computed, nextTick, ref, watch } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import { ElOption, ElSelect } from 'element-plus/es/components/select/index.mjs'
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
import ExpenseProfileTagPager from './ExpenseProfileTagPager.vue'
import RadarChart from '../charts/RadarChart.vue'
import { USER_PROFILE_RADAR_VIEW_OPTIONS, filterUserProfileRadarDimensions, filterUserProfileTagsByRadarView } from '../../utils/employeeProfileViewModel.js'
const props = defineProps({
visible: { type: Boolean, default: false },
userName: { type: String, default: '同事' },
metrics: { type: Array, default: () => [] },
tags: { type: Array, default: () => [] },
radarDimensions: { type: Array, default: () => [] },
radarDefaultView: { type: String, default: 'financial_risk' },
operations: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
errorMessage: { type: String, default: '' },
emptyReason: { type: String, default: '' }
})
const emit = defineEmits(['close'])
const radarRenderKey = ref(0)
const selectedRadarView = ref(props.radarDefaultView)
const radarViewOptions = USER_PROFILE_RADAR_VIEW_OPTIONS
const currentRadarView = computed(() => radarViewOptions.find((option) => option.value === selectedRadarView.value) || radarViewOptions[0])
const filteredRadarDimensions = computed(() => filterUserProfileRadarDimensions(props.radarDimensions, selectedRadarView.value))
const filteredBehaviorTags = computed(() => filterUserProfileTagsByRadarView(props.tags, selectedRadarView.value))
function emitClose() {
emit('close')
}
function handleVisibleChange(value) {
if (!value) {
emitClose()
}
}
const profileStatusText = computed(() => {
if (props.loading) {
return '正在读取真实用户画像数据...'
}
if (props.errorMessage) {
return props.errorMessage
}
if (props.emptyReason) {
return props.emptyReason
}
return ''
})
const profileStatusTone = computed(() => {
if (props.errorMessage) {
return 'is-error'
}
if (props.emptyReason) {
return 'is-empty'
}
return 'is-loading'
})
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'
}
function scheduleRadarFrame(callback) {
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
window.requestAnimationFrame(callback)
return
}
callback()
}
watch(
() => props.visible,
async (visible) => {
if (!visible) {
return
}
await nextTick()
scheduleRadarFrame(() => {
radarRenderKey.value += 1
})
}
)
watch(
() => props.radarDefaultView,
(value) => {
selectedRadarView.value = value || 'financial_risk'
},
{ immediate: true }
)
watch(
[filteredRadarDimensions, selectedRadarView],
async () => {
if (!props.visible) {
return
}
await nextTick()
scheduleRadarFrame(() => {
radarRenderKey.value += 1
})
}
)
</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 {
justify-content: flex-start;
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-dialog-alert {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 11px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 4px;
background: #ffffff;
color: #475569;
font-size: 12px;
font-weight: 750;
}
.profile-dialog-alert.is-error {
border-color: rgba(220, 38, 38, 0.24);
background: #fff7f7;
color: #b91c1c;
}
.profile-dialog-alert.is-empty {
border-color: rgba(245, 158, 11, 0.28);
background: #fffaf0;
color: #92400e;
}
.profile-summary-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.profile-summary-item,
.profile-panel {
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-tags-panel {
grid-template-rows: auto minmax(0, 1fr);
align-content: stretch;
min-height: 352px;
}
.profile-radar-panel {
grid-template-rows: auto minmax(0, 1fr) auto;
align-content: stretch;
min-height: 352px;
}
.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-radar-title { align-items: flex-start; }
.profile-radar-view-select {
width: 118px;
flex: 0 0 118px;
}
.profile-radar-view-select :deep(.el-select__wrapper) {
min-height: 28px;
border-radius: 4px;
box-shadow: 0 0 0 1px #cbd5e1 inset;
color: #334155;
font-size: 12px;
font-weight: 750;
}
.profile-operation-list {
display: grid;
gap: 8px;
}
.profile-panel-empty {
margin: 0;
padding: 18px 12px;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
justify-self: stretch;
box-sizing: border-box;
min-height: 100%;
border: 1px dashed #cbd5e1;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
line-height: 1.5;
font-weight: 700;
text-align: center;
}
.profile-tags-panel > .profile-panel-empty {
min-height: 284px;
}
.profile-radar-empty {
min-height: 308px;
}
.profile-operation-copy strong {
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.profile-operation-status {
border-radius: 4px;
font-weight: 800;
}
.profile-radar-layout {
display: grid;
grid-template-columns: minmax(0, 1fr);
align-items: center;
justify-items: stretch;
min-height: 360px;
animation: profileRadarEnter 360ms cubic-bezier(0.2, 0, 0, 1) both;
}
.profile-radar-chart {
width: 100%;
height: 360px;
}
.profile-behavior-tags {
display: grid;
gap: 8px;
padding-top: 10px;
min-height: 59px;
border-top: 1px solid #e8eef5;
}
.profile-behavior-tags.is-empty { visibility: hidden; }
.profile-behavior-tags-title {
color: #0f172a;
font-size: 12px;
font-weight: 850;
}
.profile-behavior-tag-list {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
.profile-behavior-tag {
--behavior-tag-rgb: 58, 124, 165;
--behavior-tag-text: #235d7e;
max-width: 132px;
overflow: hidden;
padding: 4px 9px;
border: 1px solid rgba(var(--behavior-tag-rgb), 0.24);
border-radius: 999px;
background: rgba(var(--behavior-tag-rgb), 0.08);
color: var(--behavior-tag-text);
font-size: 11.5px;
line-height: 1.25;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
animation: profileBehaviorTagIn 260ms cubic-bezier(0.2, 0, 0, 1) both;
}
.profile-behavior-tag--risk {
--behavior-tag-rgb: 245, 158, 11;
--behavior-tag-text: #92400e;
}
.profile-behavior-tag--positive {
--behavior-tag-rgb: 16, 185, 129;
--behavior-tag-text: #047857;
}
.profile-behavior-tag--accent-0 {
--behavior-tag-rgb: 58, 124, 165;
--behavior-tag-text: #235d7e;
}
.profile-behavior-tag--accent-1 {
--behavior-tag-rgb: 15, 159, 143;
--behavior-tag-text: #0f766e;
}
.profile-behavior-tag--accent-2 {
--behavior-tag-rgb: 245, 158, 11;
--behavior-tag-text: #92400e;
}
.profile-behavior-tag--accent-3 {
--behavior-tag-rgb: 124, 58, 237;
--behavior-tag-text: #5b21b6;
}
.profile-behavior-tag--accent-4 {
--behavior-tag-rgb: 220, 38, 38;
--behavior-tag-text: #991b1b;
}
.profile-behavior-tag--accent-5 {
--behavior-tag-rgb: 37, 99, 235;
--behavior-tag-text: #1d4ed8;
}
.profile-behavior-tag--accent-6 {
--behavior-tag-rgb: 22, 163, 74;
--behavior-tag-text: #15803d;
}
.profile-behavior-tag--accent-7 {
--behavior-tag-rgb: 219, 39, 119;
--behavior-tag-text: #be185d;
}
.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;
}
@keyframes expenseProfileDialogIn {
0% {
opacity: 0;
transform: scale3d(0.94, 0.94, 1);
}
100% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
@keyframes profileRadarEnter {
0% {
opacity: 0;
transform: translateY(8px) scale(0.985);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes profileBehaviorTagIn {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@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),
.profile-radar-layout,
.profile-behavior-tag {
animation-duration: 1ms !important;
}
}
</style>