xiaoyi
2026-10-29 8d541eed8bb02d5a9b32b706cde9665df9a83aec
refactor(safety): 人员与车间字段改用员工选择器与车间下拉

- 安全组织架构:负责人改 HrmEmployeeSelect;删除负责人电话手填项(改由员工档案带出)
- 巡检点位:所属车间改车间下拉(getWorkshopSimpleList)、责任人改员工选择器
- 巡检记录:巡检人改员工选择器
- 隐患台账:上报人 / 分派责任人 / 复查人全部改员工选择器;
同人校验改为按 assigneeId 比较(原按姓名,同名会误判)
- 高危作业票:作业车间改车间下拉,申请人/监护人改员工选择器;
审批弹窗审批人改员工选择器,自批校验按 applicantId 比较;
监护记录监护人改员工选择器
- 应急物资:保管人改员工选择器
- 安全培训:讲师/组织人改员工选择器
- 消防演练:组织人改员工选择器
- 事故登记:所属车间改车间下拉,表格新增车间列

提交时只送 xxxId,不再送姓名快照(快照由后端从主数据带出)。

验证:vue-tsc 类型检查 0 错误;vite build 成功
已修改10个文件
371 ■■■■■ 文件已修改
src/api/safety/index.ts 48 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/accident/index.vue 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/drill/index.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/hazard/index.vue 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/inspection/point/index.vue 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/inspection/record/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/material/index.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/org/index.vue 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/permit/index.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/training/index.vue 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/safety/index.ts
@@ -12,6 +12,8 @@
    id?: number;
    parentId?: number;
    name: string;
    /** 负责人(员工编号,关联 hrm_employee) */
    leaderId?: number;
    leaderName?: string;
    leaderPhone?: string;
    memberNames?: string;
@@ -57,11 +59,15 @@
    name: string;
    pointType: string;
    pointTypeName?: string;
    /** 所属车间(关联 mes_md_workshop) */
    workshopId?: number;
    workshopName?: string;
    location?: string;
    inspectItems?: string;
    inspectCycle?: string;
    inspectCycleName?: string;
    /** 责任人(员工编号,关联 hrm_employee) */
    ownerId?: number;
    ownerName?: string;
    status: number;
    lastInspectTime?: string;
@@ -77,7 +83,9 @@
    pointType?: string;
    pointTypeName?: string;
    inspectTime: string;
    inspector: string;
    /** 巡检人(员工编号,关联 hrm_employee) */
    inspectorId?: number;
    inspector?: string;
    result?: string;
    resultName?: string;
    content?: string;
@@ -102,8 +110,12 @@
    location?: string;
    description?: string;
    photoUrls?: string;
    reporter: string;
    /** 上报人(员工编号,关联 hrm_employee) */
    reporterId?: number;
    reporter?: string;
    reportTime?: string;
    /** 整改责任人(员工编号,关联 hrm_employee) */
    assigneeId?: number;
    assignee?: string;
    assignTime?: string;
    rectifyDeadline?: string;
@@ -111,6 +123,8 @@
    rectifyDesc?: string;
    rectifyPhotoUrls?: string;
    rectifyTime?: string;
    /** 复查人(员工编号,关联 hrm_employee) */
    recheckerId?: number;
    rechecker?: string;
    recheckTime?: string;
    recheckResult?: string;
@@ -130,10 +144,14 @@
    permitType: string;
    permitTypeName?: string;
    title: string;
    /** 作业车间(关联 mes_md_workshop) */
    workshopId?: number;
    workshopName?: string;
    location?: string;
    workContent?: string;
    applicant: string;
    /** 申请人(员工编号,关联 hrm_employee) */
    applicantId?: number;
    applicant?: string;
    applyTime?: string;
    planStartTime: string;
    planEndTime: string;
@@ -142,9 +160,13 @@
    riskLevel?: string;
    riskLevelName?: string;
    safetyMeasures?: string;
    /** 审批人(员工编号,关联 hrm_employee) */
    approverId?: number;
    approver?: string;
    approveTime?: string;
    approveComment?: string;
    /** 监护人(员工编号,关联 hrm_employee) */
    guardianId?: number;
    guardian?: string;
    guardianRequired?: boolean;
    status?: string;
@@ -159,7 +181,9 @@
    id?: number;
    permitId: number;
    monitorTime?: string;
    guardian: string;
    /** 监护人(员工编号,关联 hrm_employee) */
    guardianId?: number;
    guardian?: string;
    monitorContent?: string;
    abnormal?: string;
    photoUrls?: string;
@@ -182,6 +206,8 @@
    expireDate?: string;
    expireRemainingDays?: number;
    validityMonths?: number;
    /** 保管人(员工编号,关联 hrm_employee) */
    managerId?: number;
    manager?: string;
    status: number;
    remark?: string;
@@ -215,6 +241,8 @@
    trainingTypeName?: string;
    trainDate: string;
    durationHours?: number;
    /** 讲师/组织人(员工编号,关联 hrm_employee) */
    trainerId?: number;
    trainer?: string;
    location?: string;
    participants?: string;
@@ -233,6 +261,8 @@
    drillTypeName?: string;
    drillDate: string;
    location?: string;
    /** 组织人(员工编号,关联 hrm_employee) */
    organizerId?: number;
    organizer?: string;
    participants?: string;
    participantCount?: number;
