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