<script lang="ts" setup>
|
import type { ActionItem, BasicColumn } from '@jnpf/ui/vxeTable';
|
|
import type { RecordListMode, TmsRecordListItem } from './types';
|
|
import { computed, onMounted, watch } from 'vue';
|
import { useRoute, useRouter } from 'vue-router';
|
|
import { useMessage } from '@jnpf/hooks';
|
import { BasicVxeTable, TableAction, useVxeTable } from '@jnpf/ui/vxeTable';
|
|
import { Modal } from 'ant-design-vue';
|
|
import { archiveTmsRecords, deleteTmsRecords, getTmsRecordList, markInvalidTmsRecords, markReadyByCloseTime, promoteTmsRecordsToReady, restoreTmsRecordsToPending } from '#/api/x/tms/record';
|
import { TMS_DIC_FIELD_NAMES } from '#/views/x/tms/shared/dic';
|
import { TMS_BTN } from '#/views/x/tms/shared/ui';
|
|
import {
|
ARCHIVE_STATUS_OPTIONS,
|
EVAL_MODE_OPTIONS,
|
LIST_MODE_LABEL,
|
RECORD_LIST_PATH,
|
TASK_CATEGORY_OPTIONS,
|
TRAIN_MODE_OPTIONS,
|
colorOfArchiveStatus,
|
labelOfArchiveStatus,
|
labelOfCategory,
|
labelOfEvalMode,
|
labelOfTrainMode,
|
loadRecordDics,
|
} from './constants';
|
|
import '#/views/x/tms/shared/page.css';
|
|
defineOptions({ name: 'TmsRecord' });
|
|
const route = useRoute();
|
const router = useRouter();
|
const { createMessage } = useMessage();
|
|
function modeFromPath(path: string): RecordListMode {
|
if (path.includes('/archived')) return 'archived';
|
if (path.includes('/ready')) return 'ready';
|
return 'main';
|
}
|
|
const listMode = computed(() => modeFromPath(route.path));
|
const listModeTitle = computed(() => LIST_MODE_LABEL[listMode.value]);
|
const isMainView = computed(() => listMode.value === 'main');
|
const isReadyView = computed(() => listMode.value === 'ready');
|
const isArchivedView = computed(() => listMode.value === 'archived');
|
|
const columns: BasicColumn[] = [
|
{ title: '编号', dataIndex: 'recordNo', width: 140 },
|
{
|
title: '培训分类',
|
dataIndex: 'category',
|
width: 110,
|
customRender: ({ record }) => labelOfCategory((record as TmsRecordListItem).category),
|
},
|
{
|
title: '培训主题',
|
dataIndex: 'subject',
|
minWidth: 220,
|
slots: { default: 'subject' },
|
},
|
{ title: '培训师', dataIndex: 'trainerName', width: 100 },
|
{
|
title: '培训方式',
|
dataIndex: 'trainMode',
|
width: 110,
|
customRender: ({ record }) => {
|
const row = record as TmsRecordListItem;
|
return labelOfTrainMode(row.trainMode || row.trainType);
|
},
|
},
|
{
|
title: '考核方式',
|
dataIndex: 'evalMode',
|
width: 110,
|
customRender: ({ record }) => labelOfEvalMode((record as TmsRecordListItem).evalMode),
|
},
|
{ title: '开始时间', dataIndex: 'startTime', width: 160 },
|
{ title: '结束时间', dataIndex: 'endTime', width: 160 },
|
{ title: '关闭时间', dataIndex: 'closeTime', width: 160 },
|
{
|
title: '归档状态',
|
dataIndex: 'archiveStatus',
|
width: 100,
|
align: 'center',
|
slots: { default: 'archiveStatus' },
|
},
|
{
|
title: '签到率',
|
dataIndex: 'signRate',
|
width: 90,
|
align: 'center',
|
customRender: ({ record }) => {
|
const v = (record as TmsRecordListItem).signRate;
|
return v == null ? '-' : `${v}%`;
|
},
|
},
|
{
|
title: '合格率',
|
dataIndex: 'passRate',
|
width: 90,
|
align: 'center',
|
customRender: ({ record }) => {
|
const v = (record as TmsRecordListItem).passRate;
|
return v == null ? '-' : `${v}%`;
|
},
|
},
|
];
|
|
const [registerTable, { reload, getForm, getSelectRows }] = useVxeTable({
|
api: fetchList,
|
columns,
|
immediate: false,
|
rowKey: 'id',
|
rowSelection: { type: 'checkbox' },
|
useSearchForm: true,
|
formConfig: {
|
baseColProps: { span: 6 },
|
compact: true,
|
showAdvancedButton: true,
|
alwaysShowLines: 1,
|
autoAdvancedLine: 4,
|
schemas: [
|
{
|
field: 'recordNo',
|
label: '编号',
|
component: 'Input',
|
componentProps: { placeholder: '请输入编号', submitOnPressEnter: true },
|
},
|
{
|
field: 'subject',
|
label: '培训主题',
|
component: 'Input',
|
componentProps: { placeholder: '请输入主题', submitOnPressEnter: true },
|
},
|
{
|
field: 'category',
|
label: '培训分类',
|
component: 'Select',
|
componentProps: {
|
allowClear: true,
|
placeholder: '请选择',
|
options: TASK_CATEGORY_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'trainerName',
|
label: '培训师',
|
component: 'Input',
|
componentProps: { placeholder: '请输入培训师', submitOnPressEnter: true },
|
},
|
{
|
field: 'trainMode',
|
label: '培训方式',
|
component: 'Select',
|
componentProps: {
|
allowClear: true,
|
placeholder: '请选择',
|
options: TRAIN_MODE_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'evalMode',
|
label: '考核方式',
|
component: 'Select',
|
componentProps: {
|
allowClear: true,
|
placeholder: '请选择',
|
options: EVAL_MODE_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'archiveStatus',
|
label: '归档状态',
|
component: 'Select',
|
componentProps: {
|
allowClear: true,
|
placeholder: '请选择',
|
options: ARCHIVE_STATUS_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'startTimeRange',
|
label: '开始时间',
|
component: 'DateRange',
|
componentProps: {
|
format: 'YYYY-MM-DD',
|
placeholder: ['开始', '结束'],
|
},
|
},
|
{
|
field: 'endTimeRange',
|
label: '结束时间',
|
component: 'DateRange',
|
componentProps: {
|
format: 'YYYY-MM-DD',
|
placeholder: ['开始', '结束'],
|
},
|
},
|
],
|
},
|
actionColumn: {
|
width: 200,
|
title: '操作',
|
dataIndex: 'action',
|
fixed: 'right',
|
},
|
});
|
|
watch(
|
() => listMode.value,
|
() => reload(),
|
);
|
|
onMounted(async () => {
|
await loadRecordDics();
|
getForm()?.updateSchema?.([
|
{
|
field: 'category',
|
componentProps: {
|
allowClear: true,
|
placeholder: '请选择',
|
options: TASK_CATEGORY_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'trainMode',
|
componentProps: {
|
allowClear: true,
|
placeholder: '请选择',
|
options: TRAIN_MODE_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'evalMode',
|
componentProps: {
|
allowClear: true,
|
placeholder: '请选择',
|
options: EVAL_MODE_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
{
|
field: 'archiveStatus',
|
componentProps: {
|
allowClear: true,
|
placeholder: '请选择',
|
options: ARCHIVE_STATUS_OPTIONS.value,
|
fieldNames: TMS_DIC_FIELD_NAMES,
|
},
|
},
|
]);
|
// 支持从统计页下钻:?category=&evalMode=&startTimeFrom=&startTimeTo=
|
const q = route.query || {};
|
const patch: Record<string, any> = {};
|
if (typeof q.category === 'string' && q.category) patch.category = q.category;
|
if (typeof q.evalMode === 'string' && q.evalMode) patch.evalMode = q.evalMode;
|
if (typeof q.startTimeFrom === 'string' && typeof q.startTimeTo === 'string' && q.startTimeFrom && q.startTimeTo) {
|
patch.startTimeRange = [q.startTimeFrom, q.startTimeTo];
|
}
|
if (Object.keys(patch).length) {
|
getForm()?.setFieldsValue?.(patch);
|
}
|
reload();
|
});
|
|
async function fetchList(params: Record<string, any>) {
|
const query = { ...params, listMode: listMode.value };
|
const startRange = query.startTimeRange;
|
if (Array.isArray(startRange) && startRange.length === 2) {
|
query.startTimeFrom = startRange[0];
|
query.startTimeTo = startRange[1];
|
}
|
delete query.startTimeRange;
|
const endRange = query.endTimeRange;
|
if (Array.isArray(endRange) && endRange.length === 2) {
|
query.endTimeFrom = endRange[0];
|
query.endTimeTo = endRange[1];
|
}
|
delete query.endTimeRange;
|
const page = await getTmsRecordList(query);
|
return {
|
data: {
|
list: Array.isArray(page?.list) ? page.list : [],
|
pagination: page?.pagination || { total: 0 },
|
},
|
};
|
}
|
|
function getSelectedRows(): TmsRecordListItem[] {
|
return (getSelectRows?.() || []) as TmsRecordListItem[];
|
}
|
|
function requireOneRow(): TmsRecordListItem | null {
|
const rows = getSelectedRows();
|
if (!rows.length) {
|
createMessage.warning('请先勾选一条记录');
|
return null;
|
}
|
if (rows.length > 1) {
|
createMessage.warning('请只勾选一条记录');
|
return null;
|
}
|
return rows[0];
|
}
|
|
function requireRows(): TmsRecordListItem[] | null {
|
const rows = getSelectedRows();
|
if (!rows.length) {
|
createMessage.warning('请先勾选记录');
|
return null;
|
}
|
return rows;
|
}
|
|
function goList(mode: RecordListMode) {
|
router.push(RECORD_LIST_PATH[mode]);
|
}
|
|
function handleView(record?: TmsRecordListItem) {
|
const row = record || requireOneRow();
|
if (!row) return;
|
router.push({
|
path: `/tms/record/detail/${row.id}`,
|
query: { from: listMode.value },
|
});
|
}
|
|
function handleSignQr(record?: TmsRecordListItem) {
|
const row = record || requireOneRow();
|
if (!row) return;
|
if (row.archiveStatus === 'archived' || isArchivedView.value) {
|
createMessage.warning('已归档记录不可生成签到二维码');
|
return;
|
}
|
router.push(`/tms/record/sign/${row.id}`);
|
}
|
|
function handlePractice(record: TmsRecordListItem) {
|
if (record.archiveStatus === 'archived' || isArchivedView.value) {
|
createMessage.warning('已归档记录不可实操打分');
|
return;
|
}
|
if (record.evalMode !== 'practice') {
|
createMessage.warning('仅考核方式为实操的记录可打分');
|
return;
|
}
|
router.push(`/tms/record/practice/${record.id}`);
|
}
|
|
function handleDelete() {
|
const rows = requireRows();
|
if (!rows) return;
|
Modal.confirm({
|
title: '确认删除',
|
content: `确定删除选中的 ${rows.length} 条培训记录吗?`,
|
onOk: async () => {
|
try {
|
await deleteTmsRecords(rows.map((x) => x.id));
|
createMessage.success('删除成功');
|
reload();
|
} catch (e: any) {
|
createMessage.error(e?.message || '删除失败');
|
}
|
},
|
});
|
}
|
|
function handleArchive() {
|
const rows = requireRows();
|
if (!rows) return;
|
Modal.confirm({
|
title: '确认归档',
|
content: `确定将选中的 ${rows.length} 条记录归档吗?`,
|
onOk: async () => {
|
try {
|
const count = await archiveTmsRecords(rows.map((x) => x.id));
|
createMessage.success(`已归档 ${count} 条`);
|
reload();
|
} catch (e: any) {
|
createMessage.error(e?.message || '归档失败');
|
}
|
},
|
});
|
}
|
|
/** 临时:关闭时间已到的进行中 → pending(上线后改 XXL-JOB) */
|
function handleMarkReadyByClose() {
|
Modal.confirm({
|
title: '到期转入待归档',
|
content: '将关闭时间已到且仍为进行中的记录转入待归档(临时按钮,后续由定时任务执行)。',
|
onOk: async () => {
|
try {
|
const count = await markReadyByCloseTime();
|
createMessage.success(count > 0 ? `已转入待归档 ${count} 条` : '没有符合条件的记录');
|
reload();
|
} catch (e: any) {
|
createMessage.error(e?.message || '转入失败');
|
}
|
},
|
});
|
}
|
|
/** pending → ready */
|
function handlePromoteReady() {
|
const rows = requireRows();
|
if (!rows) return;
|
Modal.confirm({
|
title: '转入可归档',
|
content: `确定将选中的 ${rows.length} 条待归档记录转入可归档?`,
|
onOk: async () => {
|
try {
|
const count = await promoteTmsRecordsToReady(rows.map((x) => x.id));
|
createMessage.success(`已转入可归档 ${count} 条`);
|
reload();
|
} catch (e: any) {
|
createMessage.error(e?.message || '转入失败');
|
}
|
},
|
});
|
}
|
|
/** 无人完成 → invalid */
|
function handleMarkInvalid() {
|
const rows = requireRows();
|
if (!rows) return;
|
Modal.confirm({
|
title: '作废记录',
|
content: `确定作废选中的 ${rows.length} 条记录?仅无人完成时可作废,并自动生成再维护草稿任务。`,
|
onOk: async () => {
|
try {
|
const count = await markInvalidTmsRecords(rows.map((x) => x.id));
|
createMessage.success(`已作废 ${count} 条`);
|
reload();
|
} catch (e: any) {
|
createMessage.error(e?.message || '作废失败');
|
}
|
},
|
});
|
}
|
|
/** 临时:ready → pending */
|
function handleRestorePending() {
|
const rows = requireRows();
|
if (!rows) return;
|
Modal.confirm({
|
title: '恢复待归档',
|
content: `确定将选中的 ${rows.length} 条可归档记录恢复为待归档?`,
|
onOk: async () => {
|
try {
|
const count = await restoreTmsRecordsToPending(rows.map((x) => x.id));
|
createMessage.success(`已恢复 ${count} 条`);
|
reload();
|
} catch (e: any) {
|
createMessage.error(e?.message || '恢复失败');
|
}
|
},
|
});
|
}
|
|
function getTableActions(record: TmsRecordListItem): ActionItem[] {
|
// 详情页已含实时签到/合格/未完成进度,「进度」与「详情」入口重复,只保留详情
|
const actions: ActionItem[] = [
|
{ label: TMS_BTN.detail, onClick: handleView.bind(null, record) },
|
];
|
const archived = record.archiveStatus === 'archived' || isArchivedView.value;
|
if (!archived) {
|
actions.push({ label: TMS_BTN.signQr, onClick: handleSignQr.bind(null, record) });
|
}
|
if (!archived && record.evalMode === 'practice') {
|
actions.push({ label: TMS_BTN.practiceScore, onClick: handlePractice.bind(null, record) });
|
}
|
return actions;
|
}
|
</script>
|
|
<template>
|
<div class="jnpf-content-wrapper">
|
<div class="jnpf-content-wrapper-center">
|
<div class="jnpf-content-wrapper-content">
|
<BasicVxeTable @register="registerTable">
|
<template #tableTitle>
|
<!-- 1. 培训记录 -->
|
<a-space v-if="isMainView" wrap>
|
<a-button type="primary" pre-icon="icon-ym icon-ym-btn-preview" @click="handleView()">
|
{{ TMS_BTN.detail }}
|
</a-button>
|
<a-button pre-icon="icon-ym icon-ym-extend-form" @click="handleSignQr">
|
{{ TMS_BTN.signQr }}
|
</a-button>
|
<a-button type="primary" ghost @click="handleMarkReadyByClose">
|
{{ TMS_BTN.markReady }}
|
</a-button>
|
<a-button pre-icon="icon-ym icon-ym-file-text" @click="goList('ready')">可归档列表</a-button>
|
<a-button pre-icon="icon-ym icon-ym-extend-folder" @click="goList('archived')">已归档列表</a-button>
|
<span class="tms-toolbar-hint">当前:{{ listModeTitle }} · 查看详情;实操记录可「实操打分」</span>
|
</a-space>
|
|
<!-- 2. 可归档列表 -->
|
<a-space v-else-if="isReadyView" wrap>
|
<a-button type="primary" pre-icon="icon-ym icon-ym-btn-preview" @click="handleView()">
|
{{ TMS_BTN.detail }}
|
</a-button>
|
<a-button danger pre-icon="icon-ym icon-ym-btn-clearn" @click="handleDelete">
|
{{ TMS_BTN.delete }}
|
</a-button>
|
<a-button type="primary" pre-icon="icon-ym icon-ym-extend-folder" @click="handleArchive">
|
{{ TMS_BTN.archive }}
|
</a-button>
|
<a-button @click="handlePromoteReady">{{ TMS_BTN.promoteReady }}</a-button>
|
<a-button danger ghost @click="handleMarkInvalid">{{ TMS_BTN.markInvalid }}</a-button>
|
<a-button @click="handleRestorePending">{{ TMS_BTN.restorePending }}</a-button>
|
<a-button @click="goList('main')">{{ TMS_BTN.back }}</a-button>
|
<a-button pre-icon="icon-ym icon-ym-file-text" @click="goList('archived')">已归档列表</a-button>
|
<span class="tms-toolbar-hint">当前:{{ listModeTitle }} · 临时可恢复待归档</span>
|
</a-space>
|
|
<!-- 3. 已归档列表 -->
|
<a-space v-else wrap>
|
<a-button @click="handleView()">{{ TMS_BTN.detail }}</a-button>
|
<a-button @click="goList('main')">{{ TMS_BTN.back }}</a-button>
|
<a-button type="primary" ghost @click="goList('ready')">可归档列表</a-button>
|
<span class="tms-toolbar-hint">当前:{{ listModeTitle }} · 仅可查看</span>
|
</a-space>
|
</template>
|
<template #subject="{ record }">
|
<div class="subject-cell">{{ record.subject }}</div>
|
</template>
|
<template #archiveStatus="{ record }">
|
<a-tag class="archive-status-tag" :color="colorOfArchiveStatus(record.archiveStatus)">
|
{{ labelOfArchiveStatus(record.archiveStatus) }}
|
</a-tag>
|
</template>
|
<template #action="{ record }">
|
<TableAction :actions="getTableActions(record)" />
|
</template>
|
</BasicVxeTable>
|
</div>
|
</div>
|
</div>
|
</template>
|
|
<style scoped>
|
.subject-cell {
|
white-space: pre-wrap;
|
line-height: 1.45;
|
word-break: break-word;
|
}
|
|
.archive-status-tag {
|
margin-inline-end: 0;
|
}
|
</style>
|