Files
X-Financial/web/src/components/business/ExpenseStatsDetailModal.vue

688 lines
17 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(980px, calc(100vw - 48px))"
:show-close="false"
:lock-scroll="true"
class="expense-stats-detail-dialog"
modal-class="expense-stats-detail-overlay"
body-class="expense-stats-detail-body"
transition="expense-stats-detail-zoom"
aria-labelledby="expense-stats-detail-title"
@update:model-value="handleVisibleChange"
>
<template #header>
<header class="expense-stats-detail-header">
<div class="expense-stats-detail-title-block">
<span class="expense-stats-detail-eyebrow">Expense Operation Details</span>
<h2 id="expense-stats-detail-title">{{ userName }}的费用统计详情</h2>
<p>汇总历史报销分布单据处理时间和近期系统操作</p>
</div>
<ElButton
class="expense-stats-detail-close"
text
aria-label="关闭费用统计详情"
@click="emitClose"
>
<i class="mdi mdi-close"></i>
</ElButton>
</header>
</template>
<section class="expense-stats-detail-content" aria-label="费用统计详情">
<section class="expense-stats-summary-grid" aria-label="费用统计摘要">
<article v-for="metric in summaryMetrics" :key="metric.key" class="expense-stats-summary-item">
<span>{{ metric.label }}</span>
<strong>{{ metric.value }}<small>{{ metric.unit }}</small></strong>
<em>{{ metric.hint }}</em>
</article>
</section>
<div class="expense-stats-analysis-grid">
<section class="expense-stats-panel expense-stats-distribution-panel" aria-label="历史报销费用分布">
<div class="expense-stats-section-title">
<div>
<span>历史报销费用分布</span>
<small>按费用类型聚合金额和笔数</small>
</div>
</div>
<div v-if="distributionChartItems.length" class="expense-distribution-chart">
<div class="expense-distribution-chart-layout">
<DonutChart
class="expense-distribution-donut"
:items="distributionChartItems"
:center-value="distributionCenterValue"
center-label="费用总额"
:show-legend="false"
/>
<div class="expense-distribution-summary-list" aria-label="费用分布明细">
<article
v-for="(row, index) in distributionRows"
:key="`${row.label}-${index}`"
class="expense-distribution-summary-row"
>
<i :style="{ background: resolveDistributionColor(index) }"></i>
<div>
<strong>{{ row.label }}</strong>
<span>{{ row.count || 0 }} · {{ row.amountLabel || '¥0' }}</span>
</div>
<em>{{ row.percentLabel || '0%' }}</em>
</article>
</div>
</div>
</div>
<p v-else class="expense-stats-empty">暂无历史报销费用分布</p>
</section>
<section class="expense-stats-panel expense-stats-processing-panel" aria-label="单据处理时间">
<div class="expense-stats-section-title">
<div>
<span>单据处理时间</span>
<small>按最近更新的单据排序</small>
</div>
</div>
<div v-if="processingRows.length" class="expense-processing-list">
<article v-for="row in processingRows" :key="row.id" class="expense-processing-row">
<div class="expense-processing-main">
<strong>{{ row.requestId || row.id }}</strong>
<span>{{ row.title }}</span>
</div>
<div class="expense-processing-meta">
<span>{{ row.startedAt }} {{ row.updatedAt }}</span>
<em>{{ row.stepCount }} 个节点</em>
</div>
<ElTag class="expense-processing-status" :type="resolveTagType(row.statusTone)" effect="light">
{{ row.status }}
</ElTag>
<b>{{ row.durationLabel }}</b>
</article>
</div>
<p v-else class="expense-stats-empty">暂无可计算处理时间的单据</p>
</section>
</div>
<section class="expense-stats-panel expense-stats-operation-panel" aria-label="系统操作详情">
<div class="expense-stats-section-title">
<div>
<span>系统操作详情</span>
<small>待办提醒和费用进度的近期操作记录</small>
</div>
</div>
<div v-if="operationRows.length" class="expense-operation-list">
<article v-for="row in operationRows" :key="row.id" class="expense-operation-row">
<time>{{ row.time }}</time>
<div class="expense-operation-copy">
<strong>{{ row.action }}</strong>
<span>{{ row.detail }}</span>
</div>
<ElTag class="expense-operation-source" :type="resolveTagType(row.tone)" effect="light">
{{ row.source }}
</ElTag>
</article>
</div>
<p v-else class="expense-stats-empty">暂无费用系统操作记录</p>
</section>
</section>
<template #footer>
<footer class="expense-stats-detail-footer">
<span>统计口径来自当前工作台已加载的个人单据用于操作参考</span>
</footer>
</template>
</ElDialog>
</template>
<script setup>
import { computed } from 'vue'
import { ElButton } from 'element-plus/es/components/button/index.mjs'
import { ElDialog } from 'element-plus/es/components/dialog/index.mjs'
import { ElTag } from 'element-plus/es/components/tag/index.mjs'
import DonutChart from '../charts/DonutChart.vue'
const props = defineProps({
visible: { type: Boolean, default: false },
userName: { type: String, default: '同事' },
summary: { type: Object, default: () => ({}) },
detail: { type: Object, default: () => ({}) }
})
const emit = defineEmits(['close'])
const summaryMetrics = computed(() => [
{
key: 'total-amount',
label: '累计报销金额',
value: props.summary.totalAmountLabel || '¥0',
unit: '',
hint: `${props.summary.totalCount ?? 0} 笔历史单据`
},
{
key: 'monthly-amount',
label: '本月报销金额',
value: props.summary.monthlyAmountLabel || '¥0',
unit: '',
hint: `${props.summary.monthlyCount ?? 0} 笔本月单据`
},
{
key: 'in-review',
label: '审批中单据',
value: props.summary.inReviewCount ?? 0,
unit: '笔',
hint: '等待流程节点处理'
},
{
key: 'pending-payment',
label: '待付款单据',
value: props.summary.pendingPaymentCount ?? 0,
unit: '笔',
hint: '财务付款前状态'
}
])
const distributionRows = computed(() => Array.isArray(props.detail.distributionRows) ? props.detail.distributionRows : [])
const processingRows = computed(() => Array.isArray(props.detail.processingRows) ? props.detail.processingRows : [])
const operationRows = computed(() => Array.isArray(props.detail.operationRows) ? props.detail.operationRows : [])
const distributionChartColors = [
'var(--chart-blue)',
'var(--chart-amber)',
'var(--chart-purple)',
'var(--theme-primary)',
'var(--success)',
'var(--theme-primary-active)'
]
const distributionCenterValue = computed(() => props.summary.totalAmountLabel || '¥0')
const distributionChartItems = computed(() => distributionRows.value.map((row, index) => ({
name: row.label,
value: Number(row.amount || 0),
display: `${row.percentLabel || '0%'} / ${row.count || 0}`,
color: distributionChartColors[index % distributionChartColors.length]
})))
function resolveDistributionColor(index) {
return distributionChartColors[index % distributionChartColors.length]
}
function emitClose() {
emit('close')
}
function handleVisibleChange(value) {
if (!value) {
emitClose()
}
}
function resolveTagType(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-stats-detail-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-stats-detail-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-stats-detail-dialog .el-dialog__header),
:global(.expense-stats-detail-dialog .expense-stats-detail-body),
:global(.expense-stats-detail-dialog .el-dialog__footer) {
padding: 0;
margin: 0;
}
:global(.expense-stats-detail-zoom-enter-active),
:global(.expense-stats-detail-zoom-leave-active) {
transition: opacity 180ms cubic-bezier(0.2, 0, 0, 1);
}
:global(.expense-stats-detail-zoom-enter-active .expense-stats-detail-dialog) {
animation: expenseStatsDialogIn 240ms cubic-bezier(0.2, 0, 0, 1) both;
}
:global(.expense-stats-detail-zoom-leave-active .expense-stats-detail-dialog) {
animation: expenseStatsDialogOut 200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
:global(.expense-stats-detail-zoom-enter-from),
:global(.expense-stats-detail-zoom-leave-to) {
opacity: 0;
}
.expense-stats-detail-header,
.expense-stats-detail-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 18px;
background: #ffffff;
}
.expense-stats-detail-header {
border-bottom: 1px solid #e2e8f0;
}
.expense-stats-detail-footer {
justify-content: flex-start;
border-top: 1px solid #e2e8f0;
}
.expense-stats-detail-title-block {
min-width: 0;
}
.expense-stats-detail-eyebrow,
.expense-stats-section-title small {
color: #64748b;
font-size: 10px;
font-weight: 850;
letter-spacing: 0;
text-transform: uppercase;
}
.expense-stats-detail-header h2 {
margin: 3px 0 4px;
color: #0f172a;
font-size: 19px;
line-height: 1.25;
font-weight: 850;
}
.expense-stats-detail-header p,
.expense-stats-detail-footer span {
margin: 0;
color: #64748b;
font-size: 12px;
line-height: 1.5;
font-weight: 650;
}
.expense-stats-detail-close {
width: 32px;
height: 32px;
min-height: 32px;
padding: 0;
border-radius: 4px;
color: #334155;
font-size: 18px;
}
.expense-stats-detail-close:hover {
background: #eef4fb;
color: var(--theme-primary-active);
}
.expense-stats-detail-content {
max-height: min(660px, calc(100vh - 190px));
display: grid;
gap: 12px;
padding: 14px;
overflow: auto;
background: #f8fafc;
}
.expense-stats-summary-grid,
.expense-stats-analysis-grid {
display: grid;
gap: 10px;
}
.expense-stats-summary-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.expense-stats-analysis-grid {
grid-template-columns: minmax(0, 0.9fr) minmax(360px, 1.1fr);
}
.expense-stats-summary-item,
.expense-stats-panel {
border: 1px solid #e2e8f0;
border-radius: 4px;
background: #ffffff;
}
.expense-stats-summary-item {
min-width: 0;
display: grid;
gap: 4px;
padding: 10px 12px;
}
.expense-stats-summary-item span,
.expense-processing-meta,
.expense-operation-row time,
.expense-operation-copy span {
color: #64748b;
font-size: 11.5px;
font-weight: 650;
}
.expense-stats-summary-item strong {
color: #0f172a;
font-size: 18px;
line-height: 1.15;
font-weight: 850;
font-variant-numeric: tabular-nums;
}
.expense-stats-summary-item small {
margin-left: 2px;
color: #64748b;
font-size: 11px;
font-weight: 650;
}
.expense-stats-summary-item em {
overflow: hidden;
color: #94a3b8;
font-size: 11px;
font-style: normal;
font-weight: 650;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-stats-panel {
min-width: 0;
display: grid;
gap: 10px;
padding: 12px;
}
.expense-stats-distribution-panel,
.expense-stats-processing-panel {
min-height: 336px;
}
.expense-stats-section-title {
display: flex;
justify-content: space-between;
gap: 10px;
}
.expense-stats-section-title > div {
min-width: 0;
display: grid;
gap: 2px;
}
.expense-stats-section-title span {
color: #0f172a;
font-size: 14px;
font-weight: 850;
}
.expense-processing-list,
.expense-operation-list {
display: grid;
gap: 8px;
}
.expense-processing-row:first-child,
.expense-operation-row:first-child {
border-top: 0;
padding-top: 0;
}
.expense-processing-main,
.expense-operation-copy {
min-width: 0;
display: grid;
gap: 3px;
}
.expense-processing-main strong,
.expense-operation-copy strong {
overflow: hidden;
color: #0f172a;
font-size: 13px;
font-weight: 850;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-distribution-chart {
min-height: 286px;
display: grid;
align-items: stretch;
}
.expense-distribution-chart-layout {
display: grid;
grid-template-columns: minmax(170px, 0.86fr) minmax(0, 1.14fr);
align-items: center;
gap: 12px;
min-height: 286px;
}
.expense-distribution-donut {
min-height: 0;
}
.expense-distribution-donut :deep(.donut-body) {
height: 220px;
margin-top: 0;
}
.expense-distribution-summary-list {
min-width: 0;
display: grid;
align-content: center;
gap: 8px;
}
.expense-distribution-summary-row {
min-width: 0;
display: grid;
grid-template-columns: 10px minmax(0, 1fr) auto;
align-items: center;
gap: 8px;
padding: 8px 0;
border-top: 1px solid #e8eef5;
}
.expense-distribution-summary-row:first-child {
border-top: 0;
}
.expense-distribution-summary-row i {
width: 10px;
height: 10px;
border-radius: 2px;
}
.expense-distribution-summary-row div {
min-width: 0;
display: grid;
gap: 2px;
}
.expense-distribution-summary-row strong,
.expense-distribution-summary-row span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-distribution-summary-row strong {
color: #334155;
font-size: 12.5px;
font-weight: 850;
}
.expense-distribution-summary-row span {
color: #94a3b8;
font-size: 11px;
font-weight: 650;
}
.expense-distribution-summary-row em {
color: #0f172a;
font-size: 12px;
font-style: normal;
font-weight: 850;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.expense-processing-row {
display: grid;
grid-template-columns: minmax(124px, 0.8fr) minmax(164px, 1fr) auto 58px;
align-items: center;
gap: 10px;
padding: 9px 0;
border-top: 1px solid #e8eef5;
}
.expense-processing-main span,
.expense-processing-meta span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.expense-processing-meta {
min-width: 0;
display: grid;
gap: 3px;
}
.expense-processing-meta em {
color: #94a3b8;
font-size: 11px;
font-style: normal;
}
.expense-processing-status,
.expense-operation-source {
border-radius: 4px;
font-weight: 800;
}
.expense-processing-row b {
color: #0f172a;
font-size: 13px;
font-weight: 850;
text-align: right;
white-space: nowrap;
}
.expense-operation-row {
display: grid;
grid-template-columns: 126px minmax(0, 1fr) auto;
align-items: center;
gap: 10px;
padding: 9px 0;
border-top: 1px solid #e8eef5;
}
.expense-stats-empty {
margin: 0;
min-height: 180px;
display: flex;
align-items: center;
justify-content: center;
border: 1px dashed #cbd5e1;
border-radius: 4px;
background: #f8fafc;
color: #64748b;
font-size: 12px;
font-weight: 700;
text-align: center;
}
@keyframes expenseStatsDialogIn {
0% {
opacity: 0;
transform: scale3d(0.94, 0.94, 1);
}
100% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
}
@keyframes expenseStatsDialogOut {
0% {
opacity: 1;
transform: scale3d(1, 1, 1);
}
100% {
opacity: 0;
transform: scale3d(0.96, 0.96, 1);
}
}
@media (max-width: 860px) {
:global(.expense-stats-detail-dialog.el-dialog) {
width: calc(100vw - 24px) !important;
}
.expense-stats-summary-grid,
.expense-stats-analysis-grid {
grid-template-columns: 1fr;
}
.expense-stats-detail-content {
max-height: calc(100vh - 170px);
}
}
@media (max-width: 620px) {
.expense-distribution-chart-layout {
grid-template-columns: 1fr;
}
.expense-distribution-donut :deep(.donut-body) {
height: 190px;
}
.expense-processing-row,
.expense-operation-row {
grid-template-columns: 1fr;
align-items: start;
}
.expense-processing-row b {
text-align: left;
}
}
@media (prefers-reduced-motion: reduce) {
:global(.expense-stats-detail-zoom-enter-active .expense-stats-detail-dialog),
:global(.expense-stats-detail-zoom-leave-active .expense-stats-detail-dialog) {
animation-duration: 1ms !important;
}
}
</style>