<script lang="ts" setup>
|
import type { EChartsOption } from 'echarts';
|
|
import type { TrainStatsDetailRow, TrainStatsQuery } from '#/api/x/tms/trainStats';
|
|
import { computed, ref } from 'vue';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { BasicModal, useModalInner } from '@jnpf/ui/modal';
|
import {
|
Descriptions as ADescriptions,
|
DescriptionsItem as ADescriptionsItem,
|
} from 'ant-design-vue';
|
import dayjs from 'dayjs';
|
|
import { getTrainStatsDetail } from '#/api/x/tms/trainStats';
|
import { TMS_BTN } from '#/views/x/tms/shared/ui';
|
import {
|
labelOfCategory,
|
labelOfEvalMode,
|
loadRecordDics,
|
} from '#/views/x/tms/record/constants';
|
import HomeChart from '#/views/x/tms/home/HomeChart.vue';
|
|
const DT_FMT = 'YYYY-MM-DD HH:mm:ss';
|
|
defineOptions({ name: 'TmsTrainStatsDetailModal' });
|
|
defineEmits(['register']);
|
|
const { createMessage } = useMessage();
|
const loading = ref(false);
|
const rows = ref<TrainStatsDetailRow[]>([]);
|
const modalTitle = ref('统计明细');
|
/** 字典加载完成后再渲染表格标签,避免偶发回显编码 */
|
const dicReady = ref(0);
|
|
const columns = [
|
{ title: '编号', dataIndex: 'recordNo', key: 'recordNo', width: 140 },
|
{ title: '培训主题', dataIndex: 'subject', key: 'subject', ellipsis: true },
|
{ title: '培训分类', dataIndex: 'category', key: 'category', width: 120 },
|
{ title: '考核方式', dataIndex: 'evalMode', key: 'evalMode', width: 110 },
|
{ title: '开始时间', dataIndex: 'startTime', key: 'startTime', width: 170 },
|
{ title: '应到', dataIndex: 'personCount', key: 'personCount', width: 70, align: 'center' as const },
|
{ title: '签到', dataIndex: 'signedCount', key: 'signedCount', width: 70, align: 'center' as const },
|
{ title: '合格', dataIndex: 'passedCount', key: 'passedCount', width: 70, align: 'center' as const },
|
{ title: '签到率', dataIndex: 'signRate', key: 'signRate', width: 80, align: 'center' as const },
|
{ title: '合格率', dataIndex: 'passRate', key: 'passRate', width: 80, align: 'center' as const },
|
];
|
|
const summary = computed(() => {
|
const list = rows.value;
|
let person = 0;
|
let signed = 0;
|
let passed = 0;
|
for (const r of list) {
|
person += Number(r.personCount) || 0;
|
signed += Number(r.signedCount) || 0;
|
passed += Number(r.passedCount) || 0;
|
}
|
return {
|
sessionCount: list.length,
|
personCount: person,
|
signedCount: signed,
|
passedCount: passed,
|
unsignedCount: Math.max(person - signed, 0),
|
signRate: rateFloor(signed, person),
|
passRate: rateFloor(passed, person),
|
};
|
});
|
|
const showCharts = computed(() => !loading.value && rows.value.length > 0);
|
|
const axisLabelStyle = {
|
interval: 0,
|
fontSize: 11,
|
lineHeight: 14,
|
width: 72,
|
overflow: 'break' as const,
|
};
|
|
/** 各场次人次柱状图 */
|
const barOption = computed<EChartsOption>(() => {
|
const list = rows.value;
|
const labels = list.map((r, i) => nameLabel(r, i));
|
return {
|
color: ['#1677ff', '#52c41a', '#faad14'],
|
tooltip: {
|
trigger: 'axis',
|
axisPointer: { type: 'shadow' },
|
formatter: (params: any) => formatAxisTooltip(params, list, false),
|
},
|
legend: { data: ['应到', '签到', '合格'], top: 0 },
|
grid: { left: 24, right: 36, top: 40, bottom: 16, containLabel: true },
|
xAxis: {
|
type: 'category',
|
data: labels,
|
axisLabel: axisLabelStyle,
|
},
|
yAxis: {
|
type: 'value',
|
name: '人次',
|
minInterval: 1,
|
splitLine: { lineStyle: { type: 'dashed' } },
|
},
|
series: [
|
{ name: '应到', type: 'bar', barMaxWidth: 22, data: list.map((r) => Number(r.personCount) || 0) },
|
{ name: '签到', type: 'bar', barMaxWidth: 22, data: list.map((r) => Number(r.signedCount) || 0) },
|
{ name: '合格', type: 'bar', barMaxWidth: 22, data: list.map((r) => Number(r.passedCount) || 0) },
|
],
|
};
|
});
|
|
/** 各场次签到率 / 合格率折线图 */
|
const lineOption = computed<EChartsOption>(() => {
|
const list = rows.value;
|
const labels = list.map((r, i) => nameLabel(r, i));
|
return {
|
color: ['#1677ff', '#eb2f96'],
|
tooltip: {
|
trigger: 'axis',
|
formatter: (params: any) => formatAxisTooltip(params, list, true),
|
},
|
legend: { data: ['签到率', '合格率'], top: 0 },
|
// 右侧加宽,避免末点与换行类目贴边被裁切
|
grid: { left: 24, right: 48, top: 40, bottom: 16, containLabel: true },
|
xAxis: {
|
type: 'category',
|
data: labels,
|
// true:首尾类目留白,最右标签/折点不被盖住
|
boundaryGap: true,
|
axisLabel: {
|
...axisLabelStyle,
|
align: 'center',
|
margin: 10,
|
},
|
},
|
yAxis: {
|
type: 'value',
|
name: '比率',
|
min: 0,
|
max: 100,
|
axisLabel: { formatter: '{value}%' },
|
splitLine: { lineStyle: { type: 'dashed' } },
|
},
|
series: [
|
{
|
name: '签到率',
|
type: 'line',
|
smooth: true,
|
symbolSize: 7,
|
data: list.map((r) => Number(r.signRate) || 0),
|
},
|
{
|
name: '合格率',
|
type: 'line',
|
smooth: true,
|
symbolSize: 7,
|
data: list.map((r) => Number(r.passRate) || 0),
|
},
|
],
|
};
|
});
|
|
const [registerModal, { closeModal, changeLoading, setModalProps }] = useModalInner(
|
async (data?: { title?: string; query?: TrainStatsQuery | null }) => {
|
modalTitle.value = data?.title || '统计明细';
|
setModalProps({ title: modalTitle.value });
|
await loadDetail(data?.query);
|
},
|
);
|
|
async function loadDetail(query?: TrainStatsQuery | null) {
|
rows.value = [];
|
if (!query?.groupBy || query.dimCode == null || query.dimCode === '') {
|
createMessage.warning('缺少明细查询条件');
|
return;
|
}
|
loading.value = true;
|
changeLoading(true);
|
try {
|
await loadRecordDics();
|
dicReady.value += 1;
|
rows.value = (await getTrainStatsDetail(query)) || [];
|
} catch (e: any) {
|
rows.value = [];
|
createMessage.error(e?.message || '加载明细失败');
|
} finally {
|
loading.value = false;
|
changeLoading(false);
|
}
|
}
|
|
/** 横轴培训主题:自动换行,完整名称在 tooltip */
|
function nameLabel(row: TrainStatsDetailRow, index: number) {
|
const name = String(row.subject || row.recordNo || `场次${index + 1}`).trim();
|
return wrapAxisName(name, 5, 3);
|
}
|
|
function wrapAxisName(text: string, width: number, maxLines: number) {
|
const s = text.trim();
|
if (s.length <= width) return s;
|
const lines: string[] = [];
|
for (let i = 0; i < s.length && lines.length < maxLines; i += width) {
|
lines.push(s.slice(i, i + width));
|
}
|
if (s.length > width * maxLines) {
|
const last = lines[lines.length - 1] || '';
|
lines[lines.length - 1] = `${last.slice(0, Math.max(width - 1, 1))}…`;
|
}
|
return lines.join('\n');
|
}
|
|
function formatAxisTooltip(params: any, list: TrainStatsDetailRow[], isRate: boolean) {
|
const arr = Array.isArray(params) ? params : [params];
|
const idx = Number(arr[0]?.dataIndex ?? 0);
|
const row = list[idx];
|
const title = row?.subject || row?.recordNo || '-';
|
const no = row?.recordNo ? `(${row.recordNo})` : '';
|
const lines = arr.map((p: any) => {
|
const unit = isRate ? '%' : '';
|
return `${p?.marker || ''}${p?.seriesName}: ${p?.value ?? '-'}${unit}`;
|
});
|
return [`${title}${no}`, ...lines].join('<br/>');
|
}
|
|
function categoryText(v?: string) {
|
void dicReady.value;
|
return labelOfCategory(v);
|
}
|
|
function evalModeText(v?: string) {
|
void dicReady.value;
|
return labelOfEvalMode(v);
|
}
|
|
function rateFloor(num: number, den: number) {
|
if (den <= 0) return 0;
|
return Math.floor((num * 100) / den);
|
}
|
|
function rateText(v?: number | null) {
|
if (v == null || Number.isNaN(Number(v))) return '-';
|
return `${Number(v)}%`;
|
}
|
|
function formatDateTime(v?: string | null) {
|
if (v == null || v === '') return '-';
|
const d = dayjs(v);
|
return d.isValid() ? d.format(DT_FMT) : String(v);
|
}
|
|
function handleClose() {
|
closeModal();
|
}
|
</script>
|
|
<template>
|
<BasicModal
|
v-bind="$attrs"
|
:title="modalTitle"
|
width="1100px"
|
destroy-on-close
|
:show-ok-btn="false"
|
:cancel-text="TMS_BTN.close"
|
:can-fullscreen="true"
|
@register="registerModal"
|
@cancel="handleClose"
|
>
|
<a-divider orientation="left">汇总</a-divider>
|
<ADescriptions :column="4" bordered size="small" class="tms-stats-desc">
|
<ADescriptionsItem label="培训场次">{{ summary.sessionCount }}</ADescriptionsItem>
|
<ADescriptionsItem label="应到人次">{{ summary.personCount }}</ADescriptionsItem>
|
<ADescriptionsItem label="签到人次">{{ summary.signedCount }}</ADescriptionsItem>
|
<ADescriptionsItem label="合格人次">{{ summary.passedCount }}</ADescriptionsItem>
|
<ADescriptionsItem label="签到率">{{ summary.signRate }}%</ADescriptionsItem>
|
<ADescriptionsItem label="合格率">{{ summary.passRate }}%</ADescriptionsItem>
|
<ADescriptionsItem label="未签到" :span="2">{{ summary.unsignedCount }}</ADescriptionsItem>
|
</ADescriptions>
|
|
<template v-if="showCharts">
|
<a-divider orientation="left">统计图</a-divider>
|
<div class="tms-stats-charts">
|
<div class="tms-stats-charts__panel">
|
<div class="tms-stats-charts__title">各场次人次对比</div>
|
<HomeChart :option="barOption" height="300px" />
|
</div>
|
<div class="tms-stats-charts__panel">
|
<div class="tms-stats-charts__title">各场次签到率 / 合格率</div>
|
<HomeChart :option="lineOption" height="300px" />
|
</div>
|
</div>
|
</template>
|
|
<a-divider orientation="left">场次明细</a-divider>
|
<a-table
|
size="small"
|
bordered
|
row-key="recordId"
|
:loading="loading"
|
:columns="columns"
|
:data-source="rows"
|
:pagination="rows.length > 10 ? { pageSize: 10, showSizeChanger: true } : false"
|
:locale="{ emptyText: '该维度下暂无培训场次' }"
|
:scroll="{ x: 960 }"
|
>
|
<template #bodyCell="{ column, record }">
|
<template v-if="column.key === 'category'">
|
{{ categoryText(record.category) }}
|
</template>
|
<template v-else-if="column.key === 'evalMode'">
|
{{ evalModeText(record.evalMode) }}
|
</template>
|
<template v-else-if="column.key === 'startTime'">
|
{{ formatDateTime(record.startTime) }}
|
</template>
|
<template v-else-if="column.key === 'signRate'">
|
{{ rateText(record.signRate) }}
|
</template>
|
<template v-else-if="column.key === 'passRate'">
|
{{ rateText(record.passRate) }}
|
</template>
|
</template>
|
</a-table>
|
</BasicModal>
|
</template>
|
|
<style scoped>
|
.tms-stats-desc {
|
margin-bottom: 4px;
|
}
|
|
.tms-stats-charts {
|
display: grid;
|
grid-template-columns: 1fr;
|
gap: 12px;
|
}
|
|
.tms-stats-charts__panel {
|
min-width: 0;
|
}
|
|
.tms-stats-charts__title {
|
margin-bottom: 4px;
|
color: rgba(0, 0, 0, 0.45);
|
font-size: 13px;
|
}
|
</style>
|