From 883d0d8e02e9272d37531f1645cbbb84a1f886bd Mon Sep 17 00:00:00 2001
From: tuqin <tuqin@tuqin.cn>
Date: 星期一, 02 十一月 2026 13:05:35 +0800
Subject: [PATCH] feat(safety): 安全生产五期 - 安全教育培训前端
---
src/views/safety/certificate/modules/form.vue | 88 ++++
src/api/safety/certificate/index.ts | 69 +++
src/api/safety/training/index.ts | 117 +++++
src/views/safety/training/data.ts | 252 +++++++++++
src/views/safety/training/modules/attendee.vue | 211 +++++++++
src/views/safety/training/modules/form.vue | 85 +++
src/views/safety/training/index.vue | 147 ++++++
src/views/safety/certificate/data.ts | 175 +++++++
src/views/safety/certificate/index.vue | 140 ++++++
src/packages/constants/src/dict-enum.ts | 5
10 files changed, 1,289 insertions(+), 0 deletions(-)
diff --git a/src/api/safety/certificate/index.ts b/src/api/safety/certificate/index.ts
new file mode 100644
index 0000000..a5b7d30
--- /dev/null
+++ b/src/api/safety/certificate/index.ts
@@ -0,0 +1,69 @@
+import type { PageParam, PageResult } from '#/packages/effects/request/src';
+
+import { requestClient } from '#/api/request';
+
+export namespace MesSafetyCertificateApi {
+ /** 璧勬牸璇佷功 */
+ export interface Certificate {
+ id?: number;
+ userId?: number;
+ userName?: string;
+ deptId?: number;
+ deptName?: string;
+ certType?: number;
+ certNo?: string;
+ issueOrg?: string;
+ issueDate?: string;
+ expireDate?: string;
+ daysToExpire?: number;
+ expired?: boolean;
+ expiringSoon?: boolean;
+ status?: number;
+ attachmentUrl?: string;
+ remark?: string;
+ createTime?: string;
+ }
+
+ /** 鍒嗛〉鍙傛暟 */
+ export interface CertificatePageParam extends PageParam {
+ userId?: number;
+ certType?: number;
+ certNo?: string;
+ status?: number;
+ expiringSoon?: boolean;
+ }
+}
+
+/** 鏌ヨ璧勬牸璇佷功鍒嗛〉 */
+export function getCertificatePage(
+ params: MesSafetyCertificateApi.CertificatePageParam,
+) {
+ return requestClient.get<PageResult<MesSafetyCertificateApi.Certificate>>(
+ '/mes/safety/certificate/page',
+ { params },
+ );
+}
+
+/** 鏌ヨ璧勬牸璇佷功璇︽儏 */
+export function getCertificate(id: number) {
+ return requestClient.get<MesSafetyCertificateApi.Certificate>(
+ `/mes/safety/certificate/get?id=${id}`,
+ );
+}
+
+/** 鍒涘缓璧勬牸璇佷功 */
+export function createCertificate(data: MesSafetyCertificateApi.Certificate) {
+ return requestClient.post<number>('/mes/safety/certificate/create', data);
+}
+
+/** 鏇存柊璧勬牸璇佷功 */
+export function updateCertificate(data: MesSafetyCertificateApi.Certificate) {
+ return requestClient.put<boolean>('/mes/safety/certificate/update', data);
+}
+
+/** 鍒犻櫎璧勬牸璇佷功 */
+export function deleteCertificate(id: number) {
+ return requestClient.delete<boolean>(
+ `/mes/safety/certificate/delete?id=${id}`,
+ );
+}
diff --git a/src/api/safety/training/index.ts b/src/api/safety/training/index.ts
new file mode 100644
index 0000000..b68a918
--- /dev/null
+++ b/src/api/safety/training/index.ts
@@ -0,0 +1,117 @@
+import type { PageParam, PageResult } from '#/packages/effects/request/src';
+
+import { requestClient } from '#/api/request';
+
+export namespace MesSafetyTrainingApi {
+ /** 鍩硅鎵规 */
+ export interface Training {
+ id?: number;
+ code?: string;
+ title?: string;
+ trainingType?: number;
+ trainingLevel?: number;
+ trainer?: string;
+ trainerOrg?: string;
+ location?: string;
+ startTime?: string;
+ endTime?: string;
+ durationHours?: number;
+ content?: string;
+ material?: string;
+ status?: number;
+ attendeeCount?: number;
+ signedCount?: number;
+ avgScore?: number;
+ remark?: string;
+ createTime?: string;
+ }
+
+ /** 鍙傝浜哄憳 */
+ export interface Attendee {
+ id?: number;
+ trainingId?: number;
+ userId?: number;
+ userName?: string;
+ deptId?: number;
+ deptName?: string;
+ signStatus?: number;
+ signTime?: string;
+ score?: number;
+ passed?: number;
+ remark?: string;
+ }
+
+ /** 鍒嗛〉鍙傛暟 */
+ export interface TrainingPageParam extends PageParam {
+ code?: string;
+ title?: string;
+ trainingType?: number;
+ trainingLevel?: number;
+ status?: number;
+ startTimeStart?: string;
+ startTimeEnd?: string;
+ }
+}
+
+/** 鏌ヨ鍩硅鍒嗛〉 */
+export function getTrainingPage(params: MesSafetyTrainingApi.TrainingPageParam) {
+ return requestClient.get<PageResult<MesSafetyTrainingApi.Training>>(
+ '/mes/safety/training/page',
+ { params },
+ );
+}
+
+/** 鏌ヨ鍩硅璇︽儏 */
+export function getTraining(id: number) {
+ return requestClient.get<MesSafetyTrainingApi.Training>(
+ `/mes/safety/training/get?id=${id}`,
+ );
+}
+
+/** 鍒涘缓鍩硅 */
+export function createTraining(data: MesSafetyTrainingApi.Training) {
+ return requestClient.post<number>('/mes/safety/training/create', data);
+}
+
+/** 鏇存柊鍩硅 */
+export function updateTraining(data: MesSafetyTrainingApi.Training) {
+ return requestClient.put<boolean>('/mes/safety/training/update', data);
+}
+
+/** 鍒犻櫎鍩硅 */
+export function deleteTraining(id: number) {
+ return requestClient.delete<boolean>(`/mes/safety/training/delete?id=${id}`);
+}
+
+/** 鏌ヨ鍙傝浜哄憳鍒楄〃 */
+export function getAttendeeList(trainingId: number) {
+ return requestClient.get<MesSafetyTrainingApi.Attendee[]>(
+ `/mes/safety/training/attendee-list?trainingId=${trainingId}`,
+ );
+}
+
+/** 鍏ㄩ噺淇濆瓨鍙傝浜哄憳 */
+export function saveAttendees(
+ trainingId: number,
+ attendees: MesSafetyTrainingApi.Attendee[],
+) {
+ return requestClient.post<boolean>(
+ `/mes/safety/training/save-attendees?trainingId=${trainingId}`,
+ attendees,
+ );
+}
+
+/** 鏇存柊鍙傝浜哄憳绛惧埌/鎴愮哗 */
+export function updateAttendee(data: MesSafetyTrainingApi.Attendee) {
+ return requestClient.put<boolean>(
+ '/mes/safety/training/update-attendee',
+ data,
+ );
+}
+
+/** 鍒犻櫎鍙傝浜哄憳 */
+export function deleteAttendee(id: number) {
+ return requestClient.delete<boolean>(
+ `/mes/safety/training/delete-attendee?id=${id}`,
+ );
+}
diff --git a/src/packages/constants/src/dict-enum.ts b/src/packages/constants/src/dict-enum.ts
index a38684c..f3066d5 100644
--- a/src/packages/constants/src/dict-enum.ts
+++ b/src/packages/constants/src/dict-enum.ts
@@ -315,6 +315,11 @@
MES_SAFETY_TICKET_TYPE: 'mes_safety_ticket_type', // 瀹夊叏鐢熶骇-浣滀笟绫诲瀷
MES_SAFETY_TICKET_LEVEL: 'mes_safety_ticket_level', // 瀹夊叏鐢熶骇-浣滀笟绾у埆
MES_SAFETY_TICKET_STATUS: 'mes_safety_ticket_status', // 瀹夊叏鐢熶骇-浣滀笟绁ㄧ姸鎬�
+ MES_SAFETY_TRAINING_TYPE: 'mes_safety_training_type', // 瀹夊叏鐢熶骇-鍩硅绫诲瀷
+ MES_SAFETY_TRAINING_LEVEL: 'mes_safety_training_level', // 瀹夊叏鐢熶骇-鍩硅灞傜骇
+ MES_SAFETY_TRAINING_STATUS: 'mes_safety_training_status', // 瀹夊叏鐢熶骇-鍩硅鐘舵��
+ MES_SAFETY_ATTENDEE_STATUS: 'mes_safety_attendee_status', // 瀹夊叏鐢熶骇-绛惧埌鐘舵��
+ MES_SAFETY_CERT_TYPE: 'mes_safety_cert_type', // 瀹夊叏鐢熶骇-璇佷功绫诲瀷
} as const;
/** ========== WMS - 浠撳偍绠$悊妯″潡 ========== */
diff --git a/src/views/safety/certificate/data.ts b/src/views/safety/certificate/data.ts
new file mode 100644
index 0000000..821e0a3
--- /dev/null
+++ b/src/views/safety/certificate/data.ts
@@ -0,0 +1,175 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { MesSafetyCertificateApi } from '#/api/safety/certificate';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+import { getSimpleUserList } from '../risk/shared';
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'certNo',
+ label: '璇佷功缂栧彿',
+ component: 'Input',
+ componentProps: { allowClear: true, placeholder: '璇疯緭鍏ヨ瘉涔︾紪鍙�' },
+ },
+ {
+ fieldName: 'certType',
+ label: '璇佷功绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.MES_SAFETY_CERT_TYPE, 'number'),
+ placeholder: '璇烽�夋嫨璇佷功绫诲瀷',
+ },
+ },
+ {
+ fieldName: 'expiringSoon',
+ label: '鍗冲皢鍒版湡',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: [
+ { label: '浠呯湅 30 澶╁唴鍒版湡锛堝惈宸茶繃鏈燂級', value: true },
+ { label: '鍏ㄩ儴', value: false },
+ ],
+ placeholder: '鍒版湡绛涢��',
+ },
+ },
+ ];
+}
+
+/** 鏂板/淇敼鐨勮〃鍗� */
+export function useFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'id',
+ component: 'Input',
+ dependencies: { triggerFields: [''], show: () => false },
+ },
+ {
+ fieldName: 'userId',
+ label: '鎸佽瘉浜�',
+ component: 'ApiSelect',
+ componentProps: {
+ api: getSimpleUserList,
+ labelField: 'nickname',
+ valueField: 'id',
+ placeholder: '璇烽�夋嫨鎸佽瘉浜�',
+ },
+ rules: 'selectRequired',
+ },
+ {
+ fieldName: 'certType',
+ label: '璇佷功绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ options: getDictOptions(DICT_TYPE.MES_SAFETY_CERT_TYPE, 'number'),
+ placeholder: '璇烽�夋嫨璇佷功绫诲瀷',
+ },
+ rules: 'selectRequired',
+ },
+ {
+ fieldName: 'certNo',
+ label: '璇佷功缂栧彿',
+ component: 'Input',
+ componentProps: { placeholder: '璇疯緭鍏ヨ瘉涔︾紪鍙�' },
+ rules: 'required',
+ },
+ {
+ fieldName: 'issueOrg',
+ label: '鍙戣瘉鏈哄叧',
+ component: 'Input',
+ componentProps: { placeholder: '濡� 鏂扮枂搴旀�ョ鐞嗗巺' },
+ },
+ {
+ fieldName: 'issueDate',
+ label: '鍙戣瘉鏃ユ湡',
+ component: 'DatePicker',
+ componentProps: {
+ class: '!w-full',
+ format: 'YYYY-MM-DD',
+ valueFormat: 'YYYY-MM-DD',
+ placeholder: '璇烽�夋嫨鍙戣瘉鏃ユ湡',
+ },
+ },
+ {
+ fieldName: 'expireDate',
+ label: '鏈夋晥鏈熻嚦',
+ component: 'DatePicker',
+ componentProps: {
+ class: '!w-full',
+ format: 'YYYY-MM-DD',
+ valueFormat: 'YYYY-MM-DD',
+ placeholder: '鐣欑┖琛ㄧず闀挎湡鏈夋晥',
+ },
+ },
+ {
+ fieldName: 'attachmentUrl',
+ label: '璇佷功鎵弿浠�',
+ component: 'Input',
+ componentProps: { placeholder: '鎵弿浠跺湴鍧�锛堥�夊~锛�' },
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ optionType: 'button',
+ options: [
+ { label: '鏈夋晥', value: 1 },
+ { label: '宸蹭綔搴�', value: 0 },
+ ],
+ },
+ },
+ {
+ fieldName: 'remark',
+ label: '澶囨敞',
+ component: 'Textarea',
+ formItemClass: 'col-span-2',
+ componentProps: { placeholder: '璇疯緭鍏ュ娉�', rows: 2 },
+ },
+ ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions<MesSafetyCertificateApi.Certificate>['columns'] {
+ return [
+ { field: 'userName', title: '鎸佽瘉浜�', width: 120 },
+ { field: 'deptName', title: '閮ㄩ棬', width: 130 },
+ {
+ field: 'certType',
+ title: '璇佷功绫诲瀷',
+ width: 150,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.MES_SAFETY_CERT_TYPE },
+ },
+ },
+ { field: 'certNo', title: '璇佷功缂栧彿', minWidth: 160 },
+ { field: 'issueOrg', title: '鍙戣瘉鏈哄叧', minWidth: 150 },
+ { field: 'issueDate', title: '鍙戣瘉鏃ユ湡', width: 120 },
+ {
+ field: 'expireDate',
+ title: '鏈夋晥鏈熻嚦',
+ width: 160,
+ slots: { default: 'expire' },
+ },
+ {
+ field: 'status',
+ title: '鐘舵��',
+ width: 90,
+ slots: { default: 'status' },
+ },
+ {
+ title: '鎿嶄綔',
+ width: 160,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
diff --git a/src/views/safety/certificate/index.vue b/src/views/safety/certificate/index.vue
new file mode 100644
index 0000000..0619bc2
--- /dev/null
+++ b/src/views/safety/certificate/index.vue
@@ -0,0 +1,140 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { MesSafetyCertificateApi } from '#/api/safety/certificate';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { message, Tag } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import {
+ deleteCertificate,
+ getCertificatePage,
+} from '#/api/safety/certificate';
+import { useVbenModal } from '@vben/common-ui';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import FormModalComp from './modules/form.vue';
+
+defineOptions({ name: 'SafetyCertificate' });
+
+const [FormModal, formModalApi] = useVbenModal({
+ connectedComponent: FormModalComp,
+ destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) =>
+ await getCertificatePage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ }),
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<MesSafetyCertificateApi.Certificate>,
+});
+
+function handleCreate() {
+ formModalApi.setData({ formType: 'create' }).open();
+}
+
+function handleEdit(row: MesSafetyCertificateApi.Certificate) {
+ formModalApi.setData({ formType: 'update', id: row.id }).open();
+}
+
+function handleRefresh() {
+ gridApi.query();
+}
+
+async function handleDelete(row: MesSafetyCertificateApi.Certificate) {
+ await deleteCertificate(row.id!);
+ message.success('鍒犻櫎鎴愬姛');
+ gridApi.query();
+}
+</script>
+
+<template>
+ <Page auto-content-height>
+ <FormModal @success="handleRefresh" />
+ <Grid>
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: '鏂板璇佷功',
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['mes:safety-certificate:create'],
+ onClick: handleCreate,
+ },
+ ]"
+ />
+ </template>
+
+ <template #expire="{ row }">
+ <span v-if="!row.expireDate" class="text-gray-500">闀挎湡鏈夋晥</span>
+ <span v-else-if="row.expired" class="font-semibold text-red-600">
+ {{ row.expireDate }}
+ <Tag color="error" class="ml-1">宸茶繃鏈�</Tag>
+ </span>
+ <span v-else-if="row.expiringSoon" class="font-semibold text-orange-600">
+ {{ row.expireDate }}
+ <Tag color="warning" class="ml-1">鍓� {{ row.daysToExpire }} 澶�</Tag>
+ </span>
+ <span v-else>
+ {{ row.expireDate }}
+ <span class="ml-1 text-gray-400">({{ row.daysToExpire }} 澶�)</span>
+ </span>
+ </template>
+
+ <template #status="{ row }">
+ <Tag :color="row.status === 1 ? 'success' : 'default'">
+ {{ row.status === 1 ? '鏈夋晥' : '宸蹭綔搴�' }}
+ </Tag>
+ </template>
+
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '缂栬緫',
+ type: 'link',
+ icon: ACTION_ICON.EDIT,
+ auth: ['mes:safety-certificate:update'],
+ onClick: handleEdit.bind(null, row),
+ },
+ {
+ label: '鍒犻櫎',
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['mes:safety-certificate:delete'],
+ popConfirm: {
+ title: '纭畾鍒犻櫎璇ヨ瘉涔﹀悧锛�',
+ confirm: handleDelete.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/safety/certificate/modules/form.vue b/src/views/safety/certificate/modules/form.vue
new file mode 100644
index 0000000..4259dc5
--- /dev/null
+++ b/src/views/safety/certificate/modules/form.vue
@@ -0,0 +1,88 @@
+<script lang="ts" setup>
+import type { MesSafetyCertificateApi } from '#/api/safety/certificate';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+ createCertificate,
+ getCertificate,
+ updateCertificate,
+} from '#/api/safety/certificate';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+
+const formType = ref<'create' | 'update'>('create');
+const isUpdate = computed(() => formType.value === 'update');
+const getTitle = computed(() =>
+ isUpdate.value ? '淇敼璧勬牸璇佷功' : '鏂板璧勬牸璇佷功',
+);
+
+const [Form, formApi] = useVbenForm({
+ commonConfig: {
+ componentProps: { class: 'w-full' },
+ formItemClass: 'col-span-1',
+ labelWidth: 100,
+ },
+ wrapperClass: 'grid-cols-2',
+ layout: 'horizontal',
+ schema: useFormSchema(),
+ showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ const { valid } = await formApi.validate();
+ if (!valid) {
+ return;
+ }
+ modalApi.lock();
+ const data =
+ (await formApi.getValues()) as MesSafetyCertificateApi.Certificate;
+ try {
+ await (isUpdate.value
+ ? updateCertificate(data)
+ : createCertificate(data));
+ emit('success');
+ message.success('鎿嶄綔鎴愬姛');
+ await modalApi.close();
+ } finally {
+ modalApi.unlock();
+ }
+ },
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ return;
+ }
+ const data = modalApi.getData<{
+ formType: 'create' | 'update';
+ id?: number;
+ }>();
+ formType.value = data.formType;
+ await formApi.resetForm();
+ if (data?.id) {
+ modalApi.lock();
+ try {
+ const cert = await getCertificate(data.id);
+ await formApi.setValues(cert);
+ } finally {
+ modalApi.unlock();
+ }
+ } else {
+ formApi.setFieldValue('status', 1);
+ }
+ },
+});
+</script>
+
+<template>
+ <Modal :title="getTitle" class="w-1/2">
+ <Form class="mx-4" />
+ </Modal>
+</template>
diff --git a/src/views/safety/training/data.ts b/src/views/safety/training/data.ts
new file mode 100644
index 0000000..2a2ddec
--- /dev/null
+++ b/src/views/safety/training/data.ts
@@ -0,0 +1,252 @@
+import type { VbenFormSchema } from '#/adapter/form';
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { MesSafetyTrainingApi } from '#/api/safety/training';
+
+import { DICT_TYPE } from '#/packages/constants/src';
+import { getDictOptions } from '#/packages/effects/hooks/src';
+
+export const DATETIME_FORMAT = 'YYYY-MM-DD HH:mm:ss';
+
+/** 鍩硅鐘舵�� */
+export const TRAINING_STATUS = {
+ PLANNED: 0,
+ ONGOING: 10,
+ FINISHED: 20,
+ CANCELED: 30,
+};
+
+export function trainingStatusColor(status?: number): string {
+ switch (status) {
+ case 0: return 'processing';
+ case 10: return 'warning';
+ case 20: return 'success';
+ case 30: return 'default';
+ default: return 'default';
+ }
+}
+
+/** 鍒楄〃鐨勬悳绱㈣〃鍗� */
+export function useGridFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'code',
+ label: '鍩硅缂栧彿',
+ component: 'Input',
+ componentProps: { allowClear: true, placeholder: '璇疯緭鍏ュ煿璁紪鍙�' },
+ },
+ {
+ fieldName: 'title',
+ label: '鍩硅涓婚',
+ component: 'Input',
+ componentProps: { allowClear: true, placeholder: '璇疯緭鍏ュ煿璁富棰�' },
+ },
+ {
+ fieldName: 'trainingType',
+ label: '鍩硅绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.MES_SAFETY_TRAINING_TYPE, 'number'),
+ placeholder: '璇烽�夋嫨鍩硅绫诲瀷',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.MES_SAFETY_TRAINING_STATUS, 'number'),
+ placeholder: '璇烽�夋嫨鐘舵��',
+ },
+ },
+ ];
+}
+
+/** 鏂板/淇敼鐨勮〃鍗� */
+export function useFormSchema(): VbenFormSchema[] {
+ return [
+ {
+ fieldName: 'id',
+ component: 'Input',
+ dependencies: { triggerFields: [''], show: () => false },
+ },
+ {
+ fieldName: 'code',
+ label: '鍩硅缂栧彿',
+ component: 'Input',
+ componentProps: { placeholder: '濡� PX-2026-001' },
+ rules: 'required',
+ },
+ {
+ fieldName: 'title',
+ label: '鍩硅涓婚',
+ component: 'Input',
+ componentProps: { placeholder: '濡� 鐗圭浣滀笟瀹夊叏鎿嶄綔鍩硅' },
+ rules: 'required',
+ },
+ {
+ fieldName: 'trainingType',
+ label: '鍩硅绫诲瀷',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.MES_SAFETY_TRAINING_TYPE, 'number'),
+ placeholder: '璇烽�夋嫨鍩硅绫诲瀷',
+ },
+ },
+ {
+ fieldName: 'trainingLevel',
+ label: '鍩硅灞傜骇',
+ component: 'Select',
+ componentProps: {
+ allowClear: true,
+ options: getDictOptions(DICT_TYPE.MES_SAFETY_TRAINING_LEVEL, 'number'),
+ placeholder: '涓夌骇瀹夊叏鏁欒偛鏃堕�夋嫨灞傜骇',
+ },
+ },
+ {
+ fieldName: 'trainer',
+ label: '璁插笀',
+ component: 'Input',
+ componentProps: { placeholder: '璇疯緭鍏ヨ甯堝鍚�' },
+ },
+ {
+ fieldName: 'trainerOrg',
+ label: '璁插笀鍗曚綅',
+ component: 'Input',
+ componentProps: { placeholder: '澶栭儴璁插笀鏃跺~鍐�' },
+ },
+ {
+ fieldName: 'location',
+ label: '鍩硅鍦扮偣',
+ component: 'Input',
+ componentProps: { placeholder: '濡� 涓�妤煎煿璁' },
+ },
+ {
+ fieldName: 'startTime',
+ label: '寮�濮嬫椂闂�',
+ component: 'DatePicker',
+ componentProps: {
+ class: '!w-full',
+ showTime: true,
+ format: DATETIME_FORMAT,
+ valueFormat: DATETIME_FORMAT,
+ placeholder: '璇烽�夋嫨鍩硅寮�濮嬫椂闂�',
+ },
+ },
+ {
+ fieldName: 'endTime',
+ label: '缁撴潫鏃堕棿',
+ component: 'DatePicker',
+ componentProps: {
+ class: '!w-full',
+ showTime: true,
+ format: DATETIME_FORMAT,
+ valueFormat: DATETIME_FORMAT,
+ placeholder: '璇烽�夋嫨鍩硅缁撴潫鏃堕棿',
+ },
+ },
+ {
+ fieldName: 'durationHours',
+ label: '瀛︽椂',
+ component: 'InputNumber',
+ componentProps: {
+ class: '!w-full',
+ min: 0,
+ precision: 1,
+ placeholder: '濡� 4',
+ },
+ },
+ {
+ fieldName: 'status',
+ label: '鐘舵��',
+ component: 'RadioGroup',
+ componentProps: {
+ buttonStyle: 'solid',
+ optionType: 'button',
+ options: getDictOptions(DICT_TYPE.MES_SAFETY_TRAINING_STATUS, 'number'),
+ },
+ rules: 'selectRequired',
+ },
+ {
+ fieldName: 'content',
+ label: '鍩硅鍐呭',
+ component: 'Textarea',
+ formItemClass: 'col-span-2',
+ componentProps: { placeholder: '璇疯緭鍏ュ煿璁唴瀹�', rows: 3 },
+ },
+ {
+ fieldName: 'material',
+ label: '鍩硅璧勬枡',
+ component: 'Input',
+ formItemClass: 'col-span-2',
+ componentProps: { placeholder: '鏁欐潗/璧勬枡鍚嶇О鎴栭摼鎺�' },
+ },
+ {
+ fieldName: 'remark',
+ label: '澶囨敞',
+ component: 'Textarea',
+ formItemClass: 'col-span-2',
+ componentProps: { placeholder: '璇疯緭鍏ュ娉�', rows: 2 },
+ },
+ ];
+}
+
+/** 鍒楄〃鐨勫瓧娈� */
+export function useGridColumns(): VxeTableGridOptions<MesSafetyTrainingApi.Training>['columns'] {
+ return [
+ { field: 'code', title: '鍩硅缂栧彿', width: 140 },
+ { field: 'title', title: '鍩硅涓婚', minWidth: 200 },
+ {
+ field: 'trainingType',
+ title: '绫诲瀷',
+ width: 130,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.MES_SAFETY_TRAINING_TYPE },
+ },
+ },
+ {
+ field: 'trainingLevel',
+ title: '灞傜骇',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.MES_SAFETY_TRAINING_LEVEL },
+ },
+ },
+ { field: 'trainer', title: '璁插笀', width: 100 },
+ { field: 'startTime', title: '寮�濮嬫椂闂�', width: 170 },
+ { field: 'durationHours', title: '瀛︽椂', width: 80, align: 'right' },
+ {
+ field: 'attendeeCount',
+ title: '鍙傝/瀹炲埌',
+ width: 120,
+ align: 'center',
+ slots: { default: 'attendee' },
+ },
+ {
+ field: 'avgScore',
+ title: '骞冲潎鍒�',
+ width: 90,
+ align: 'right',
+ formatter: 'formatAmount2',
+ },
+ {
+ field: 'status',
+ title: '鐘舵��',
+ width: 100,
+ cellRender: {
+ name: 'CellDict',
+ props: { type: DICT_TYPE.MES_SAFETY_TRAINING_STATUS },
+ },
+ },
+ {
+ title: '鎿嶄綔',
+ width: 220,
+ fixed: 'right',
+ slots: { default: 'actions' },
+ },
+ ];
+}
diff --git a/src/views/safety/training/index.vue b/src/views/safety/training/index.vue
new file mode 100644
index 0000000..90c1559
--- /dev/null
+++ b/src/views/safety/training/index.vue
@@ -0,0 +1,147 @@
+<script lang="ts" setup>
+import type { VxeTableGridOptions } from '#/adapter/vxe-table';
+import type { MesSafetyTrainingApi } from '#/api/safety/training';
+
+import { Page } from '#/packages/effects/common-ui/src';
+
+import { message } from 'ant-design-vue';
+
+import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
+import { deleteTraining, getTrainingPage } from '#/api/safety/training';
+import { useVbenModal } from '@vben/common-ui';
+
+import { useGridColumns, useGridFormSchema } from './data';
+import AttendeeModal from './modules/attendee.vue';
+import FormModalComp from './modules/form.vue';
+
+defineOptions({ name: 'SafetyTraining' });
+
+const [FormModal, formModalApi] = useVbenModal({
+ connectedComponent: FormModalComp,
+ destroyOnClose: true,
+});
+
+const [AttendeeModalComp, attendeeModalApi] = useVbenModal({
+ connectedComponent: AttendeeModal,
+ destroyOnClose: true,
+});
+
+const [Grid, gridApi] = useVbenVxeGrid({
+ formOptions: {
+ schema: useGridFormSchema(),
+ },
+ gridOptions: {
+ columns: useGridColumns(),
+ height: 'auto',
+ keepSource: true,
+ proxyConfig: {
+ ajax: {
+ query: async ({ page }, formValues) =>
+ await getTrainingPage({
+ pageNo: page.currentPage,
+ pageSize: page.pageSize,
+ ...formValues,
+ }),
+ },
+ },
+ rowConfig: {
+ keyField: 'id',
+ isHover: true,
+ },
+ toolbarConfig: {
+ refresh: true,
+ search: true,
+ },
+ } as VxeTableGridOptions<MesSafetyTrainingApi.Training>,
+});
+
+function handleCreate() {
+ formModalApi.setData({ formType: 'create' }).open();
+}
+
+function handleEdit(row: MesSafetyTrainingApi.Training) {
+ formModalApi.setData({ formType: 'update', id: row.id }).open();
+}
+
+function handleAttendee(row: MesSafetyTrainingApi.Training) {
+ attendeeModalApi.setData({ id: row.id, title: row.title }).open();
+}
+
+function handleRefresh() {
+ gridApi.query();
+}
+
+async function handleDelete(row: MesSafetyTrainingApi.Training) {
+ await deleteTraining(row.id!);
+ message.success('鍒犻櫎鎴愬姛');
+ gridApi.query();
+}
+</script>
+
+<template>
+ <Page auto-content-height>
+ <FormModal @success="handleRefresh" />
+ <AttendeeModalComp @success="handleRefresh" />
+ <Grid>
+ <template #toolbar-tools>
+ <TableAction
+ :actions="[
+ {
+ label: '鏂板缓鍩硅',
+ type: 'primary',
+ icon: ACTION_ICON.ADD,
+ auth: ['mes:safety-training:create'],
+ onClick: handleCreate,
+ },
+ ]"
+ />
+ </template>
+
+ <template #attendee="{ row }">
+ <span>
+ {{ row.attendeeCount ?? 0 }} /
+ <span
+ :class="
+ (row.signedCount ?? 0) < (row.attendeeCount ?? 0)
+ ? 'text-orange-600'
+ : 'text-green-600'
+ "
+ >
+ {{ row.signedCount ?? 0 }}
+ </span>
+ </span>
+ </template>
+
+ <template #actions="{ row }">
+ <TableAction
+ :actions="[
+ {
+ label: '鍙傝浜哄憳',
+ type: 'link',
+ auth: ['mes:safety-training:attendee'],
+ onClick: handleAttendee.bind(null, row),
+ },
+ {
+ label: '缂栬緫',
+ type: 'link',
+ icon: ACTION_ICON.EDIT,
+ auth: ['mes:safety-training:update'],
+ onClick: handleEdit.bind(null, row),
+ },
+ {
+ label: '鍒犻櫎',
+ type: 'link',
+ danger: true,
+ icon: ACTION_ICON.DELETE,
+ auth: ['mes:safety-training:delete'],
+ popConfirm: {
+ title: '鍒犻櫎鍩硅浼氬悓鏃跺垹闄ゅ叾鍙傝璁板綍锛岀‘瀹氬垹闄ゅ悧锛�',
+ confirm: handleDelete.bind(null, row),
+ },
+ },
+ ]"
+ />
+ </template>
+ </Grid>
+ </Page>
+</template>
diff --git a/src/views/safety/training/modules/attendee.vue b/src/views/safety/training/modules/attendee.vue
new file mode 100644
index 0000000..d311c54
--- /dev/null
+++ b/src/views/safety/training/modules/attendee.vue
@@ -0,0 +1,211 @@
+<script lang="ts" setup>
+import type { MesSafetyTrainingApi } from '#/api/safety/training';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import {
+ Button,
+ InputNumber,
+ message,
+ Select,
+ Table,
+ Tag,
+} from 'ant-design-vue';
+
+import {
+ getAttendeeList,
+ saveAttendees,
+} from '#/api/safety/training';
+import { getSimpleUserList } from '../../risk/shared';
+
+const emit = defineEmits(['success']);
+
+const trainingId = ref<number>();
+const trainingTitle = ref<string>();
+const attendees = ref<MesSafetyTrainingApi.Attendee[]>([]);
+const loading = ref(false);
+const saving = ref(false);
+const userOptions = ref<Array<{ id: number; nickname: string }>>([]);
+const pickingUsers = ref<number[]>([]);
+const showPicker = ref(false);
+
+/** 绛惧埌鐘舵�侀�夐」 */
+const SIGN_OPTIONS = [
+ { label: '鏈鍒�', value: 0 },
+ { label: '宸茬鍒�', value: 1 },
+ { label: '杩熷埌', value: 2 },
+ { label: '缂哄腑', value: 3 },
+];
+
+/** 閫氳繃鐘舵�侀�夐」 */
+const PASS_OPTIONS = [
+ { label: '鏈��', value: 0 },
+ { label: '閫氳繃', value: 1 },
+ { label: '涓嶉�氳繃', value: 2 },
+];
+
+const columns = [
+ { title: '濮撳悕', dataIndex: 'userName', width: 120 },
+ { title: '绛惧埌鐘舵��', dataIndex: 'signStatus', width: 140, key: 'sign' },
+ { title: '鑰冭瘯鎴愮哗', dataIndex: 'score', width: 120, key: 'score' },
+ { title: '鏄惁閫氳繃', dataIndex: 'passed', width: 130, key: 'passed' },
+ { title: '鎿嶄綔', dataIndex: 'action', width: 80, key: 'action' },
+];
+
+const totalCount = computed(() => attendees.value.length);
+const signedCount = computed(
+ () =>
+ attendees.value.filter((a) => a.signStatus === 1 || a.signStatus === 2)
+ .length,
+);
+
+/** 宸查�変腑浣嗕笉鍦ㄥ垪琛ㄩ噷鐨勭敤鎴凤紙鐢ㄤ簬閫夋嫨鍣ㄧ鐢ㄥ凡娣诲姞椤癸級 */
+const existedUserIds = computed(() =>
+ attendees.value.map((a) => a.userId).filter(Boolean),
+);
+
+async function loadUsers() {
+ if (userOptions.value.length > 0) return;
+ const list = await getSimpleUserList();
+ userOptions.value = list.map((u) => ({ id: u.id, nickname: u.nickname }));
+}
+
+function handleOpenPicker() {
+ showPicker.value = true;
+ pickingUsers.value = [];
+ loadUsers();
+}
+
+function handleAddUsers() {
+ if (pickingUsers.value.length === 0) {
+ message.warning('璇烽�夋嫨瑕佹坊鍔犵殑浜哄憳');
+ return;
+ }
+ for (const uid of pickingUsers.value) {
+ if (existedUserIds.value.includes(uid)) continue;
+ const user = userOptions.value.find((u) => u.id === uid);
+ attendees.value.push({
+ userId: uid,
+ userName: user?.nickname,
+ signStatus: 0,
+ passed: 0,
+ });
+ }
+ showPicker.value = false;
+ pickingUsers.value = [];
+}
+
+function handleRemove(index: number) {
+ attendees.value.splice(index, 1);
+}
+
+async function handleSave() {
+ saving.value = true;
+ try {
+ await saveAttendees(trainingId.value!, attendees.value);
+ emit('success');
+ message.success('鍙傝浜哄憳宸蹭繚瀛�');
+ await modalApi.close();
+ } finally {
+ saving.value = false;
+ }
+}
+
+const [Modal, modalApi] = useVbenModal({
+ onConfirm: handleSave,
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ attendees.value = [];
+ return;
+ }
+ const data = modalApi.getData<{ id: number; title?: string }>();
+ trainingId.value = data.id;
+ trainingTitle.value = data.title;
+ loading.value = true;
+ try {
+ attendees.value = (await getAttendeeList(data.id)) ?? [];
+ } finally {
+ loading.value = false;
+ }
+ },
+});
+</script>
+
+<template>
+ <Modal :title="`鍙傝浜哄憳 - ${trainingTitle ?? ''}`" class="w-3/4">
+ <div class="mx-4 flex flex-col gap-3">
+ <div class="flex items-center justify-between">
+ <div class="text-gray-600">
+ 鍏� <b>{{ totalCount }}</b> 浜猴紝宸茬鍒�
+ <b>{{ signedCount }}</b> 浜�
+ </div>
+ <Button type="primary" @click="handleOpenPicker">娣诲姞浜哄憳</Button>
+ </div>
+
+ <div v-if="showPicker" class="flex items-center gap-2 rounded bg-gray-50 p-3">
+ <Select
+ v-model:value="pickingUsers"
+ mode="multiple"
+ class="flex-1"
+ placeholder="閫夋嫨瑕佸弬璁殑浜哄憳锛堝彲澶氶�夛級"
+ :options="
+ userOptions
+ .filter((u) => !existedUserIds.includes(u.id))
+ .map((u) => ({ label: u.nickname, value: u.id }))
+ "
+ option-filter-prop="label"
+ />
+ <Button type="primary" @click="handleAddUsers">纭畾娣诲姞</Button>
+ <Button @click="showPicker = false">鍙栨秷</Button>
+ </div>
+
+ <Table
+ :data-source="attendees"
+ :columns="columns"
+ :loading="loading"
+ :pagination="false"
+ size="small"
+ row-key="userId"
+ :scroll="{ y: 380 }"
+ >
+ <template #bodyCell="{ column, record, index }">
+ <template v-if="column.key === 'sign'">
+ <Select
+ v-model:value="record.signStatus"
+ :options="SIGN_OPTIONS"
+ class="w-full"
+ />
+ </template>
+ <template v-else-if="column.key === 'score'">
+ <InputNumber
+ v-model:value="record.score"
+ :min="0"
+ :max="100"
+ :precision="1"
+ class="w-full"
+ placeholder="鎴愮哗"
+ />
+ </template>
+ <template v-else-if="column.key === 'passed'">
+ <Select
+ v-model:value="record.passed"
+ :options="PASS_OPTIONS"
+ class="w-full"
+ />
+ </template>
+ <template v-else-if="column.key === 'action'">
+ <Tag
+ color="error"
+ class="cursor-pointer"
+ @click="handleRemove(index)"
+ >
+ 绉婚櫎
+ </Tag>
+ </template>
+ </template>
+ </Table>
+ </div>
+ </Modal>
+</template>
diff --git a/src/views/safety/training/modules/form.vue b/src/views/safety/training/modules/form.vue
new file mode 100644
index 0000000..fc02e18
--- /dev/null
+++ b/src/views/safety/training/modules/form.vue
@@ -0,0 +1,85 @@
+<script lang="ts" setup>
+import type { MesSafetyTrainingApi } from '#/api/safety/training';
+
+import { computed, ref } from 'vue';
+
+import { useVbenModal } from '@vben/common-ui';
+
+import { message } from 'ant-design-vue';
+
+import { useVbenForm } from '#/adapter/form';
+import {
+ createTraining,
+ getTraining,
+ updateTraining,
+} from '#/api/safety/training';
+
+import { useFormSchema } from '../data';
+
+const emit = defineEmits(['success']);
+
+const formType = ref<'create' | 'update'>('create');
+const isUpdate = computed(() => formType.value === 'update');
+const getTitle = computed(() =>
+ isUpdate.value ? '淇敼鍩硅鎵规' : '鏂板缓鍩硅鎵规',
+);
+
+const [Form, formApi] = useVbenForm({
+ commonConfig: {
+ componentProps: { class: 'w-full' },
+ formItemClass: 'col-span-1',
+ labelWidth: 100,
+ },
+ wrapperClass: 'grid-cols-2',
+ layout: 'horizontal',
+ schema: useFormSchema(),
+ showDefaultActions: false,
+});
+
+const [Modal, modalApi] = useVbenModal({
+ async onConfirm() {
+ const { valid } = await formApi.validate();
+ if (!valid) {
+ return;
+ }
+ modalApi.lock();
+ const data = (await formApi.getValues()) as MesSafetyTrainingApi.Training;
+ try {
+ await (isUpdate.value ? updateTraining(data) : createTraining(data));
+ emit('success');
+ message.success('鎿嶄綔鎴愬姛');
+ await modalApi.close();
+ } finally {
+ modalApi.unlock();
+ }
+ },
+ async onOpenChange(isOpen: boolean) {
+ if (!isOpen) {
+ return;
+ }
+ const data = modalApi.getData<{
+ formType: 'create' | 'update';
+ id?: number;
+ }>();
+ formType.value = data.formType;
+ await formApi.resetForm();
+ if (data?.id) {
+ modalApi.lock();
+ try {
+ const training = await getTraining(data.id);
+ await formApi.setValues(training);
+ } finally {
+ modalApi.unlock();
+ }
+ } else {
+ formApi.setFieldValue('status', 0);
+ }
+ },
+});
+</script>
+
+<template>
+ <Modal :title="getTitle" class="w-2/3">
+ <Form class="mx-4" />
+ </Modal>
+</template>
--
Gitblit v1.9.3