feat(dashboard): polish risk and digital employee boards
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user