@@ -255,6 +285,8 @@
    levelName?: string;
    occurTime: string;
    location?: string;
    /** 所属车间(关联 mes_md_workshop) */
    workshopId?: number;
    workshopName?: string;
    casualtyDesc?: string;
    economicLoss?: number;
@@ -262,6 +294,8 @@
    description?: string;
    causeAnalysis?: string;
    measures?: string;
    /** 登记人(员工编号,关联 hrm_employee) */
    reporterId?: number;
    reporter?: string;
    reportTime?: string;
    status?: string;
@@ -389,7 +423,7 @@
export function deleteHazard(id: number) {
  return requestClient.delete('/safety/hazard/delete', { params: { id } });
}
export function assignHazard(data: { id: number; assignee: string; rectifyDeadline?: string; remark?: string }) {
export function assignHazard(data: { id: number; assigneeId: number; rectifyDeadline?: string; remark?: string }) {
  return requestClient.put('/safety/hazard/assign', data);
}
export function rectifyHazard(data: { id: number; rectifyDesc: string; rectifyPhotoUrls?: string }) {
@@ -397,7 +431,7 @@
}
export function recheckHazard(data: {
  id: number;
  rechecker: string;
  recheckerId: number;
  recheckResult: string;
  recheckRemark?: string;
}) {
@@ -422,7 +456,7 @@
}
export function approveWorkPermit(data: {
  id: number;
  approver: string;
  approverId: number;
  decision: string;
  approveComment?: string;
}) {
src/views/safety/accident/index.vue
@@ -25,6 +25,7 @@
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getWorkshopSimpleList } from '#/api/mes/md/workstation/workshop';
import {
  createAccident,
  deleteAccident,
@@ -46,6 +47,8 @@
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const workshopOptions = ref<{ label: string; value: number }[]>([]);
const open = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
@@ -56,14 +59,13 @@
  level: 'GENERAL',
  occurTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
  location: '',
  workshopName: '',
  workshopId: undefined,
  casualtyDesc: '',
  economicLoss: 0,
  involvedPersons: '',
  description: '',
  causeAnalysis: '',
  measures: '',
  reporter: '',
  status: 'REPORTED',
  attachment: '',
  remark: '',
@@ -84,6 +86,7 @@
  { title: '等级', dataIndex: 'level', width: 110, align: 'center' as const },
  { title: '发生时间', dataIndex: 'occurTime', width: 165 },
  { title: '发生地点', dataIndex: 'location', width: 160, ellipsis: true },
  { title: '车间', dataIndex: 'workshopName', width: 130, ellipsis: true },
  { title: '伤亡情况', dataIndex: 'casualtyDesc', width: 160, ellipsis: true },
  { title: '状态', dataIndex: 'status', width: 110, align: 'center' as const },
  { title: '操作', key: 'action', width: 150, fixed: 'right' as const },
@@ -109,6 +112,15 @@
  }
}
async function loadWorkshops() {
  try {
    const list = await getWorkshopSimpleList();
    workshopOptions.value = (list || []).map((w) => ({ label: w.name!, value: w.id! }));
  } catch {
    workshopOptions.value = [];
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
@@ -131,6 +143,9 @@
  saving.value = true;
  try {
    const data = { ...model, title: model.title.trim() };
    // 所属车间名称 / 登记人姓名为后端回填的快照字段,不参与提交
    delete data.workshopName;
    delete data.reporter;
    await (data.id ? updateAccident(data) : createAccident(data));
    message.success('事故记录已保存');
    open.value = false;
@@ -146,7 +161,10 @@
  await load();
}
onMounted(() => load());
onMounted(() => {
  void load();
  void loadWorkshops();
});
</script>
<template>
@@ -191,7 +209,7 @@
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1400 }"
        :scroll="{ x: 1530 }"
        size="middle"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
@@ -211,6 +229,9 @@
          </template>
          <template v-else-if="column.dataIndex === 'status'">
            <Tag>{{ record.statusName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'workshopName'">
            {{ record.workshopName || '-' }}
          </template>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
@@ -249,8 +270,14 @@
        <FormItem label="发生地点" name="location">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 2#高压配电室" />
        </FormItem>
        <FormItem label="所属车间/区域" name="workshopName">
          <Input v-model:value="model.workshopName" :maxlength="100" placeholder="如 电气车间" />
        <FormItem label="所属车间/区域" name="workshopId">
          <Select
            v-model:value="model.workshopId"
            :options="workshopOptions"
            show-search
            :filter-option="(i: string, o: any) => String(o.label).includes(i)"
            placeholder="请选择车间"
          />
        </FormItem>
        <FormItem label="伤亡情况" name="casualtyDesc">
          <Input v-model:value="model.casualtyDesc" :maxlength="500" placeholder="如 1 人轻伤" />
src/views/safety/drill/index.vue
@@ -31,6 +31,7 @@
  getDrillPage,
  updateDrill,
} from '#/api/safety';
import HrmEmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { DRILL_TYPE_OPTIONS } from '../shared';
@@ -50,7 +51,7 @@
  drillType: 'FIRE',
  drillDate: dayjs().format('YYYY-MM-DD'),
  location: '',
  organizer: '',
  organizerId: undefined,
  participants: '',
  participantCount: 0,
  planDesc: '',
@@ -121,6 +122,8 @@
  saving.value = true;
  try {
    const data = { ...model, title: model.title.trim() };
    // 组织人姓名为后端回填的快照字段,不参与提交
    delete data.organizer;
    await (data.id ? updateDrill(data) : createDrill(data));
    message.success('演练记录已保存');
    open.value = false;
@@ -187,6 +190,10 @@
          <template v-if="column.dataIndex === 'drillType'">
            <Tag color="orange">{{ record.drillTypeName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'organizer'">
            <span v-if="record.organizer">{{ record.organizer }}</span>
            <span v-else class="text-gray-300">-</span>
          </template>
          <template v-else-if="column.dataIndex === 'durationMinutes'">
            {{ record.durationMinutes ?? '-' }}
          </template>
@@ -224,8 +231,8 @@
        <FormItem label="演练地点" name="location">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 厂区东侧消防通道" />
        </FormItem>
        <FormItem label="组织人" name="organizer">
          <Input v-model:value="model.organizer" :maxlength="50" placeholder="请输入姓名" />
        <FormItem label="组织人" name="organizerId">
          <HrmEmployeeSelect v-model="model.organizerId" placeholder="从员工档案中选择组织人" />
        </FormItem>
        <FormItem label="时长(分钟)" name="durationMinutes">
          <InputNumber v-model:value="model.durationMinutes" :min="0" style="width: 100%" />
src/views/safety/hazard/index.vue
@@ -37,6 +37,7 @@
  updateHazard,
  uploadSafetyFiles,
} from '#/api/safety';
import HrmEmployeeSelect from '#/views/hrm/employee/components/select.vue';
import {
  HAZARD_LEVEL_COLOR,
@@ -82,7 +83,9 @@
  location: '',
  description: '',
  photoUrls: '',
  reporter: '',
  reporterId: undefined,
  // 上报人姓名为后端回填的快照字段,仅用于展示
  reporter: undefined,
  reportTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
  rectifyDeadline: undefined,
  remark: '',
@@ -92,13 +95,13 @@
const editRules = {
  title: [{ required: true, whitespace: true, message: '请输入隐患标题' }],
  reporter: [{ required: true, whitespace: true, message: '请输入上报人' }],
  reporterId: [{ required: true, message: '请选择上报人' }],
};
const assignOpen = ref(false);
const assignModel = reactive<{ id: number; assignee: string; rectifyDeadline?: string; remark?: string }>({
const assignModel = reactive<{ id: number; assigneeId?: number; rectifyDeadline?: string; remark?: string }>({
  id: 0,
  assignee: '',
  assigneeId: undefined,
});
const rectifyOpen = ref(false);
@@ -109,11 +112,14 @@
const rectifyPhotos = ref<string[]>([]);
const recheckOpen = ref(false);
const recheckModel = reactive<{ id: number; rechecker: string; recheckResult: string; recheckRemark?: string }>({
const recheckModel = reactive<{ id: number; recheckerId?: number; recheckResult: string; recheckRemark?: string }>({
  id: 0,
  rechecker: '',
  recheckerId: undefined,
  recheckResult: 'PASSED',
});
/** 当前复查的隐患,用于校验「复查人不能与整改责任人为同一人」 */
const recheckRow = ref<SafetyApi.Hazard>();
const detailOpen = ref(false);
const detailRow = ref<null | SafetyApi.Hazard>(null);
@@ -193,9 +199,10 @@
    const data = {
      ...model,
      title: model.title.trim(),
      reporter: model.reporter.trim(),
      photoUrls: photoList.value.join(','),
    };
    // 上报人姓名为只读快照,不再提交
    delete data.reporter;
    await (data.id ? updateHazard(data) : createHazard(data));
    message.success('隐患已保存');
    editOpen.value = false;
@@ -207,20 +214,25 @@
function openAssign(row: SafetyApi.Hazard) {
  assignModel.id = row.id!;
  assignModel.assignee = row.assignee ?? '';
  assignModel.assigneeId = row.assigneeId;
  assignModel.rectifyDeadline = row.rectifyDeadline;
  assignModel.remark = '';
  assignOpen.value = true;
}
async function saveAssign() {
  if (!assignModel.assignee.trim()) {
    message.warning('请填写整改责任人');
  if (!assignModel.assigneeId) {
    message.warning('请选择整改责任人');
    return;
  }
  saving.value = true;
  try {
    await assignHazard({ ...assignModel, assignee: assignModel.assignee.trim() });
    await assignHazard({
      id: assignModel.id,
      assigneeId: assignModel.assigneeId,
      rectifyDeadline: assignModel.rectifyDeadline,
      remark: assignModel.remark,
    });
    message.success('已分派');
    assignOpen.value = false;
    await load();
@@ -258,20 +270,30 @@
function openRecheck(row: SafetyApi.Hazard) {
  recheckModel.id = row.id!;
  recheckModel.rechecker = '';
  recheckModel.recheckerId = undefined;
  recheckModel.recheckResult = 'PASSED';
  recheckModel.recheckRemark = '';
  recheckRow.value = row;
  recheckOpen.value = true;
}
async function saveRecheck() {
  if (!recheckModel.rechecker.trim()) {
    message.warning('请填写复查人');
  if (!recheckModel.recheckerId) {
    message.warning('请选择复查人');
    return;
  }
  if (recheckModel.recheckerId === recheckRow.value?.assigneeId) {
    message.warning('复查人不能与整改责任人为同一人');
    return;
  }
  saving.value = true;
  try {
    await recheckHazard({ ...recheckModel, rechecker: recheckModel.rechecker.trim() });
    await recheckHazard({
      id: recheckModel.id,
      recheckerId: recheckModel.recheckerId,
      recheckResult: recheckModel.recheckResult,
      recheckRemark: recheckModel.recheckRemark,
    });
    message.success(recheckModel.recheckResult === 'PASSED' ? '复查通过,隐患已闭环' : '复查不通过,已退回整改');
    recheckOpen.value = false;
    await load();
@@ -435,8 +457,8 @@
        <FormItem label="整改期限" name="rectifyDeadline">
          <DatePicker v-model:value="model.rectifyDeadline" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
        <FormItem label="上报人" name="reporter">
          <Input v-model:value="model.reporter" :maxlength="50" placeholder="请输入姓名" />
        <FormItem label="上报人" name="reporterId">
          <HrmEmployeeSelect v-model="model.reporterId" placeholder="从员工档案中选择上报人" />
        </FormItem>
        <FormItem label="上报时间" name="reportTime">
          <DatePicker
@@ -472,7 +494,7 @@
    <Modal v-model:open="assignOpen" title="分派整改责任人" :confirm-loading="saving" ok-text="确认分派" cancel-text="取消" @ok="saveAssign">
      <Form layout="vertical" class="mt-4">
        <FormItem label="整改责任人" required>
          <Input v-model:value="assignModel.assignee" :maxlength="50" placeholder="请输入整改责任人姓名" />
          <HrmEmployeeSelect v-model="assignModel.assigneeId" placeholder="从员工档案中选择整改责任人" />
        </FormItem>
        <FormItem label="整改期限">
          <DatePicker v-model:value="assignModel.rectifyDeadline" value-format="YYYY-MM-DD" class="w-full" />
@@ -506,7 +528,8 @@
    <Modal v-model:open="recheckOpen" title="隐患复查" :confirm-loading="saving" ok-text="提交复查" cancel-text="取消" @ok="saveRecheck">
      <Form layout="vertical" class="mt-4">
        <FormItem label="复查人" required>
          <Input v-model:value="recheckModel.rechecker" :maxlength="50" placeholder="请输入复查人姓名(不能与整改责任人相同)" />
          <HrmEmployeeSelect v-model="recheckModel.recheckerId" placeholder="从员工档案中选择复查人" />
          <div class="text-muted-foreground mt-1 text-xs">复查人不能与整改责任人为同一人。</div>
        </FormItem>
        <FormItem label="复查结论" required>
          <Select v-model:value="recheckModel.recheckResult" :options="recheckOptions" />
src/views/safety/inspection/point/index.vue
@@ -23,12 +23,14 @@
  Tag,
} from 'ant-design-vue';
import { getWorkshopSimpleList } from '#/api/mes/md/workstation/workshop';
import {
  createInspectionPoint,
  deleteInspectionPoint,
  getInspectionPointPage,
  updateInspectionPoint,
} from '#/api/safety';
import HrmEmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { INSPECT_CYCLE_OPTIONS, POINT_TYPE_OPTIONS } from '../../shared';
@@ -47,16 +49,34 @@
  code: '',
  name: '',
  pointType: 'HIGH_VOLTAGE_ROOM',
  workshopName: '',
  workshopId: undefined,
  // 车间 / 责任人为后端回填的快照字段,仅用于展示
  workshopName: undefined,
  location: '',
  inspectItems: '1. 门锁与警示标识完好\n2. 绝缘工器具在有效期内\n3. 消防器材在位可用\n4. 记录本填写完整',
  inspectCycle: 'DAILY',
  ownerName: '',
  ownerId: undefined,
  ownerName: undefined,
  status: 0,
  remark: '',
});
const model = reactive<SafetyApi.InspectionPoint>(initial());
/** 车间下拉选项(来源:MES 车间主数据) */
const workshopOptions = ref<{ label: string; value: number }[]>([]);
async function loadWorkshops() {
  try {
    const list = await getWorkshopSimpleList();
    workshopOptions.value = (list || []).map((w) => ({
      label: w.name!,
      value: w.id!,
    }));
  } catch {
    message.error('加载车间列表失败');
  }
}
const rules = {
  code: [{ required: true, whitespace: true, message: '请输入点位编码' }],
@@ -118,6 +138,9 @@
  saving.value = true;
  try {
    const data = { ...model, code: model.code.trim(), name: model.name.trim() };
    // 车间名称 / 责任人姓名为只读快照,不再提交
    delete data.workshopName;
    delete data.ownerName;
    await (data.id ? updateInspectionPoint(data) : createInspectionPoint(data));
    message.success('巡检点位已保存');
    open.value = false;
@@ -133,7 +156,10 @@
  await load();
}
onMounted(() => load());
onMounted(() => {
  void loadWorkshops();
  void load();
});
</script>
<template>
@@ -271,11 +297,17 @@
        <FormItem label="巡检周期" name="inspectCycle">
          <Select v-model:value="model.inspectCycle" :options="INSPECT_CYCLE_OPTIONS" />
        </FormItem>
        <FormItem label="所属车间/区域" name="workshopName">
          <Input v-model:value="model.workshopName" :maxlength="100" placeholder="如 电气车间" />
        <FormItem label="所属车间/区域" name="workshopId">
          <Select
            v-model:value="model.workshopId"
            :options="workshopOptions"
            show-search
            :filter-option="(i: string, o: any) => String(o.label).includes(i)"
            placeholder="请选择车间"
          />
        </FormItem>
        <FormItem label="责任人" name="ownerName">
          <Input v-model:value="model.ownerName" :maxlength="50" placeholder="请输入姓名" />
        <FormItem label="责任人" name="ownerId">
          <HrmEmployeeSelect v-model="model.ownerId" placeholder="从员工档案中选择责任人" />
        </FormItem>
        <FormItem label="具体位置" name="location" class="col-span-full">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 厂区西北角 1 号配电房" />
src/views/safety/inspection/record/index.vue
@@ -34,6 +34,7 @@
  updateInspection,
  uploadSafetyFiles,
} from '#/api/safety';
import HrmEmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { INSPECTION_RESULT_OPTIONS, splitUrls } from '../../shared';
@@ -55,7 +56,9 @@
const initial = (): SafetyApi.Inspection => ({
  pointId: 0,
  inspectTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
  inspector: '',
  inspectorId: undefined,
  // 巡检人姓名为后端回填的快照字段,仅用于展示
  inspector: undefined,
  result: 'NORMAL',
  content: '',
  photoUrls: '',
@@ -68,7 +71,7 @@
const rules = {
  pointId: [{ required: true, message: '请选择巡检点位' }],
  inspectTime: [{ required: true, message: '请选择巡检时间' }],
  inspector: [{ required: true, whitespace: true, message: '请输入巡检人' }],
  inspectorId: [{ required: true, message: '请选择巡检人' }],
};
const pointOptions = computed(() =>
@@ -168,9 +171,10 @@
  try {
    const data = {
      ...model,
      inspector: model.inspector.trim(),
      photoUrls: photoList.value.join(','),
    };
    // 巡检人姓名为只读快照,不再提交
    delete data.inspector;
    await (data.id ? updateInspection(data) : createInspection(data));
    message.success('巡检记录已保存');
    open.value = false;
@@ -363,8 +367,8 @@
            class="w-full"
          />
        </FormItem>
        <FormItem label="巡检人" name="inspector">
          <Input v-model:value="model.inspector" :maxlength="50" placeholder="请输入巡检人姓名" />
        <FormItem label="巡检人" name="inspectorId">
          <HrmEmployeeSelect v-model="model.inspectorId" placeholder="从员工档案中选择巡检人" />
        </FormItem>
        <FormItem label="巡检结果" name="result">
          <Select v-model:value="model.result" :options="INSPECTION_RESULT_OPTIONS" />
src/views/safety/material/index.vue
@@ -35,6 +35,7 @@
  getMaterialStockPage,
  updateMaterial,
} from '#/api/safety';
import HrmEmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { MATERIAL_CATEGORY_COLOR, MATERIAL_CATEGORY_OPTIONS, numberText } from '../shared';
@@ -69,7 +70,7 @@
  storageLocation: '',
  expireDate: undefined,
  validityMonths: undefined,
  manager: '',
  managerId: undefined,
  status: 0,
  remark: '',
});
@@ -198,6 +199,8 @@
  saving.value = true;
  try {
    const data = { ...model, code: model.code.trim(), name: model.name.trim() };
    // 保管人姓名为后端回填的快照字段,不参与提交
    delete data.manager;
    await (data.id ? updateMaterial(data) : createMaterial(data));
    message.success('物资已保存');
    editOpen.value = false;
@@ -301,6 +304,10 @@
            <template #bodyCell="{ column, record }">
              <template v-if="column.dataIndex === 'category'">
                <Tag :color="MATERIAL_CATEGORY_COLOR[record.category ?? '']">{{ record.categoryName }}</Tag>
              </template>
              <template v-else-if="column.dataIndex === 'manager'">
                <span v-if="record.manager">{{ record.manager }}</span>
                <span v-else class="text-gray-300">-</span>
              </template>
              <template v-else-if="column.dataIndex === 'stockQty'">
                <span :class="{ 'font-semibold text-red-500': record.belowSafeStock }">
@@ -425,8 +432,8 @@
        <FormItem label="存放位置" name="storageLocation">
          <Input v-model:value="model.storageLocation" :maxlength="100" placeholder="如 安全工器具室 A 柜" />
        </FormItem>
        <FormItem label="保管人" name="manager">
          <Input v-model:value="model.manager" :maxlength="50" placeholder="请输入姓名" />
        <FormItem label="保管人" name="managerId">
          <HrmEmployeeSelect v-model="model.managerId" placeholder="从员工档案中选择保管人" />
        </FormItem>
        <FormItem :label="expireRequired ? '有效期至(必填)' : '有效期至'" name="expireDate">
          <DatePicker v-model:value="model.expireDate" value-format="YYYY-MM-DD" class="w-full" />
src/views/safety/org/index.vue
@@ -27,6 +27,7 @@
} from 'ant-design-vue';
import { createOrg, deleteOrg, getOrgTree, updateOrg } from '#/api/safety';
import HrmEmployeeSelect from '#/views/hrm/employee/components/select.vue';
defineOptions({ name: 'SafetyOrg' });
@@ -44,8 +45,10 @@
const initial = (): SafetyApi.Org => ({
  parentId: 0,
  name: '',
  leaderName: '',
  leaderPhone: '',
  leaderId: undefined,
  // 负责人姓名 / 电话为后端回填的快照字段,仅用于展示
  leaderName: undefined,
  leaderPhone: undefined,
  memberNames: '',
  duty: '',
  sort: 1,
@@ -112,6 +115,9 @@
  saving.value = true;
  try {
    const data = { ...model, name: model.name.trim() };
    // 负责人姓名 / 电话为只读快照,不再提交
    delete data.leaderName;
    delete data.leaderPhone;
    await (data.id ? updateOrg(data) : createOrg(data));
    message.success('安全组织已保存');
    open.value = false;
@@ -287,11 +293,8 @@
            placeholder="选择上级组织"
          />
        </FormItem>
        <FormItem label="负责人姓名" name="leaderName">
          <Input v-model:value="model.leaderName" :maxlength="50" placeholder="请输入姓名" />
        </FormItem>
        <FormItem label="负责人电话" name="leaderPhone">
          <Input v-model:value="model.leaderPhone" :maxlength="30" placeholder="选填" />
        <FormItem label="负责人" name="leaderId">
          <HrmEmployeeSelect v-model="model.leaderId" placeholder="从员工档案中选择负责人" />
        </FormItem>
        <FormItem label="显示顺序" name="sort">
          <InputNumber v-model:value="model.sort" :min="0" :max="9999" style="width: 100%" />
src/views/safety/permit/index.vue
@@ -29,6 +29,7 @@
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getWorkshopSimpleList } from '#/api/mes/md/workstation/workshop';
import {
  approveWorkPermit,
  cancelWorkPermit,
@@ -43,6 +44,7 @@
  updateWorkPermit,
  uploadSafetyFiles,
} from '#/api/safety';
import HrmEmployeeSelect from '#/views/hrm/employee/components/select.vue';
import {
  PERMIT_STATUS_COLOR,
@@ -60,6 +62,8 @@
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const workshopOptions = ref<{ label: string; value: number }[]>([]);
const editOpen = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
@@ -69,15 +73,15 @@
const initial = (): SafetyApi.WorkPermit => ({
  permitType: 'HOT_WORK',
  title: '',
  workshopName: '',
  workshopId: undefined,
  location: '',
  workContent: '',
  applicant: '',
  applicantId: undefined,
  planStartTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
  planEndTime: dayjs().add(4, 'hour').format('YYYY-MM-DD HH:mm:ss'),
  riskLevel: 'NORMAL',
  safetyMeasures: '',
  guardian: '',
  guardianId: undefined,
  remark: '',
});
@@ -86,7 +90,7 @@
const editRules = {
  permitType: [{ required: true, message: '请选择作业类型' }],
  title: [{ required: true, whitespace: true, message: '请输入作业名称' }],
  applicant: [{ required: true, whitespace: true, message: '请输入申请人' }],
  applicantId: [{ required: true, message: '请选择申请人' }],
  planStartTime: [{ required: true, message: '请选择计划开始时间' }],
  planEndTime: [{ required: true, message: '请选择计划结束时间' }],
};
@@ -101,14 +105,24 @@
/* 审批 */
const approveOpen = ref(false);
const approveModel = reactive({ id: 0, approver: '', decision: 'APPROVED', approveComment: '' });
const approveModel = reactive<{
  id: number;
  approverId?: number;
  decision: string;
  approveComment: string;
}>({ id: 0, approverId: undefined, decision: 'APPROVED', approveComment: '' });
/* 详情抽屉 */
const detailOpen = ref(false);
const detailRow = ref<null | SafetyApi.WorkPermit>(null);
const monitors = ref<SafetyApi.WorkPermitMonitor[]>([]);
const monitorLoading = ref(false);
const monitorForm = reactive<SafetyApi.WorkPermitMonitor>({ permitId: 0, guardian: '', monitorContent: '', abnormal: '' });
const monitorForm = reactive<SafetyApi.WorkPermitMonitor>({
  permitId: 0,
  guardianId: undefined,
  monitorContent: '',
  abnormal: '',
});
const monitorPhotos = ref<string[]>([]);
const columns = [
@@ -116,6 +130,7 @@
  { title: '作业类型', dataIndex: 'permitType', width: 130 },
  { title: '作业名称', dataIndex: 'title', minWidth: 200, ellipsis: true },
  { title: '作业地点', dataIndex: 'location', width: 160, ellipsis: true },
  { title: '作业车间', dataIndex: 'workshopName', width: 130, ellipsis: true },
  { title: '申请人', dataIndex: 'applicant', width: 100 },
  { title: '计划开始', dataIndex: 'planStartTime', width: 165 },
  { title: '计划结束', dataIndex: 'planEndTime', width: 165 },
@@ -141,6 +156,15 @@
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
async function loadWorkshops() {
  try {
    const list = await getWorkshopSimpleList();
    workshopOptions.value = (list || []).map((w) => ({ label: w.name!, value: w.id! }));
  } catch {
    workshopOptions.value = [];
  }
}
@@ -180,7 +204,7 @@
}
async function saveEdit() {
  if (guardianRequired.value && !model.guardian?.trim()) {
  if (guardianRequired.value && !model.guardianId) {
    message.warning('动火 / 高压带电 / 有限空间作业必须指定监护人');
    return;
  }
@@ -190,9 +214,13 @@
    const data = {
      ...model,
      title: model.title.trim(),
      applicant: model.applicant.trim(),
      photoUrls: photoList.value.join(','),
    };
    // 申请人 / 审批人 / 监护人 / 车间名称均为后端回填的快照字段,不参与提交
    delete data.applicant;
    delete data.approver;
    delete data.guardian;
    delete data.workshopName;
    await (data.id ? updateWorkPermit(data) : createWorkPermit(data));
    message.success(data.id ? '作业票已更新' : '作业票已提交,等待审批');
    editOpen.value = false;
@@ -204,21 +232,33 @@
/* ---------- 审批 ---------- */
function openApprove(row: SafetyApi.WorkPermit) {
  // 记录审批目标行,用于校验“申请人不能审批自己的作业票”
  detailRow.value = row;
  approveModel.id = row.id!;
  approveModel.approver = '';
  approveModel.approverId = undefined;
  approveModel.decision = 'APPROVED';
  approveModel.approveComment = '';
  approveOpen.value = true;
}
async function saveApprove() {
  if (!approveModel.approver.trim()) {
    message.warning('请填写审批人');
  const { approverId } = approveModel;
  if (!approverId) {
    message.warning('请选择审批人');
    return;
  }
  if (approverId === detailRow.value?.applicantId) {
    message.warning('申请人不能审批自己的作业票');
    return;
  }
  saving.value = true;
  try {
    await approveWorkPermit({ ...approveModel, approver: approveModel.approver.trim() });
    await approveWorkPermit({
      id: approveModel.id,
      approverId,
      decision: approveModel.decision,
      approveComment: approveModel.approveComment,
    });
    message.success(approveModel.decision === 'APPROVED' ? '已批准' : '已驳回');
    approveOpen.value = false;
    await load();
@@ -251,7 +291,7 @@
  detailRow.value = row;
  detailOpen.value = true;
  monitorForm.permitId = row.id!;
  monitorForm.guardian = row.guardian ?? '';
  monitorForm.guardianId = row.guardianId;
  monitorForm.monitorContent = '';
  monitorForm.abnormal = '';
  monitorPhotos.value = [];
@@ -286,15 +326,15 @@
}
async function saveMonitor() {
  if (!monitorForm.guardian.trim()) {
    message.warning('请填写监护人');
  if (!monitorForm.guardianId) {
    message.warning('请选择监护人');
    return;
  }
  saving.value = true;
  try {
    await createMonitor({
      ...monitorForm,
      guardian: monitorForm.guardian.trim(),
      guardianId: monitorForm.guardianId,
      monitorTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
      photoUrls: monitorPhotos.value.join(','),
    });
@@ -320,7 +360,10 @@
  await load();
}
onMounted(() => load());
onMounted(() => {
  void load();
  void loadWorkshops();
});
</script>
<template>
@@ -365,7 +408,7 @@
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1750 }"
        :scroll="{ x: 1900 }"
        size="middle"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
@@ -382,6 +425,10 @@
          </template>
          <template v-else-if="column.dataIndex === 'riskLevel'">
            <Tag :color="RISK_LEVEL_COLOR[record.riskLevel ?? '']">{{ record.riskLevelName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'applicant'">
            <span v-if="record.applicant">{{ record.applicant }}</span>
            <span v-else class="text-gray-300">-</span>
          </template>
          <template v-else-if="column.dataIndex === 'guardian'">
            <span v-if="record.guardian">{{ record.guardian }}</span>
@@ -457,8 +504,14 @@
        <FormItem label="作业名称" name="title" class="col-span-full">
          <Input v-model:value="model.title" :maxlength="200" placeholder="如 2#主变 10kV 开关柜检修动火" />
        </FormItem>
        <FormItem label="作业区域/车间" name="workshopName">
          <Input v-model:value="model.workshopName" :maxlength="100" placeholder="如 电气车间" />
        <FormItem label="作业区域/车间" name="workshopId">
          <Select
            v-model:value="model.workshopId"
            :options="workshopOptions"
            show-search
            :filter-option="(i: string, o: any) => String(o.label).includes(i)"
            placeholder="请选择车间"
          />
        </FormItem>
        <FormItem label="作业地点" name="location">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 2#高压配电室" />
@@ -469,11 +522,11 @@
        <FormItem label="计划结束时间" name="planEndTime">
          <DatePicker v-model:value="model.planEndTime" show-time format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" class="w-full" />
        </FormItem>
        <FormItem label="申请人" name="applicant">
          <Input v-model:value="model.applicant" :maxlength="50" placeholder="请输入姓名" />
        <FormItem label="申请人" name="applicantId">
          <HrmEmployeeSelect v-model="model.applicantId" placeholder="从员工档案中选择申请人" />
        </FormItem>
        <FormItem label="监护人" name="guardian">
          <Input v-model:value="model.guardian" :maxlength="50" :placeholder="guardianRequired ? '动火/高压带电/有限空间必填' : '选填'" />
        <FormItem label="监护人" name="guardianId">
          <HrmEmployeeSelect v-model="model.guardianId" :placeholder="guardianRequired ? '动火/高压带电/有限空间必选' : '选填'" />
        </FormItem>
        <FormItem label="作业内容与范围" name="workContent" class="col-span-full">
          <Input.TextArea v-model:value="model.workContent" :maxlength="2000" :rows="3" show-count placeholder="说明作业内容、涉及设备与作业范围" />
@@ -502,7 +555,7 @@
    <Modal v-model:open="approveOpen" title="作业票审批" :confirm-loading="saving" ok-text="提交审批" cancel-text="取消" @ok="saveApprove">
      <Form layout="vertical" class="mt-4">
        <FormItem label="审批人" required>
          <Input v-model:value="approveModel.approver" :maxlength="50" placeholder="请输入审批人姓名(不能与申请人相同)" />
          <HrmEmployeeSelect v-model="approveModel.approverId" placeholder="从员工档案中选择审批人(不能与申请人相同)" />
        </FormItem>
        <FormItem label="审批结论" required>
          <Select
@@ -535,7 +588,7 @@
              <Descriptions.Item label="作业名称" :span="2">{{ detailRow.title }}</Descriptions.Item>
              <Descriptions.Item label="作业区域">{{ detailRow.workshopName || '-' }}</Descriptions.Item>
              <Descriptions.Item label="作业地点">{{ detailRow.location || '-' }}</Descriptions.Item>
              <Descriptions.Item label="申请人">{{ detailRow.applicant }}</Descriptions.Item>
              <Descriptions.Item label="申请人">{{ detailRow.applicant || '-' }}</Descriptions.Item>
              <Descriptions.Item label="申请时间">{{ detailRow.applyTime ? String(detailRow.applyTime).replace('T', ' ') : '-' }}</Descriptions.Item>
              <Descriptions.Item label="计划开始">{{ detailRow.planStartTime ? String(detailRow.planStartTime).replace('T', ' ') : '-' }}</Descriptions.Item>
              <Descriptions.Item label="计划结束">{{ detailRow.planEndTime ? String(detailRow.planEndTime).replace('T', ' ') : '-' }}</Descriptions.Item>
@@ -561,7 +614,7 @@
              <div class="mb-2 text-sm font-semibold">添加监护记录</div>
              <Form layout="vertical">
                <FormItem label="监护人">
                  <Input v-model:value="monitorForm.guardian" :maxlength="50" placeholder="姓名" />
                  <HrmEmployeeSelect v-model="monitorForm.guardianId" placeholder="从员工档案中选择监护人" />
                </FormItem>
                <FormItem label="监护内容与情况">
                  <Input.TextArea v-model:value="monitorForm.monitorContent" :maxlength="1000" :rows="2" placeholder="记录监护时的现场情况" />
src/views/safety/training/index.vue
@@ -31,6 +31,7 @@
  getTrainingPage,
  updateTraining,
} from '#/api/safety';
import HrmEmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { TRAINING_TYPE_OPTIONS } from '../shared';
@@ -50,7 +51,7 @@
  trainingType: 'SAFETY',
  trainDate: dayjs().format('YYYY-MM-DD'),
  durationHours: 2,
  trainer: '',
  trainerId: undefined,
  location: '',
  participants: '',
  participantCount: 0,
@@ -120,6 +121,8 @@
  saving.value = true;
  try {
    const data = { ...model, title: model.title.trim() };
    // 讲师/组织人姓名为后端回填的快照字段,不参与提交
    delete data.trainer;
    await (data.id ? updateTraining(data) : createTraining(data));
    message.success('培训记录已保存');
    open.value = false;
@@ -186,6 +189,10 @@
          <template v-if="column.dataIndex === 'trainingType'">
            <Tag color="blue">{{ record.trainingTypeName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'trainer'">
            <span v-if="record.trainer">{{ record.trainer }}</span>
            <span v-else class="text-gray-300">-</span>
          </template>
          <template v-else-if="column.dataIndex === 'durationHours'">
            {{ record.durationHours ?? '-' }}
          </template>
@@ -226,8 +233,8 @@
        <FormItem label="培训地点" name="location">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 三楼培训室" />
        </FormItem>
        <FormItem label="讲师/组织人" name="trainer">
          <Input v-model:value="model.trainer" :maxlength="50" placeholder="请输入姓名" />
        <FormItem label="讲师/组织人" name="trainerId">
          <HrmEmployeeSelect v-model="model.trainerId" placeholder="从员工档案中选择讲师/组织人" />
        </FormItem>
        <FormItem label="参加人数" name="participantCount">
          <InputNumber v-model:value="model.participantCount" :min="0" style="width: 100%" placeholder="留空按名单自动统计" />