feat(dashboard): polish risk and digital employee boards

This commit is contained in:
caoxiaozhu
2026-06-03 09:41:32 +08:00
parent 15006a05a7
commit 0d6327a990
11 changed files with 716 additions and 100 deletions

View File

@@ -149,6 +149,7 @@ useEcharts(chartElement, chartOptions)
<style scoped>
.digital-employee-daily-work-chart {
width: 100%;
height: 250px;
height: 100%;
min-height: 280px;
}
</style>

View File

@@ -16,14 +16,16 @@
<i class="mdi mdi-alert-circle-outline"></i>
<span>{{ errorMessage }}</span>
</div>
<DigitalEmployeeDailyWorkChart v-else :rows="dailyRows" />
<div v-else class="digital-chart-fill digital-trend-fill">
<DigitalEmployeeDailyWorkChart :rows="dailyRows" />
</div>
</article>
<article class="panel dashboard-card digital-work-day-panel">
<div class="card-head">
<h3>每日工作摘要 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="digital-day-list">
<div class="digital-day-list digital-card-fill">
<div
v-for="row in dailyRows"
:key="row.date"
@@ -43,23 +45,26 @@
<div class="card-head">
<h3>技能类型分布 <i class="mdi mdi-information-outline"></i></h3>
</div>
<DonutChart
:items="categoryLegend"
:center-value="String(dashboard.totals.totalRuns || 0)"
center-label="工作次数"
/>
<div class="digital-chart-fill digital-donut-fill">
<DonutChart
:items="categoryLegend"
:center-value="String(dashboard.totals.totalRuns || 0)"
center-label="工作次数"
/>
</div>
</article>
<article class="panel dashboard-card digital-task-panel">
<div class="card-head">
<h3>工作模块排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
<BarChart
v-if="taskRanking.length"
:items="taskRanking"
value-prefix=""
value-suffix=""
/>
<div v-if="taskRanking.length" class="digital-chart-fill digital-bar-fill">
<BarChart
:items="taskRanking"
value-prefix=""
value-suffix=""
/>
</div>
<div v-else class="digital-dashboard-empty">
<i class="mdi mdi-clipboard-check-outline"></i>
<span>当前周期暂无工作记录</span>
@@ -70,7 +75,7 @@
<div class="card-head">
<h3>业务产出 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="digital-output-grid">
<div class="digital-output-grid digital-card-fill">
<div v-for="item in outputItems" :key="item.label" class="digital-output-item">
<span :style="{ color: item.color }"><i :class="item.icon"></i></span>
<div>
@@ -216,12 +221,17 @@ function formatRunMetrics(metrics = {}) {
.digital-employee-dashboard {
display: grid;
grid-template-columns: repeat(12, minmax(0, 1fr));
grid-auto-rows: minmax(300px, auto);
align-items: stretch;
gap: 18px;
min-width: 0;
}
.dashboard-card {
min-width: 0;
min-height: 300px;
display: flex;
flex-direction: column;
padding: 18px;
border: 1px solid #edf2f7;
background: #fff;
@@ -270,23 +280,72 @@ function formatRunMetrics(metrics = {}) {
font-weight: 800;
}
.digital-work-trend-panel,
.digital-recent-panel {
grid-column: span 8;
.digital-work-trend-panel {
grid-column: span 7;
min-height: 380px;
}
.digital-work-day-panel {
grid-column: span 4;
grid-column: span 5;
min-height: 380px;
}
.digital-category-panel,
.digital-task-panel,
.digital-output-panel {
grid-column: span 4;
min-height: 320px;
}
.digital-recent-panel {
grid-column: span 12;
min-height: 330px;
}
.digital-card-fill {
flex: 1;
min-height: 0;
}
.digital-chart-fill {
flex: 1;
min-height: 250px;
display: flex;
min-width: 0;
}
.digital-chart-fill > :deep(*) {
flex: 1;
min-width: 0;
}
.digital-trend-fill {
min-height: 290px;
}
.digital-donut-fill :deep(.donut-chart) {
min-height: 100%;
}
.digital-donut-fill :deep(.donut-body) {
flex: 1;
height: auto;
min-height: 168px;
}
.digital-bar-fill :deep(.bar-chart) {
min-height: 100%;
align-items: stretch;
}
.digital-bar-fill :deep(.chart-area) {
height: auto;
min-height: 240px;
}
.digital-dashboard-state,
.digital-dashboard-empty {
flex: 1;
min-height: 220px;
display: grid;
place-content: center;
@@ -314,7 +373,9 @@ function formatRunMetrics(metrics = {}) {
.digital-day-list {
display: grid;
align-content: start;
gap: 9px;
overflow: auto;
}
.digital-day-row {
@@ -372,12 +433,13 @@ function formatRunMetrics(metrics = {}) {
.digital-output-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-rows: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.digital-output-item {
min-width: 0;
min-height: 92px;
min-height: 0;
display: flex;
align-items: center;
gap: 12px;
@@ -420,7 +482,10 @@ function formatRunMetrics(metrics = {}) {
}
.digital-recent-table {
flex: 1;
min-height: 0;
display: grid;
align-content: start;
border: 1px solid #edf2f7;
border-radius: 4px;
overflow: hidden;

View File

@@ -1,6 +1,6 @@
<template>
<section class="risk-observation-dashboard" :class="{ 'is-loading': loading }">
<div v-if="loading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
<section class="risk-observation-dashboard" :class="{ 'is-loading': showBlockingLoading }">
<div v-if="showBlockingLoading" class="risk-dashboard-loading-overlay" role="status" aria-live="polite">
<i class="mdi mdi-loading mdi-spin"></i>
<span>{{ loadingLabel }}</span>
</div>
@@ -96,25 +96,36 @@
<div class="card-head">
<h3>异常排行 <i class="mdi mdi-information-outline"></i></h3>
</div>
<div class="risk-ranking-grid">
<section
v-for="group in rankingGroups"
:key="group.label"
class="risk-ranking-group"
>
<span class="risk-ranking-title">{{ group.label }}</span>
<ol v-if="group.rows.length" class="risk-ranking-list">
<li v-for="(row, index) in group.rows" :key="`${group.label}-${row.name}`">
<em>{{ index + 1 }}</em>
<div>
<strong>{{ row.name }}</strong>
<small>{{ row.amountLabel }}</small>
</div>
<span>{{ row.count }}</span>
</li>
</ol>
<p v-else class="risk-dashboard-inline-empty">暂无数据</p>
</section>
<div v-if="rankingChartItems.length" class="risk-ranking-visual">
<BarChart
:items="rankingChartItems"
value-prefix=""
value-suffix=""
:compact="false"
/>
<div class="risk-ranking-detail-grid">
<section
v-for="group in rankingDetailGroups"
:key="group.label"
class="risk-ranking-group"
>
<span class="risk-ranking-title">{{ group.label }}</span>
<ol class="risk-ranking-list">
<li v-for="(row, index) in group.rows" :key="`${group.label}-${row.name}`">
<em>{{ index + 1 }}</em>
<div>
<strong>{{ row.name }}</strong>
<small>{{ row.amountLabel }}</small>
</div>
<span>{{ row.count }}</span>
</li>
</ol>
</section>
</div>
</div>
<div v-else class="risk-dashboard-empty">
<i class="mdi mdi-chart-bar"></i>
<span>当前周期暂无异常排行</span>
</div>
</article>
@@ -147,7 +158,7 @@
{{ formatRiskLevel(item.riskLevel) }}
</span>
<span class="risk-recent-main">
<strong>{{ item.title || formatSignal(item.riskSignal) }}</strong>
<strong>{{ formatObservationTitle(item) }}</strong>
<small>{{ item.claimNo || item.claimId || '未关联单据' }}</small>
</span>
<span class="risk-recent-score">{{ item.riskScore }}</span>
@@ -169,6 +180,12 @@ import BarChart from '../charts/BarChart.vue'
import DonutChart from '../charts/DonutChart.vue'
import RiskDailyTrendChart from '../charts/RiskDailyTrendChart.vue'
import EnterpriseSelect from '../shared/EnterpriseSelect.vue'
import {
formatRiskDimensionLabel,
formatRiskLevelLabel,
formatRiskObservationTitle,
formatRiskSignalLabel
} from '../../utils/riskLabels.js'
const props = defineProps({
dashboard: { type: Object, required: true },
@@ -188,6 +205,7 @@ const router = useRouter()
const loadingLabel = computed(() => (
props.lastUpdatedAt ? '正在同步最新风险数据' : '正在加载风险看板数据'
))
const showBlockingLoading = computed(() => props.loading && !props.lastUpdatedAt)
const lastUpdatedLabel = computed(() => {
if (!props.lastUpdatedAt) {
return ''
@@ -218,6 +236,33 @@ const rankingGroups = computed(() => [
buildRankingGroup('规则', props.dashboard.topRules, 'rule'),
buildRankingGroup('费用类型', props.dashboard.topExpenseTypes, 'expense_type')
])
const rankingChartItems = computed(() => rankingGroups.value
.map((group, index) => {
const topRow = group.rows[0]
if (!topRow) {
return null
}
return {
name: group.label,
shortName: group.label,
value: topRow.count,
meta: `${topRow.name} · ${topRow.amountLabel}`,
color: [
'#ef4444',
'#f59e0b',
'var(--theme-primary)',
'#0f766e',
'#2563eb'
][index] || '#64748b'
}
})
.filter(Boolean))
const rankingDetailGroups = computed(() => rankingGroups.value
.filter((group) => group.rows.length)
.map((group) => ({
...group,
rows: group.rows.slice(0, 3)
})))
const effectItems = computed(() => {
const sourceDistribution = props.dashboard.sourceDistribution || {}
const total = Number(props.dashboard.totalObservations || 0)
@@ -282,46 +327,19 @@ function formatPercent(value) {
}
function formatRiskLevel(value) {
const labels = {
critical: '重大',
high: '高',
medium: '中',
low: '低'
}
return labels[String(value || '').trim()] || '未知'
return formatRiskLevelLabel(value)
}
function formatDimensionName(value, kind = '') {
const text = String(value || '').trim()
if (!text || text === 'unknown') {
const unknownLabels = {
department: '未归集部门',
expense_type: '未归集费用',
risk_type: '未知风险类型',
supplier: '未归集供应商',
grade: '未归集职级',
employee: '未归集员工',
rule: '未关联规则'
}
return unknownLabels[kind] || '未知'
}
if (kind === 'risk_type' || kind === 'expense_type' || kind === 'rule') {
return formatSignal(text)
}
return text.replace(/_/g, ' ')
return formatRiskDimensionLabel(value, kind)
}
function formatSignal(value) {
const text = String(value || '').trim()
const labels = {
duplicate_invoice: '重复发票',
split_billing: '拆分报销',
frequent_small_claims: '高频小额',
location_mismatch: '地点不一致',
amount_outlier: '金额异常',
preapproval_absent: '缺少事前申请'
}
return labels[text] || text.replace(/_/g, ' ') || '未知风险'
return formatRiskSignalLabel(value)
}
function formatObservationTitle(item) {
return formatRiskObservationTitle(item)
}
function openClaim(item) {
@@ -472,7 +490,7 @@ function openClaim(item) {
}
.risk-dimension-grid,
.risk-ranking-grid {
.risk-ranking-detail-grid {
display: grid;
gap: 10px;
}
@@ -481,7 +499,20 @@ function openClaim(item) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.risk-ranking-grid {
.risk-ranking-visual {
display: grid;
gap: 14px;
}
.risk-ranking-visual :deep(.bar-chart) {
min-height: 250px;
}
.risk-ranking-visual :deep(.chart-area) {
height: 250px;
}
.risk-ranking-detail-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
@@ -759,7 +790,7 @@ function openClaim(item) {
}
.risk-dimension-grid,
.risk-ranking-grid {
.risk-ranking-detail-grid {
grid-template-columns: minmax(0, 1fr);
}
}