tuqin
6 天以前 883d0d8e02e9272d37531f1645cbbb84a1f886bd
feat(safety): 安全生产五期 - 安全教育培训前端

配套后端培训与证书接口。

新增:
- api/safety/training、api/safety/certificate
- views/safety/training:培训列表 + 培训表单 + 参训人员管理弹窗
* 列表用「参训/实到」形式展示,实到少于应到用橙色警示,到齐用绿色
* 参训弹窗支持多选批量添加人员、逐行设置签到状态与考试成绩、
顶部实时显示总人数与已签到人数
- views/safety/certificate:资格证书列表 + 表单
* 有效期三种状态展示:长期有效(灰)、30天内到期(橙+剩余天数)、
已过期(红+标签),便于提前安排复审换证
- dict-enum.ts 补 5 个字典常量

注:本期只提交代码,未部署。
已添加9个文件
已修改1个文件
1289 ■■■■■ 文件已修改
src/api/safety/certificate/index.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/safety/training/index.ts 117 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/certificate/data.ts 175 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/certificate/index.vue 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/certificate/modules/form.vue 88 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/training/data.ts 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/training/index.vue 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/training/modules/attendee.vue 211 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/training/modules/form.vue 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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}`,
  );
}
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}`,
  );
}
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 - ä»“储管理模块 ========== */
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' },
    },
  ];
}
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>
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>
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' },
    },
  ];
}
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>
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>
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>