lead
5 天以前 d5b9c28ca9914e8ef396bd2ce0092b264469f3f5
feat(safety): 移植安全生产模块前端页面

来源:dev_pro_长辉电力 分支,14 个文件。

页面(src/views/safety/):
- org 安全组织架构、cert 特种作业证、inspection/point 巡检点位、
inspection/record 巡检记录、hazard 隐患闭环、permit 高危作业票、
material 应急物资台账、material/stock 出入库流水、
training 安全培训、drill 消防演练、accident 事故登记、ledger 标准化台账
- shared.ts:枚举选项、状态颜色、数字格式化等公共常量

API(src/api/safety/index.ts):
- 对接后端 /safety/** 全部接口,类型定义与后端 VO 一致

康意适配:
- 员工选择组件路径改为康意已有的 hrm/employee/components/select.vue
(长辉为 select-dropdown.vue,康意没有该文件;两者 modelValue/placeholder
接口一致,均为弹窗选择)
- 车间下拉 API 路径改为 #/api/mes/md/workstation/workshop/index
(康意为目录形式,长辉为 workshop.ts)

验证:
- pnpm vite build --mode production 构建通过,产出 dist
- 已部署到 /data/vue/mom/kyzn/dist(nginx 9008),
js/safety-BY74d3hE.js、index.html、/safety/org 路由均返回 200
- 菜单接口返回的 13 个 component 路径与页面文件一一对应
已添加14个文件
5262 ■■■■■ 文件已修改
src/api/safety/index.ts 585 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/accident/index.vue 318 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/cert/index.vue 374 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/drill/index.vue 270 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/hazard/index.vue 604 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/inspection/point/index.vue 356 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/inspection/record/index.vue 452 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/ledger/index.vue 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/material/index.vue 480 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/material/stock/index.vue 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/org/index.vue 332 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/permit/index.vue 682 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/shared.ts 246 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/safety/training/index.vue 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/safety/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,585 @@
import type { PageParam, PageResult } from '@vben/request';
import { requestClient } from '#/api/request';
/** å®‰å…¨ç”Ÿäº§ - é€šç”¨åˆ†é¡µå‚æ•° */
type PageQuery = PageParam & Record<string, any>;
/** å®‰å…¨ç”Ÿäº§ API å‘½åç©ºé—´ */
export namespace SafetyApi {
  // ==================== å®‰å…¨ç»„织架构 ====================
  export interface Org {
    id?: number;
    parentId?: number;
    name: string;
    /** è´Ÿè´£äººï¼ˆå‘˜å·¥ç¼–号,关联 hrm_employee) */
    leaderId?: number;
    leaderName?: string;
    leaderPhone?: string;
    memberNames?: string;
    duty?: string;
    sort: number;
    status: number;
    remark?: string;
    createTime?: string;
    children?: Org[];
  }
  // ==================== ç‰¹ç§ä½œä¸šè¯ ====================
  export interface Certificate {
    id?: number;
    /** æŒè¯äººï¼ˆå‘˜å·¥ç¼–号,关联 hrm_employee) */
    employeeId?: number;
    /** æŒè¯äººå…³è”的系统用户编号 */
    userId?: number;
    employeeName?: string;
    employeeNo?: string;
    deptName?: string;
    phone?: string;
    certType: string;
    certTypeName?: string;
    certNo: string;
    issuer?: string;
    issueDate?: string;
    expireDate: string;
    reviewDate?: string;
    warnDays?: number;
    status?: string;
    statusName?: string;
    remainingDays?: number;
    attachment?: string;
    remark?: string;
    createTime?: string;
  }
  // ==================== å·¡æ£€ç‚¹ä½ ====================
  export interface InspectionPoint {
    id?: number;
    code: string;
    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;
    remark?: string;
    createTime?: string;
  }
  // ==================== å·¡æ£€è®°å½• ====================
  export interface Inspection {
    id?: number;
    pointId: number;
    pointName?: string;
    pointType?: string;
    pointTypeName?: string;
    inspectTime: string;
    /** å·¡æ£€äººï¼ˆå‘˜å·¥ç¼–号,关联 hrm_employee) */
    inspectorId?: number;
    inspector?: string;
    result?: string;
    resultName?: string;
    content?: string;
    photoUrls?: string;
    hazardCount?: number;
    remark?: string;
    createTime?: string;
  }
  // ==================== é𐿂£ ====================
  export interface Hazard {
    id?: number;
    code?: string;
    title: string;
    level?: string;
    levelName?: string;
    source?: string;
    sourceName?: string;
    inspectionId?: number;
    pointId?: number;
    pointName?: string;
    location?: string;
    description?: string;
    photoUrls?: string;
    /** ä¸ŠæŠ¥äººï¼ˆå‘˜å·¥ç¼–号,关联 hrm_employee) */
    reporterId?: number;
    reporter?: string;
    reportTime?: string;
    /** æ•´æ”¹è´£ä»»äººï¼ˆå‘˜å·¥ç¼–号,关联 hrm_employee) */
    assigneeId?: number;
    assignee?: string;
    assignTime?: string;
    rectifyDeadline?: string;
    overdue?: boolean;
    rectifyDesc?: string;
    rectifyPhotoUrls?: string;
    rectifyTime?: string;
    /** å¤æŸ¥äººï¼ˆå‘˜å·¥ç¼–号,关联 hrm_employee) */
    recheckerId?: number;
    rechecker?: string;
    recheckTime?: string;
    recheckResult?: string;
    recheckResultName?: string;
    recheckRemark?: string;
    status?: string;
    statusName?: string;
    closeTime?: string;
    remark?: string;
    createTime?: string;
  }
  // ==================== é«˜å±ä½œä¸šç¥¨ ====================
  export interface WorkPermit {
    id?: number;
    code?: string;
    permitType: string;
    permitTypeName?: string;
    title: string;
    /** ä½œä¸šè½¦é—´ï¼ˆå…³è” mes_md_workshop) */
    workshopId?: number;
    workshopName?: string;
    location?: string;
    workContent?: string;
    /** ç”³è¯·äººï¼ˆå‘˜å·¥ç¼–号,关联 hrm_employee) */
    applicantId?: number;
    applicant?: string;
    applyTime?: string;
    planStartTime: string;
    planEndTime: string;
    actualStartTime?: string;
    actualEndTime?: string;
    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;
    statusName?: string;
    attachment?: string;
    photoUrls?: string;
    remark?: string;
    createTime?: string;
  }
  export interface WorkPermitMonitor {
    id?: number;
    permitId: number;
    monitorTime?: string;
    /** ç›‘护人(员工编号,关联 hrm_employee) */
    guardianId?: number;
    guardian?: string;
    monitorContent?: string;
    abnormal?: string;
    photoUrls?: string;
    createTime?: string;
  }
  // ==================== åº”急物资 ====================
  export interface Material {
    id?: number;
    code: string;
    name: string;
    category: string;
    categoryName?: string;
    spec?: string;
    unit?: string;
    stockQty?: number;
    safeStock?: number;
    belowSafeStock?: boolean;
    storageLocation?: string;
    expireDate?: string;
    expireRemainingDays?: number;
    validityMonths?: number;
    /** ä¿ç®¡äººï¼ˆå‘˜å·¥ç¼–号,关联 hrm_employee) */
    managerId?: number;
    manager?: string;
    status: number;
    remark?: string;
    createTime?: string;
  }
  export interface MaterialStock {
    id?: number;
    materialId: number;
    materialName?: string;
    category?: string;
    categoryName?: string;
    bizType: string;
    bizTypeName?: string;
    quantity: number;
    stockBefore?: number;
    stockAfter?: number;
    bizDate?: string;
    operator: string;
    receiver?: string;
    purpose?: string;
    remark?: string;
    createTime?: string;
  }
  // ==================== åŸ¹è®­ / æ¼”练 / äº‹æ•… ====================
  export interface Training {
    id?: number;
    title: string;
    trainingType?: string;
    trainingTypeName?: string;
    trainDate: string;
    durationHours?: number;
    /** è®²å¸ˆ/组织人(员工编号,关联 hrm_employee) */
    trainerId?: number;
    trainer?: string;
    location?: string;
    participants?: string;
    participantCount?: number;
    content?: string;
    examResult?: string;
    attachment?: string;
    remark?: string;
    createTime?: string;
  }
  export interface Drill {
    id?: number;
    title: string;
    drillType?: string;
    drillTypeName?: string;
    drillDate: string;
    location?: string;
    /** ç»„织人(员工编号,关联 hrm_employee) */
    organizerId?: number;
    organizer?: string;
    participants?: string;
    participantCount?: number;
    planDesc?: string;
    processDesc?: string;
    summary?: string;
    durationMinutes?: number;
    attachment?: string;
    remark?: string;
    createTime?: string;
  }
  export interface Accident {
    id?: number;
    code?: string;
    title: string;
    accidentType: string;
    accidentTypeName?: string;
    level?: string;
    levelName?: string;
    occurTime: string;
    location?: string;
    /** æ‰€å±žè½¦é—´ï¼ˆå…³è” mes_md_workshop) */
    workshopId?: number;
    workshopName?: string;
    casualtyDesc?: string;
    economicLoss?: number;
    involvedPersons?: string;
    description?: string;
    causeAnalysis?: string;
    measures?: string;
    /** ç™»è®°äººï¼ˆå‘˜å·¥ç¼–号,关联 hrm_employee) */
    reporterId?: number;
    reporter?: string;
    reportTime?: string;
    status?: string;
    statusName?: string;
    attachment?: string;
    remark?: string;
    createTime?: string;
  }
  // ==================== æ€»è§ˆ ====================
  export interface MonthlyCount {
    month: string;
    count: number;
  }
  export interface Overview {
    certTotal: number;
    certExpiring: number;
    certExpired: number;
    hazardTotal: number;
    hazardOpen: number;
    hazardOverdue: number;
    hazardCloseRate: number;
    permitPending: number;
    permitInProgress: number;
    materialBelowSafeStock: number;
    materialExpiring: number;
    trainingThisMonth: number;
    drillThisMonth: number;
    accidentThisMonth: number;
    hazardTrend: MonthlyCount[];
    accidentTrend: MonthlyCount[];
  }
}
// ==================== å®‰å…¨ç»„织架构 ====================
export function getOrgTree() {
  return requestClient.get<SafetyApi.Org[]>('/safety/org/tree');
}
export function getOrg(id: number) {
  return requestClient.get<SafetyApi.Org>('/safety/org/get', { params: { id } });
}
export function createOrg(data: SafetyApi.Org) {
  return requestClient.post<number>('/safety/org/create', data);
}
export function updateOrg(data: SafetyApi.Org) {
  return requestClient.put('/safety/org/update', data);
}
export function deleteOrg(id: number) {
  return requestClient.delete('/safety/org/delete', { params: { id } });
}
// ==================== ç‰¹ç§ä½œä¸šè¯ ====================
export function getCertificatePage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.Certificate>>('/safety/certificate/page', { params });
}
export function getCertificate(id: number) {
  return requestClient.get<SafetyApi.Certificate>('/safety/certificate/get', { params: { id } });
}
export function createCertificate(data: SafetyApi.Certificate) {
  return requestClient.post<number>('/safety/certificate/create', data);
}
export function updateCertificate(data: SafetyApi.Certificate) {
  return requestClient.put('/safety/certificate/update', data);
}
export function deleteCertificate(id: number) {
  return requestClient.delete('/safety/certificate/delete', { params: { id } });
}
export function getExpiringCertificates(days?: number) {
  return requestClient.get<SafetyApi.Certificate[]>('/safety/certificate/expiring-list', {
    params: { days },
  });
}
// ==================== å·¡æ£€ç‚¹ä½ ====================
export function getInspectionPointPage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.InspectionPoint>>('/safety/inspection/point/page', { params });
}
export function getInspectionPoint(id: number) {
  return requestClient.get<SafetyApi.InspectionPoint>('/safety/inspection/point/get', { params: { id } });
}
export function getInspectionPointSimpleList() {
  return requestClient.get<SafetyApi.InspectionPoint[]>('/safety/inspection/point/simple-list');
}
export function createInspectionPoint(data: SafetyApi.InspectionPoint) {
  return requestClient.post<number>('/safety/inspection/point/create', data);
}
export function updateInspectionPoint(data: SafetyApi.InspectionPoint) {
  return requestClient.put('/safety/inspection/point/update', data);
}
export function deleteInspectionPoint(id: number) {
  return requestClient.delete('/safety/inspection/point/delete', { params: { id } });
}
// ==================== å·¡æ£€è®°å½• ====================
export function getInspectionPage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.Inspection>>('/safety/inspection/record/page', { params });
}
export function getInspection(id: number) {
  return requestClient.get<SafetyApi.Inspection>('/safety/inspection/record/get', { params: { id } });
}
export function createInspection(data: SafetyApi.Inspection) {
  return requestClient.post<number>('/safety/inspection/record/create', data);
}
export function updateInspection(data: SafetyApi.Inspection) {
  return requestClient.put('/safety/inspection/record/update', data);
}
export function deleteInspection(id: number) {
  return requestClient.delete('/safety/inspection/record/delete', { params: { id } });
}
// ==================== é𐿂£ ====================
export function getHazardPage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.Hazard>>('/safety/hazard/page', { params });
}
export function getHazard(id: number) {
  return requestClient.get<SafetyApi.Hazard>('/safety/hazard/get', { params: { id } });
}
export function createHazard(data: SafetyApi.Hazard) {
  return requestClient.post<number>('/safety/hazard/create', data);
}
export function updateHazard(data: SafetyApi.Hazard) {
  return requestClient.put('/safety/hazard/update', data);
}
export function deleteHazard(id: number) {
  return requestClient.delete('/safety/hazard/delete', { params: { id } });
}
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 }) {
  return requestClient.put('/safety/hazard/rectify', data);
}
export function recheckHazard(data: {
  id: number;
  recheckerId: number;
  recheckResult: string;
  recheckRemark?: string;
}) {
  return requestClient.put('/safety/hazard/recheck', data);
}
// ==================== é«˜å±ä½œä¸šç¥¨ ====================
export function getWorkPermitPage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.WorkPermit>>('/safety/permit/page', { params });
}
export function getWorkPermit(id: number) {
  return requestClient.get<SafetyApi.WorkPermit>('/safety/permit/get', { params: { id } });
}
export function createWorkPermit(data: SafetyApi.WorkPermit) {
  return requestClient.post<number>('/safety/permit/create', data);
}
export function updateWorkPermit(data: SafetyApi.WorkPermit) {
  return requestClient.put('/safety/permit/update', data);
}
export function deleteWorkPermit(id: number) {
  return requestClient.delete('/safety/permit/delete', { params: { id } });
}
export function approveWorkPermit(data: {
  id: number;
  approverId: number;
  decision: string;
  approveComment?: string;
}) {
  return requestClient.put('/safety/permit/approve', data);
}
export function startWorkPermit(id: number) {
  return requestClient.put('/safety/permit/start', null, { params: { id } });
}
export function closeWorkPermit(id: number) {
  return requestClient.put('/safety/permit/close', null, { params: { id } });
}
export function cancelWorkPermit(id: number) {
  return requestClient.put('/safety/permit/cancel', null, { params: { id } });
}
export function getMonitorList(permitId: number) {
  return requestClient.get<SafetyApi.WorkPermitMonitor[]>('/safety/permit/monitor/list', {
    params: { permitId },
  });
}
export function createMonitor(data: SafetyApi.WorkPermitMonitor) {
  return requestClient.post<number>('/safety/permit/monitor/create', data);
}
export function deleteMonitor(id: number) {
  return requestClient.delete('/safety/permit/monitor/delete', { params: { id } });
}
// ==================== åº”急物资 ====================
export function getMaterialPage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.Material>>('/safety/material/page', { params });
}
export function getMaterial(id: number) {
  return requestClient.get<SafetyApi.Material>('/safety/material/get', { params: { id } });
}
export function createMaterial(data: SafetyApi.Material) {
  return requestClient.post<number>('/safety/material/create', data);
}
export function updateMaterial(data: SafetyApi.Material) {
  return requestClient.put('/safety/material/update', data);
}
export function deleteMaterial(id: number) {
  return requestClient.delete('/safety/material/delete', { params: { id } });
}
export function getBelowSafeStockMaterials() {
  return requestClient.get<SafetyApi.Material[]>('/safety/material/below-safe-stock');
}
export function getExpiringMaterials(days?: number) {
  return requestClient.get<SafetyApi.Material[]>('/safety/material/expiring-list', { params: { days } });
}
export function getMaterialStockPage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.MaterialStock>>('/safety/material/stock/page', { params });
}
export function createMaterialStock(data: SafetyApi.MaterialStock) {
  return requestClient.post<number>('/safety/material/stock/create', data);
}
// ==================== åŸ¹è®­ / æ¼”练 / äº‹æ•… ====================
export function getTrainingPage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.Training>>('/safety/training/page', { params });
}
export function getTraining(id: number) {
  return requestClient.get<SafetyApi.Training>('/safety/training/get', { params: { id } });
}
export function createTraining(data: SafetyApi.Training) {
  return requestClient.post<number>('/safety/training/create', data);
}
export function updateTraining(data: SafetyApi.Training) {
  return requestClient.put('/safety/training/update', data);
}
export function deleteTraining(id: number) {
  return requestClient.delete('/safety/training/delete', { params: { id } });
}
export function getDrillPage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.Drill>>('/safety/training/drill/page', { params });
}
export function getDrill(id: number) {
  return requestClient.get<SafetyApi.Drill>('/safety/training/drill/get', { params: { id } });
}
export function createDrill(data: SafetyApi.Drill) {
  return requestClient.post<number>('/safety/training/drill/create', data);
}
export function updateDrill(data: SafetyApi.Drill) {
  return requestClient.put('/safety/training/drill/update', data);
}
export function deleteDrill(id: number) {
  return requestClient.delete('/safety/training/drill/delete', { params: { id } });
}
export function getAccidentPage(params: PageQuery) {
  return requestClient.get<PageResult<SafetyApi.Accident>>('/safety/training/accident/page', { params });
}
export function getAccident(id: number) {
  return requestClient.get<SafetyApi.Accident>('/safety/training/accident/get', { params: { id } });
}
export function createAccident(data: SafetyApi.Accident) {
  return requestClient.post<number>('/safety/training/accident/create', data);
}
export function updateAccident(data: SafetyApi.Accident) {
  return requestClient.put('/safety/training/accident/update', data);
}
export function deleteAccident(id: number) {
  return requestClient.delete('/safety/training/accident/delete', { params: { id } });
}
// ==================== å°è´¦ä¸Žæ€»è§ˆ ====================
export function getSafetyOverview() {
  return requestClient.get<SafetyApi.Overview>('/safety/ledger/overview');
}
export function exportSafetyLedger(params: { type?: string; startDate?: string; endDate?: string }) {
  return requestClient.download('/safety/ledger/export', { params });
}
// ==================== ç§»åŠ¨ç«¯æ‹ç…§ä¸Šä¼  ====================
// è¯´æ˜Žï¼šrequestClient.upload å¯¹æ•°ç»„字段会展开成 files[0]、files[1],
// è€ŒåŽç«¯æ‰¹é‡æŽ¥å£ç”¨ @RequestPart("files") List<MultipartFile>,因此走 FormData ç›´ä¼ ã€‚
export function uploadSafetyFile(file: File, bizType = 'safety') {
  return requestClient.upload<string>('/safety/file/upload', { file, bizType });
}
export function uploadSafetyFiles(files: File[], bizType = 'safety') {
  const formData = new FormData();
  files.forEach((file) => formData.append('files', file));
  if (bizType) formData.append('bizType', bizType);
  return requestClient.post<string[]>('/safety/file/upload-batch', formData, {
    headers: { 'Content-Type': 'multipart/form-data' },
  });
}
src/views/safety/accident/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,318 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Card,
  DatePicker,
  Form,
  FormItem,
  Input,
  InputNumber,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Table,
  Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getWorkshopSimpleList } from '#/api/mes/md/workstation/workshop/index';
import {
  createAccident,
  deleteAccident,
  getAccidentPage,
  updateAccident,
} from '#/api/safety';
import {
  ACCIDENT_LEVEL_COLOR,
  ACCIDENT_LEVEL_OPTIONS,
  ACCIDENT_STATUS_OPTIONS,
  ACCIDENT_TYPE_OPTIONS,
} from '../shared';
defineOptions({ name: 'SafetyAccident' });
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.Accident[]>([]);
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>();
const initial = (): SafetyApi.Accident => ({
  title: '',
  accidentType: 'ELECTRIC_SHOCK',
  level: 'GENERAL',
  occurTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
  location: '',
  workshopId: undefined,
  casualtyDesc: '',
  economicLoss: 0,
  involvedPersons: '',
  description: '',
  causeAnalysis: '',
  measures: '',
  status: 'REPORTED',
  attachment: '',
  remark: '',
});
const model = reactive<SafetyApi.Accident>(initial());
const rules = {
  title: [{ required: true, whitespace: true, message: '请输入事故名称' }],
  accidentType: [{ required: true, message: '请选择事故类型' }],
  occurTime: [{ required: true, message: '请选择发生时间' }],
};
const columns = [
  { title: '事故编号', dataIndex: 'code', width: 165, fixed: 'left' as const },
  { title: '事故名称', dataIndex: 'title', minWidth: 220, ellipsis: true },
  { title: '类型', dataIndex: 'accidentType', width: 120 },
  { 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 },
];
async function load(reset = false) {
  if (reset) pagination.current = 1;
  loading.value = true;
  try {
    const params: any = { ...filters, pageNo: pagination.current, pageSize: pagination.pageSize };
    if (params.occurTimeRange?.length === 2) {
      params.occurTime = params.occurTimeRange;
      delete params.occurTimeRange;
    }
    const data = await getAccidentPage(params);
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    message.error('加载事故记录失败');
    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 = [];
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: any) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
function showForm(row?: SafetyApi.Accident) {
  Object.assign(model, initial(), row ?? {});
  open.value = true;
  formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate();
  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;
    await load();
  } finally {
    saving.value = false;
  }
}
async function remove(row: SafetyApi.Accident) {
  await deleteAccident(row.id!);
    message.success('已删除');
  await load();
}
onMounted(() => {
  void load();
  void loadWorkshops();
});
</script>
<template>
  <Page
    title="事故登记"
    description="电力行业安全事故的登记、调查、处理与结案归档(触电、火灾、高处坠落等)。"
    auto-content-height
  >
    <Card>
      <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
        <FormItem label="发生时间">
          <DatePicker.RangePicker v-model:value="filters.occurTimeRange" show-time format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" />
        </FormItem>
        <FormItem label="名称">
          <Input v-model:value="filters.title" allow-clear placeholder="事故名称" style="width: 170px" />
        </FormItem>
        <FormItem label="类型">
          <Select v-model:value="filters.accidentType" :options="ACCIDENT_TYPE_OPTIONS" allow-clear placeholder="全部" style="width: 130px" />
        </FormItem>
        <FormItem label="状态">
          <Select v-model:value="filters.status" :options="ACCIDENT_STATUS_OPTIONS" allow-clear placeholder="全部" style="width: 120px" />
        </FormItem>
        <FormItem>
          <Space>
            <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
            <Button @click="resetFilters">重置</Button>
          </Space>
        </FormItem>
      </Form>
      <div class="mb-4 flex items-center justify-between">
        <span class="text-base font-semibold">事故记录</span>
        <Button v-access:code="['safety:accident:create']" type="primary" @click="showForm()">
          <template #icon><IconifyIcon icon="lucide:plus" /></template>
          ç™»è®°äº‹æ•…
        </Button>
      </div>
      <Table
        :columns="columns"
        :data-source="rows"
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1530 }"
        size="middle"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'accidentType'">
            <Tag color="red">{{ record.accidentTypeName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'level'">
            <Tag :color="ACCIDENT_LEVEL_COLOR[record.level ?? '']">{{ record.levelName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'occurTime'">
            {{ record.occurTime ? String(record.occurTime).replace('T', ' ') : '-' }}
          </template>
          <template v-else-if="column.dataIndex === 'casualtyDesc'">
            <span :class="{ 'text-red-500': !!record.casualtyDesc }">{{ record.casualtyDesc || '-' }}</span>
          </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">
              <Button v-access:code="['safety:accident:update']" type="link" size="small" @click="showForm(record as SafetyApi.Accident)">编辑</Button>
              <Popconfirm title="确定删除该事故记录?" ok-text="确定" cancel-text="取消" @confirm="remove(record as SafetyApi.Accident)">
                <Button v-access:code="['safety:accident:delete']" type="link" danger size="small">删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal
      v-model:open="open"
      :title="model.id ? '编辑事故记录' : '事故登记'"
      :width="780"
      :confirm-loading="saving"
      ok-text="保存"
      cancel-text="取消"
      @ok="save"
    >
      <Form ref="formRef" :model="model" :rules="rules" :disabled="saving" layout="vertical" class="safety-form">
        <FormItem label="事故名称" name="title" class="col-span-full">
          <Input v-model:value="model.title" :maxlength="200" placeholder="如 10kV å¼€å…³æŸœè¯¯æ“ä½œè§¦ç”µ" />
        </FormItem>
        <FormItem label="事故类型" name="accidentType">
          <Select v-model:value="model.accidentType" :options="ACCIDENT_TYPE_OPTIONS" />
        </FormItem>
        <FormItem label="事故等级" name="level">
          <Select v-model:value="model.level" :options="ACCIDENT_LEVEL_OPTIONS" />
        </FormItem>
        <FormItem label="发生时间" name="occurTime">
          <DatePicker v-model:value="model.occurTime" show-time format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" class="w-full" />
        </FormItem>
        <FormItem label="发生地点" name="location">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 2#高压配电室" />
        </FormItem>
        <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 äººè½»ä¼¤" />
        </FormItem>
        <FormItem label="直接经济损失(元)" name="economicLoss">
          <InputNumber v-model:value="model.economicLoss" :min="0" :precision="2" style="width: 100%" />
        </FormItem>
        <FormItem label="处理状态" name="status">
          <Select v-model:value="model.status" :options="ACCIDENT_STATUS_OPTIONS" />
        </FormItem>
        <FormItem label="涉事人员" name="involvedPersons" class="col-span-full">
          <Input v-model:value="model.involvedPersons" :maxlength="500" placeholder="多个姓名用逗号分隔" />
        </FormItem>
        <FormItem label="事故经过" name="description" class="col-span-full">
          <Input.TextArea v-model:value="model.description" :maxlength="2000" :rows="3" show-count placeholder="描述事故发生的时间、地点与经过" />
        </FormItem>
        <FormItem label="原因分析" name="causeAnalysis" class="col-span-full">
          <Input.TextArea v-model:value="model.causeAnalysis" :maxlength="2000" :rows="2" placeholder="直接原因与间接原因" />
        </FormItem>
        <FormItem label="处理措施与整改要求" name="measures" class="col-span-full">
          <Input.TextArea v-model:value="model.measures" :maxlength="2000" :rows="2" placeholder="责任认定、处理决定与整改要求" />
        </FormItem>
        <FormItem label="现场照片/调查报告" name="attachment" class="col-span-full">
          <Input v-model:value="model.attachment" :maxlength="2000" placeholder="多个 URL ç”¨é€—号分隔" />
        </FormItem>
        <FormItem label="备注" name="remark" class="col-span-full">
          <Input v-model:value="model.remark" :maxlength="500" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.safety-filters { gap: 8px 0; margin-bottom: 20px; }
.safety-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; margin-top: 16px; }
@media (max-width: 640px) { .safety-form { grid-template-columns: minmax(0, 1fr); } }
</style>
src/views/safety/cert/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,374 @@
<script lang="ts" setup>
import type { FormInstance, TablePaginationConfig } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Alert,
  Button,
  Card,
  DatePicker,
  Form,
  FormItem,
  Input,
  InputNumber,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Table,
  Tag,
} from 'ant-design-vue';
import {
  createCertificate,
  deleteCertificate,
  getCertificatePage,
  updateCertificate,
} from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { CERT_STATUS_COLOR, CERT_STATUS_OPTIONS, CERT_TYPE_OPTIONS } from '../shared';
defineOptions({ name: 'SafetyCert' });
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.Certificate[]>([]);
const loading = ref(false);
const loadError = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const open = ref(false);
const saving = ref(false);
const detail = ref(false);
const formRef = ref<FormInstance>();
const initial = (): SafetyApi.Certificate => ({
  employeeId: undefined,
  certType: 'HIGH_VOLTAGE_OPERATION',
  certNo: '',
  issuer: '',
  issueDate: undefined,
  expireDate: '',
  reviewDate: undefined,
  warnDays: 30,
  status: 'VALID',
  attachment: '',
  remark: '',
});
const model = reactive<SafetyApi.Certificate>(initial());
const rules = {
  employeeId: [{ required: true, message: '请选择持证人' }],
  certType: [{ required: true, message: '请选择证件类型' }],
  certNo: [{ required: true, whitespace: true, message: '请输入证件编号' }],
  expireDate: [{ required: true, message: '请选择有效期至' }],
};
const columns = [
  { title: '持证人', dataIndex: 'employeeName', width: 150, fixed: 'left' as const },
  { title: '部门', dataIndex: 'deptName', width: 120 },
  { title: '证件类型', dataIndex: 'certType', width: 160 },
  { title: '证件编号', dataIndex: 'certNo', width: 190 },
  { title: '发证机关', dataIndex: 'issuer', width: 160 },
  { title: '有效期至', dataIndex: 'expireDate', width: 120 },
  { title: '剩余天数', dataIndex: 'remainingDays', width: 110, align: 'center' as const },
  { title: '状态', dataIndex: 'status', width: 100, align: 'center' as const },
  { title: '操作', key: 'action', width: 170, fixed: 'right' as const },
];
let requestId = 0;
async function load(reset = false) {
  if (reset) pagination.current = 1;
  const currentId = ++requestId;
  loading.value = true;
  loadError.value = false;
  try {
    const data = await getCertificatePage({
      ...filters,
      pageNo: pagination.current,
      pageSize: pagination.pageSize,
    });
    if (currentId !== requestId) return;
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    if (currentId === requestId) {
      rows.value = [];
      loadError.value = true;
    }
  } finally {
    if (currentId === requestId) loading.value = false;
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: TablePaginationConfig) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
function showForm(row?: SafetyApi.Certificate, readonly = false) {
  detail.value = readonly;
  Object.assign(model, initial(), row ?? {});
  open.value = true;
  formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate();
  saving.value = true;
  try {
    const data = {
      ...model,
      certNo: model.certNo.trim(),
    };
    await (data.id ? updateCertificate(data) : createCertificate(data));
    message.success('特种作业证已保存');
    open.value = false;
    await load();
  } finally {
    saving.value = false;
  }
}
const deletingId = ref<number>();
async function remove(row: SafetyApi.Certificate) {
  deletingId.value = row.id;
  try {
    await deleteCertificate(row.id!);
    message.success('已删除');
    if (rows.value.length === 1 && pagination.current > 1) pagination.current--;
    await load();
  } finally {
    deletingId.value = undefined;
  }
}
onMounted(() => load());
</script>
<template>
  <Page auto-content-height>
    <Card>
      <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
        <FormItem label="持证人">
          <Input v-model:value="filters.employeeName" allow-clear placeholder="姓名" style="width: 120px" />
        </FormItem>
        <FormItem label="工号">
          <Input v-model:value="filters.employeeNo" allow-clear placeholder="工号" style="width: 120px" />
        </FormItem>
        <FormItem label="部门">
          <Input v-model:value="filters.deptName" allow-clear placeholder="部门" style="width: 130px" />
        </FormItem>
        <FormItem label="证件类型">
          <Select
            v-model:value="filters.certType"
            :options="CERT_TYPE_OPTIONS"
            allow-clear
            placeholder="全部类型"
            style="width: 170px"
          />
        </FormItem>
        <FormItem label="状态">
          <Select
            v-model:value="filters.status"
            :options="CERT_STATUS_OPTIONS"
            allow-clear
            placeholder="全部状态"
            style="width: 130px"
          />
        </FormItem>
        <FormItem>
          <Space>
            <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
            <Button @click="resetFilters">重置</Button>
          </Space>
        </FormItem>
      </Form>
      <div class="mb-4 flex items-center justify-between">
        <span class="text-base font-semibold">特种作业证台账</span>
        <Button
          v-access:code="['safety:cert:create']"
          type="primary"
          @click="showForm()"
        >
          <template #icon>
            <IconifyIcon icon="lucide:plus" />
          </template>
          æ–°å¢žè¯ä»¶
        </Button>
      </div>
      <Alert
        v-if="loadError"
        type="error"
        message="证件数据加载失败,请点击查询重试。"
        show-icon
        class="mb-4"
      />
      <Table
        :columns="columns"
        :data-source="rows"
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1400 }"
        size="middle"
        :locale="{ emptyText: '暂无特种作业证记录' }"
        :header-cell-style="{
          background: '#fafafa',
          color: '#606266',
          fontWeight: 600,
          padding: '12px 16px',
          whiteSpace: 'nowrap',
        }"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'employeeName'">
            <div class="flex flex-col">
              <span class="font-medium">{{ record.employeeName || '-' }}</span>
              <span class="text-muted-foreground font-mono text-xs">
                {{ record.employeeNo || '' }}
              </span>
            </div>
          </template>
          <template v-else-if="column.dataIndex === 'deptName'">
            {{ record.deptName || '-' }}
          </template>
          <template v-else-if="column.dataIndex === 'certType'">
            <Tag color="blue">{{ record.certTypeName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'remainingDays'">
            <span
              v-if="record.remainingDays !== null && record.remainingDays !== undefined"
              :class="{
                'font-semibold text-red-500': record.remainingDays < 0,
                'font-semibold text-orange-500': record.remainingDays >= 0 && record.remainingDays <= 30,
              }"
            >
              {{ record.remainingDays < 0 ? `已过期 ${Math.abs(record.remainingDays)} å¤©` : `${record.remainingDays} å¤©` }}
            </span>
            <span v-else class="text-gray-300">-</span>
          </template>
          <template v-else-if="column.dataIndex === 'status'">
            <Tag :color="CERT_STATUS_COLOR[record.status ?? '']">
              {{ record.statusName }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
              <Button type="link" size="small" @click="showForm(record as SafetyApi.Certificate, true)">详情</Button>
              <Button
                v-access:code="['safety:cert:update']"
                type="link"
                size="small"
                @click="showForm(record as SafetyApi.Certificate)"
              >编辑</Button>
              <Popconfirm
                title="确定删除这本特种作业证?"
                ok-text="确定"
                cancel-text="取消"
                @confirm="remove(record as SafetyApi.Certificate)"
              >
                <Button
                  v-access:code="['safety:cert:delete']"
                  type="link"
                  danger
                  size="small"
                  :loading="deletingId === record.id"
                >删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal
      v-model:open="open"
      :title="detail ? '特种作业证详情' : model.id ? '编辑特种作业证' : '新增特种作业证'"
      :width="720"
      :confirm-loading="saving"
      :footer="detail ? null : undefined"
      ok-text="保存"
      cancel-text="取消"
      @ok="save"
    >
      <Form
        ref="formRef"
        :model="model"
        :rules="rules"
        :disabled="detail || saving"
        layout="vertical"
        class="safety-form"
      >
        <FormItem label="持证人" name="employeeId" class="col-span-full">
          <EmployeeSelect v-model="model.employeeId" placeholder="输入姓名或工号搜索持证人" />
          <div class="text-muted-foreground mt-1 text-xs">
            æŒè¯äººæ¥è‡ªäººåŠ›èµ„æºçš„å‘˜å·¥æ¡£æ¡ˆï¼Œå§“å / å·¥å· / éƒ¨é—¨è‡ªåŠ¨å¸¦å‡ºï¼Œå‘˜å·¥è°ƒå²—åŽå°è´¦ä¼šåŒæ­¥æ›´æ–°ã€‚
          </div>
        </FormItem>
        <FormItem label="证件类型" name="certType">
          <Select v-model:value="model.certType" :options="CERT_TYPE_OPTIONS" />
        </FormItem>
        <FormItem label="证件编号" name="certNo">
          <Input v-model:value="model.certNo" :maxlength="64" placeholder="请输入证件编号" />
        </FormItem>
        <FormItem label="发证机关" name="issuer">
          <Input v-model:value="model.issuer" :maxlength="100" placeholder="如 åº”急管理局" />
        </FormItem>
        <FormItem label="发证日期" name="issueDate">
          <DatePicker v-model:value="model.issueDate" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
        <FormItem label="有效期至" name="expireDate">
          <DatePicker v-model:value="model.expireDate" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
        <FormItem label="下次复审日期" name="reviewDate">
          <DatePicker v-model:value="model.reviewDate" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
        <FormItem label="到期预警提前天数" name="warnDays">
          <InputNumber v-model:value="model.warnDays" :min="0" :max="365" style="width: 100%" />
        </FormItem>
        <FormItem label="备注" name="remark" class="col-span-full">
          <Input.TextArea v-model:value="model.remark" :maxlength="500" :rows="2" show-count placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.safety-filters {
  gap: 8px 0;
  margin-bottom: 20px;
}
.safety-form {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0 20px;
  margin-top: 16px;
}
@media (max-width: 640px) {
  .safety-form {
    grid-template-columns: minmax(0, 1fr);
  }
}
</style>
src/views/safety/drill/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,270 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Card,
  DatePicker,
  Form,
  FormItem,
  Input,
  InputNumber,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Table,
  Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import {
  createDrill,
  deleteDrill,
  getDrillPage,
  updateDrill,
} from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { DRILL_TYPE_OPTIONS } from '../shared';
defineOptions({ name: 'SafetyDrill' });
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.Drill[]>([]);
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const open = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
const initial = (): SafetyApi.Drill => ({
  title: '',
  drillType: 'FIRE',
  drillDate: dayjs().format('YYYY-MM-DD'),
  location: '',
  organizerId: undefined,
  participants: '',
  participantCount: 0,
  planDesc: '',
  processDesc: '',
  summary: '',
  durationMinutes: 30,
  attachment: '',
  remark: '',
});
const model = reactive<SafetyApi.Drill>(initial());
const rules = {
  title: [{ required: true, whitespace: true, message: '请输入演练名称' }],
  drillDate: [{ required: true, message: '请选择演练日期' }],
};
const columns = [
  { title: '演练日期', dataIndex: 'drillDate', width: 130, fixed: 'left' as const },
  { title: '演练名称', dataIndex: 'title', minWidth: 220, ellipsis: true },
  { title: '类型', dataIndex: 'drillType', width: 140 },
  { title: '时长(分)', dataIndex: 'durationMinutes', width: 100, align: 'center' as const },
  { title: '组织人', dataIndex: 'organizer', width: 120 },
  { title: '演练地点', dataIndex: 'location', width: 160, ellipsis: true },
  { title: '参加人数', dataIndex: 'participantCount', width: 110, align: 'center' as const },
  { title: '操作', key: 'action', width: 150, fixed: 'right' as const },
];
async function load(reset = false) {
  if (reset) pagination.current = 1;
  loading.value = true;
  try {
    const params: any = { ...filters, pageNo: pagination.current, pageSize: pagination.pageSize };
    if (params.drillDateRange?.length === 2) {
      params.drillDate = params.drillDateRange;
      delete params.drillDateRange;
    }
    const data = await getDrillPage(params);
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    message.error('加载演练记录失败');
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: any) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
function showForm(row?: SafetyApi.Drill) {
  Object.assign(model, initial(), row ?? {});
  open.value = true;
  formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate();
  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;
    await load();
  } finally {
    saving.value = false;
  }
}
async function remove(row: SafetyApi.Drill) {
  await deleteDrill(row.id!);
  message.success('已删除');
  await load();
}
onMounted(() => load());
</script>
<template>
  <Page
    title="消防演练"
    description="消防、应急疏散、触电事故、危化品泄漏等演练的电子化归档,留存方案、过程与总结。"
    auto-content-height
  >
    <Card>
      <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
        <FormItem label="演练日期">
          <DatePicker.RangePicker v-model:value="filters.drillDateRange" value-format="YYYY-MM-DD" />
        </FormItem>
        <FormItem label="名称">
          <Input v-model:value="filters.title" allow-clear placeholder="演练名称" style="width: 170px" />
        </FormItem>
        <FormItem label="类型">
          <Select v-model:value="filters.drillType" :options="DRILL_TYPE_OPTIONS" allow-clear placeholder="全部" style="width: 160px" />
        </FormItem>
        <FormItem>
          <Space>
            <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
            <Button @click="resetFilters">重置</Button>
          </Space>
        </FormItem>
      </Form>
      <div class="mb-4 flex items-center justify-between">
        <span class="text-base font-semibold">演练记录</span>
        <Button v-access:code="['safety:drill:create']" type="primary" @click="showForm()">
          <template #icon><IconifyIcon icon="lucide:plus" /></template>
          æ–°å¢žæ¼”练
        </Button>
      </div>
      <Table
        :columns="columns"
        :data-source="rows"
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1200 }"
        size="middle"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
      >
        <template #bodyCell="{ column, record }">
          <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>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
              <Button v-access:code="['safety:drill:update']" type="link" size="small" @click="showForm(record as SafetyApi.Drill)">编辑</Button>
              <Popconfirm title="确定删除该演练记录?" ok-text="确定" cancel-text="取消" @confirm="remove(record as SafetyApi.Drill)">
                <Button v-access:code="['safety:drill:delete']" type="link" danger size="small">删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal
      v-model:open="open"
      :title="model.id ? '编辑演练记录' : '新增演练记录'"
      :width="760"
      :confirm-loading="saving"
      ok-text="保存"
      cancel-text="取消"
      @ok="save"
    >
      <Form ref="formRef" :model="model" :rules="rules" :disabled="saving" layout="vertical" class="safety-form">
        <FormItem label="演练名称" name="title" class="col-span-full">
          <Input v-model:value="model.title" :maxlength="200" placeholder="如 2026 å¹´åº¦æ¶ˆé˜²ç–æ•£æ¼”练" />
        </FormItem>
        <FormItem label="演练类型" name="drillType">
          <Select v-model:value="model.drillType" :options="DRILL_TYPE_OPTIONS" />
        </FormItem>
        <FormItem label="演练日期" name="drillDate">
          <DatePicker v-model:value="model.drillDate" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
        <FormItem label="演练地点" name="location">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 åŽ‚åŒºä¸œä¾§æ¶ˆé˜²é€šé“" />
        </FormItem>
        <FormItem label="组织人" name="organizerId">
          <EmployeeSelect v-model="model.organizerId" placeholder="输入姓名或工号搜索组织人" />
        </FormItem>
        <FormItem label="时长(分钟)" name="durationMinutes">
          <InputNumber v-model:value="model.durationMinutes" :min="0" style="width: 100%" />
        </FormItem>
        <FormItem label="参加人数" name="participantCount">
          <InputNumber v-model:value="model.participantCount" :min="0" style="width: 100%" placeholder="留空按名单自动统计" />
        </FormItem>
        <FormItem label="参加人员" name="participants" class="col-span-full">
          <Input v-model:value="model.participants" :maxlength="2000" placeholder="多个姓名用逗号分隔" />
        </FormItem>
        <FormItem label="演练方案" name="planDesc" class="col-span-full">
          <Input.TextArea v-model:value="model.planDesc" :maxlength="2000" :rows="2" show-count placeholder="演练的目的、场景与流程安排" />
        </FormItem>
        <FormItem label="演练过程记录" name="processDesc" class="col-span-full">
          <Input.TextArea v-model:value="model.processDesc" :maxlength="2000" :rows="3" show-count placeholder="实际演练过程的记录" />
        </FormItem>
        <FormItem label="总结与改进" name="summary" class="col-span-full">
          <Input.TextArea v-model:value="model.summary" :maxlength="2000" :rows="2" placeholder="演练取得的成效与后续改进措施" />
        </FormItem>
        <FormItem label="附件(照片/记录表)" name="attachment" class="col-span-full">
          <Input v-model:value="model.attachment" :maxlength="2000" placeholder="多个 URL ç”¨é€—号分隔" />
        </FormItem>
        <FormItem label="备注" name="remark" class="col-span-full">
          <Input v-model:value="model.remark" :maxlength="500" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.safety-filters { gap: 8px 0; margin-bottom: 20px; }
.safety-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; margin-top: 16px; }
@media (max-width: 640px) { .safety-form { grid-template-columns: minmax(0, 1fr); } }
</style>
src/views/safety/hazard/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,604 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Card,
  DatePicker,
  Descriptions,
  Form,
  FormItem,
  Image,
  Input,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Steps,
  Table,
  Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import {
  assignHazard,
  createHazard,
  deleteHazard,
  getHazardPage,
  recheckHazard,
  rectifyHazard,
  updateHazard,
  uploadSafetyFiles,
} from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select.vue';
import {
  HAZARD_LEVEL_COLOR,
  HAZARD_LEVEL_OPTIONS,
  HAZARD_SOURCE_OPTIONS,
  HAZARD_STATUS_COLOR,
  HAZARD_STATUS_OPTIONS,
  splitUrls,
} from '../shared';
defineOptions({ name: 'SafetyHazard' });
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.Hazard[]>([]);
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const editOpen = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
const uploading = ref(false);
const photoList = ref<string[]>([]);
/** å¤æŸ¥ç»“论选项 */
const recheckOptions = [
  { label: '通过(隐患闭环)', value: 'PASSED' },
  { label: '不通过(退回整改)', value: 'REJECTED' },
];
/** é—­çŽ¯æµç¨‹æ­¥éª¤ */
const stepItems = [
  { title: '上报' },
  { title: '分派' },
  { title: '整改' },
  { title: '复查' },
  { title: '闭环' },
];
const initial = (): SafetyApi.Hazard => ({
  title: '',
  level: 'GENERAL',
  source: 'SELF_CHECK',
  location: '',
  description: '',
  photoUrls: '',
  reporterId: undefined,
  // ä¸ŠæŠ¥äººå§“名为后端回填的快照字段,仅用于展示
  reporter: undefined,
  reportTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
  rectifyDeadline: undefined,
  remark: '',
});
const model = reactive<SafetyApi.Hazard>(initial());
const editRules = {
  title: [{ required: true, whitespace: true, message: '请输入隐患标题' }],
  reporterId: [{ required: true, message: '请选择上报人' }],
};
const assignOpen = ref(false);
const assignModel = reactive<{ id: number; assigneeId?: number; rectifyDeadline?: string; remark?: string }>({
  id: 0,
  assigneeId: undefined,
});
const rectifyOpen = ref(false);
const rectifyModel = reactive<{ id: number; rectifyDesc: string; rectifyPhotoUrls?: string }>({
  id: 0,
  rectifyDesc: '',
});
const rectifyPhotos = ref<string[]>([]);
const recheckOpen = ref(false);
const recheckModel = reactive<{ id: number; recheckerId?: number; recheckResult: string; recheckRemark?: string }>({
  id: 0,
  recheckerId: undefined,
  recheckResult: 'PASSED',
});
/** å½“前复查的隐患,用于校验「复查人不能与整改责任人为同一人」 */
const recheckRow = ref<SafetyApi.Hazard>();
const detailOpen = ref(false);
const detailRow = ref<null | SafetyApi.Hazard>(null);
const columns = [
  { title: '隐患编号', dataIndex: 'code', width: 165, fixed: 'left' as const },
  { title: '标题', dataIndex: 'title', minWidth: 200, ellipsis: true },
  { title: '等级', dataIndex: 'level', width: 100, align: 'center' as const },
  { title: '来源', dataIndex: 'source', width: 110 },
  { title: '位置', dataIndex: 'location', width: 160, ellipsis: true },
  { title: '上报人', dataIndex: 'reporter', width: 100 },
  { title: '上报时间', dataIndex: 'reportTime', width: 165 },
  { title: '整改责任人', dataIndex: 'assignee', width: 120 },
  { title: '整改期限', dataIndex: 'rectifyDeadline', width: 150 },
  { title: '状态', dataIndex: 'status', width: 110, align: 'center' as const },
  { title: '操作', key: 'action', width: 230, fixed: 'right' as const },
];
async function load(reset = false) {
  if (reset) pagination.current = 1;
  loading.value = true;
  try {
    const data = await getHazardPage({
      ...filters,
      pageNo: pagination.current,
      pageSize: pagination.pageSize,
    });
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    message.error('加载隐患列表失败');
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: any) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
function showEdit(row?: SafetyApi.Hazard) {
  Object.assign(model, initial(), row ?? {});
  photoList.value = splitUrls(model.photoUrls);
  editOpen.value = true;
  formRef.value?.clearValidate();
}
async function handleUpload(e: Event, target: any) {
  const input = e.target as HTMLInputElement;
  const files = Array.from(input.files ?? []);
  if (!files.length) return;
  uploading.value = true;
  try {
    const urls = await uploadSafetyFiles(files, 'safety/hazard');
    target.value = [...target.value, ...urls];
    message.success(`已上传 ${urls.length} å¼ ç…§ç‰‡`);
  } catch {
    message.error('照片上传失败');
  } finally {
    uploading.value = false;
    input.value = '';
  }
}
async function saveEdit() {
  await formRef.value?.validate();
  saving.value = true;
  try {
    const data = {
      ...model,
      title: model.title.trim(),
      photoUrls: photoList.value.join(','),
    };
    // ä¸ŠæŠ¥äººå§“名为只读快照,不再提交
    delete data.reporter;
    await (data.id ? updateHazard(data) : createHazard(data));
    message.success('隐患已保存');
    editOpen.value = false;
    await load();
  } finally {
    saving.value = false;
  }
}
function openAssign(row: SafetyApi.Hazard) {
  assignModel.id = row.id!;
  assignModel.assigneeId = row.assigneeId;
  assignModel.rectifyDeadline = row.rectifyDeadline;
  assignModel.remark = '';
  assignOpen.value = true;
}
async function saveAssign() {
  if (!assignModel.assigneeId) {
    message.warning('请选择整改责任人');
    return;
  }
  saving.value = true;
  try {
    await assignHazard({
      id: assignModel.id,
      assigneeId: assignModel.assigneeId,
      rectifyDeadline: assignModel.rectifyDeadline,
      remark: assignModel.remark,
    });
    message.success('已分派');
    assignOpen.value = false;
    await load();
  } finally {
    saving.value = false;
  }
}
function openRectify(row: SafetyApi.Hazard) {
  rectifyModel.id = row.id!;
  rectifyModel.rectifyDesc = row.rectifyDesc ?? '';
  rectifyPhotos.value = splitUrls(row.rectifyPhotoUrls);
  rectifyOpen.value = true;
}
async function saveRectify() {
  if (!rectifyModel.rectifyDesc.trim()) {
    message.warning('请填写整改措施与结果');
    return;
  }
  saving.value = true;
  try {
    await rectifyHazard({
      ...rectifyModel,
      rectifyDesc: rectifyModel.rectifyDesc.trim(),
      rectifyPhotoUrls: rectifyPhotos.value.join(','),
    });
    message.success('整改结果已提交,等待复查');
    rectifyOpen.value = false;
    await load();
  } finally {
    saving.value = false;
  }
}
function openRecheck(row: SafetyApi.Hazard) {
  recheckModel.id = row.id!;
  recheckModel.recheckerId = undefined;
  recheckModel.recheckResult = 'PASSED';
  recheckModel.recheckRemark = '';
  recheckRow.value = row;
  recheckOpen.value = true;
}
async function saveRecheck() {
  if (!recheckModel.recheckerId) {
    message.warning('请选择复查人');
    return;
  }
  if (recheckModel.recheckerId === recheckRow.value?.assigneeId) {
    message.warning('复查人不能与整改责任人为同一人');
    return;
  }
  saving.value = true;
  try {
    await recheckHazard({
      id: recheckModel.id,
      recheckerId: recheckModel.recheckerId,
      recheckResult: recheckModel.recheckResult,
      recheckRemark: recheckModel.recheckRemark,
    });
    message.success(recheckModel.recheckResult === 'PASSED' ? '复查通过,隐患已闭环' : '复查不通过,已退回整改');
    recheckOpen.value = false;
    await load();
  } finally {
    saving.value = false;
  }
}
function openDetail(row: SafetyApi.Hazard) {
  detailRow.value = row;
  detailOpen.value = true;
}
async function remove(row: SafetyApi.Hazard) {
  await deleteHazard(row.id!);
  message.success('已删除');
  await load();
}
function stepIndex(status?: string): number {
  const order = ['REPORTED', 'ASSIGNED', 'RECTIFYING', 'RECHECKING', 'CLOSED'];
  const normalized = status === 'REJECTED' ? 'RECTIFYING' : (status ?? '');
  const idx = order.indexOf(normalized);
  return idx < 0 ? 0 : idx;
}
onMounted(() => load());
</script>
<template>
  <Page
    title="隐患台账"
    description="隐患上报 â†’ åˆ†æ´¾ â†’ æ•´æ”¹ â†’ å¤æŸ¥å…¨æµç¨‹é—­çŽ¯ç®¡ç†ï¼Œæ”¯æŒçŽ°åœºæ‹ç…§å–è¯ã€‚"
    auto-content-height
  >
    <Card>
      <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
        <FormItem label="标题">
          <Input v-model:value="filters.title" allow-clear placeholder="隐患标题" style="width: 160px" />
        </FormItem>
        <FormItem label="等级">
          <Select v-model:value="filters.level" :options="HAZARD_LEVEL_OPTIONS" allow-clear placeholder="全部" style="width: 130px" />
        </FormItem>
        <FormItem label="状态">
          <Select v-model:value="filters.status" :options="HAZARD_STATUS_OPTIONS" allow-clear placeholder="全部" style="width: 130px" />
        </FormItem>
        <FormItem label="责任人">
          <Input v-model:value="filters.assignee" allow-clear placeholder="姓名" style="width: 120px" />
        </FormItem>
        <FormItem>
          <Space>
            <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
            <Button @click="resetFilters">重置</Button>
          </Space>
        </FormItem>
      </Form>
      <div class="mb-4 flex items-center justify-between">
        <span class="text-base font-semibold">隐患列表</span>
        <Button v-access:code="['safety:hazard:create']" type="primary" @click="showEdit()">
          <template #icon><IconifyIcon icon="lucide:plus" /></template>
          é𐿂£ä¸ŠæŠ¥
        </Button>
      </div>
      <Table
        :columns="columns"
        :data-source="rows"
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1700 }"
        size="middle"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'level'">
            <Tag :color="HAZARD_LEVEL_COLOR[record.level ?? '']">{{ record.levelName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'source'">{{ record.sourceName }}</template>
          <template v-else-if="column.dataIndex === 'reportTime'">
            {{ record.reportTime ? String(record.reportTime).replace('T', ' ') : '-' }}
          </template>
          <template v-else-if="column.dataIndex === 'rectifyDeadline'">
            <span :class="{ 'font-semibold text-red-500': record.overdue }">
              {{ record.rectifyDeadline || '-' }}
              <Tag v-if="record.overdue" color="red" class="ml-1">超期</Tag>
            </span>
          </template>
          <template v-else-if="column.dataIndex === 'status'">
            <Tag :color="HAZARD_STATUS_COLOR[record.status ?? '']">{{ record.statusName }}</Tag>
          </template>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
              <Button type="link" size="small" @click="openDetail(record as SafetyApi.Hazard)">详情</Button>
              <Button
                v-if="record.status === 'REPORTED' || record.status === 'REJECTED'"
                v-access:code="['safety:hazard:assign']"
                type="link"
                size="small"
                @click="openAssign(record as SafetyApi.Hazard)"
              >分派</Button>
              <Button
                v-if="record.status === 'RECTIFYING' || record.status === 'REJECTED'"
                v-access:code="['safety:hazard:rectify']"
                type="link"
                size="small"
                @click="openRectify(record as SafetyApi.Hazard)"
              >整改</Button>
              <Button
                v-if="record.status === 'RECHECKING'"
                v-access:code="['safety:hazard:recheck']"
                type="link"
                size="small"
                @click="openRecheck(record as SafetyApi.Hazard)"
              >复查</Button>
              <Popconfirm
                title="确定删除该隐患?"
                ok-text="确定"
                cancel-text="取消"
                @confirm="remove(record as SafetyApi.Hazard)"
              >
                <Button v-access:code="['safety:hazard:delete']" type="link" danger size="small">删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal
      v-model:open="editOpen"
      :title="model.id ? '编辑隐患' : '隐患上报'"
      :width="720"
      :confirm-loading="saving"
      ok-text="保存"
      cancel-text="取消"
      @ok="saveEdit"
    >
      <Form
        ref="formRef"
        :model="model"
        :rules="editRules"
        :disabled="saving"
        layout="vertical"
        class="safety-form"
      >
        <FormItem label="隐患标题" name="title" class="col-span-full">
          <Input v-model:value="model.title" :maxlength="200" placeholder="如 1#配电室门禁损坏,无关人员可随意进入" />
        </FormItem>
        <FormItem label="隐患等级" name="level">
          <Select v-model:value="model.level" :options="HAZARD_LEVEL_OPTIONS" />
        </FormItem>
        <FormItem label="隐患来源" name="source">
          <Select v-model:value="model.source" :options="HAZARD_SOURCE_OPTIONS" />
        </FormItem>
        <FormItem label="隐患位置" name="location">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 1#高压配电室" />
        </FormItem>
        <FormItem label="整改期限" name="rectifyDeadline">
          <DatePicker v-model:value="model.rectifyDeadline" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
        <FormItem label="上报人" name="reporterId">
          <EmployeeSelect v-model="model.reporterId" placeholder="输入姓名或工号搜索上报人" />
        </FormItem>
        <FormItem label="上报时间" name="reportTime">
          <DatePicker
            v-model:value="model.reportTime"
            show-time
            format="YYYY-MM-DD HH:mm:ss"
            value-format="YYYY-MM-DD HH:mm:ss"
            class="w-full"
          />
        </FormItem>
        <FormItem label="隐患描述" name="description" class="col-span-full">
          <Input.TextArea v-model:value="model.description" :maxlength="2000" :rows="3" show-count placeholder="描述隐患的具体情况和可能造成的后果" />
        </FormItem>
        <FormItem label="现场照片(移动端可直接拍照上传)" class="col-span-full">
          <div class="flex flex-wrap items-center gap-2">
            <Image.PreviewGroup>
              <Image v-for="url in photoList" :key="url" :src="url" :width="72" :height="72" class="rounded object-cover" />
            </Image.PreviewGroup>
            <label class="photo-btn">
              <IconifyIcon class="size-5" icon="lucide:camera" />
              <span class="mt-1 text-xs">{{ uploading ? '上传中' : '拍照' }}</span>
              <input type="file" accept="image/*" capture="environment" multiple class="hidden" :disabled="uploading" @change="handleUpload($event, photoList)" />
            </label>
          </div>
          <div class="text-muted-foreground mt-1 text-xs">手机上点击「拍照」直接调用摄像头,最多一次 9 å¼ ã€‚</div>
        </FormItem>
        <FormItem label="备注" name="remark" class="col-span-full">
          <Input v-model:value="model.remark" :maxlength="500" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
    <Modal v-model:open="assignOpen" title="分派整改责任人" :confirm-loading="saving" ok-text="确认分派" cancel-text="取消" @ok="saveAssign">
      <Form layout="vertical" class="mt-4">
        <FormItem label="整改责任人" required>
          <EmployeeSelect v-model="assignModel.assigneeId" placeholder="输入姓名或工号搜索整改责任人" />
        </FormItem>
        <FormItem label="整改期限">
          <DatePicker v-model:value="assignModel.rectifyDeadline" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
        <FormItem label="分派说明">
          <Input.TextArea v-model:value="assignModel.remark" :maxlength="500" :rows="2" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
    <Modal v-model:open="rectifyOpen" title="提交整改结果" :confirm-loading="saving" ok-text="提交整改" cancel-text="取消" @ok="saveRectify">
      <Form layout="vertical" class="mt-4">
        <FormItem label="整改措施与结果" required>
          <Input.TextArea v-model:value="rectifyModel.rectifyDesc" :maxlength="2000" :rows="4" show-count placeholder="描述已采取的整改措施和完成情况" />
        </FormItem>
        <FormItem label="整改后照片">
          <div class="flex flex-wrap items-center gap-2">
            <Image.PreviewGroup>
              <Image v-for="url in rectifyPhotos" :key="url" :src="url" :width="72" :height="72" class="rounded object-cover" />
            </Image.PreviewGroup>
            <label class="photo-btn">
              <IconifyIcon class="size-5" icon="lucide:camera" />
              <span class="mt-1 text-xs">{{ uploading ? '上传中' : '拍照' }}</span>
              <input type="file" accept="image/*" capture="environment" multiple class="hidden" :disabled="uploading" @change="handleUpload($event, rectifyPhotos)" />
            </label>
          </div>
        </FormItem>
      </Form>
    </Modal>
    <Modal v-model:open="recheckOpen" title="隐患复查" :confirm-loading="saving" ok-text="提交复查" cancel-text="取消" @ok="saveRecheck">
      <Form layout="vertical" class="mt-4">
        <FormItem label="复查人" required>
          <EmployeeSelect 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" />
        </FormItem>
        <FormItem label="复查意见">
          <Input.TextArea v-model:value="recheckModel.recheckRemark" :maxlength="500" :rows="2" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
    <Modal v-model:open="detailOpen" title="隐患详情" :width="820" :footer="null">
      <template v-if="detailRow">
        <Steps :current="stepIndex(detailRow.status)" size="small" class="mb-5 mt-2" :items="stepItems" />
        <Descriptions bordered size="small" :column="2">
          <Descriptions.Item label="隐患编号">{{ detailRow.code }}</Descriptions.Item>
          <Descriptions.Item label="状态">
            <Tag :color="HAZARD_STATUS_COLOR[detailRow.status ?? '']">{{ detailRow.statusName }}</Tag>
          </Descriptions.Item>
          <Descriptions.Item label="标题" :span="2">{{ detailRow.title }}</Descriptions.Item>
          <Descriptions.Item label="等级">
            <Tag :color="HAZARD_LEVEL_COLOR[detailRow.level ?? '']">{{ detailRow.levelName }}</Tag>
          </Descriptions.Item>
          <Descriptions.Item label="来源">{{ detailRow.sourceName }}</Descriptions.Item>
          <Descriptions.Item label="位置">{{ detailRow.location || '-' }}</Descriptions.Item>
          <Descriptions.Item label="上报人">{{ detailRow.reporter }}</Descriptions.Item>
          <Descriptions.Item label="上报时间">{{ detailRow.reportTime ? String(detailRow.reportTime).replace('T', ' ') : '-' }}</Descriptions.Item>
          <Descriptions.Item label="整改责任人">{{ detailRow.assignee || '-' }}</Descriptions.Item>
          <Descriptions.Item label="整改期限">
            {{ detailRow.rectifyDeadline || '-' }}
            <Tag v-if="detailRow.overdue" color="red" class="ml-1">超期</Tag>
          </Descriptions.Item>
          <Descriptions.Item label="隐患描述" :span="2">{{ detailRow.description || '-' }}</Descriptions.Item>
          <Descriptions.Item label="现场照片" :span="2">
            <Image.PreviewGroup v-if="splitUrls(detailRow.photoUrls).length">
              <Image v-for="url in splitUrls(detailRow.photoUrls)" :key="url" :src="url" :width="80" :height="80" class="mr-2 rounded object-cover" />
            </Image.PreviewGroup>
            <span v-else>-</span>
          </Descriptions.Item>
          <Descriptions.Item label="整改措施" :span="2">{{ detailRow.rectifyDesc || '-' }}</Descriptions.Item>
          <Descriptions.Item label="整改后照片" :span="2">
            <Image.PreviewGroup v-if="splitUrls(detailRow.rectifyPhotoUrls).length">
              <Image v-for="url in splitUrls(detailRow.rectifyPhotoUrls)" :key="url" :src="url" :width="80" :height="80" class="mr-2 rounded object-cover" />
            </Image.PreviewGroup>
            <span v-else>-</span>
          </Descriptions.Item>
          <Descriptions.Item label="复查人">{{ detailRow.rechecker || '-' }}</Descriptions.Item>
          <Descriptions.Item label="复查结论">{{ detailRow.recheckResultName || '-' }}</Descriptions.Item>
          <Descriptions.Item label="复查意见" :span="2">{{ detailRow.recheckRemark || '-' }}</Descriptions.Item>
        </Descriptions>
      </template>
    </Modal>
  </Page>
</template>
<style scoped>
.safety-filters { gap: 8px 0; margin-bottom: 20px; }
.safety-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; margin-top: 16px; }
@media (max-width: 640px) { .safety-form { grid-template-columns: minmax(0, 1fr); } }
.photo-btn {
  display: flex;
  width: 72px;
  height: 72px;
  cursor: pointer;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  border-radius: 6px;
  border: 1px dashed hsl(var(--border));
  color: #909399;
}
.photo-btn:hover { border-color: #60a5fa; color: #3b82f6; }
</style>
src/views/safety/inspection/point/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,356 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Card,
  Form,
  FormItem,
  Input,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Switch,
  Table,
  Tag,
} from 'ant-design-vue';
import { getWorkshopSimpleList } from '#/api/mes/md/workstation/workshop/index';
import {
  createInspectionPoint,
  deleteInspectionPoint,
  getInspectionPointPage,
  updateInspectionPoint,
} from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { INSPECT_CYCLE_OPTIONS, POINT_TYPE_OPTIONS } from '../../shared';
defineOptions({ name: 'SafetyInspectionPoint' });
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.InspectionPoint[]>([]);
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const open = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
const initial = (): SafetyApi.InspectionPoint => ({
  code: '',
  name: '',
  pointType: 'HIGH_VOLTAGE_ROOM',
  workshopId: undefined,
  // è½¦é—´ / è´£ä»»äººä¸ºåŽç«¯å›žå¡«çš„快照字段,仅用于展示
  workshopName: undefined,
  location: '',
  inspectItems: '1. é—¨é”ä¸Žè­¦ç¤ºæ ‡è¯†å®Œå¥½\n2. ç»ç¼˜å·¥å™¨å…·åœ¨æœ‰æ•ˆæœŸå†…\n3. æ¶ˆé˜²å™¨æåœ¨ä½å¯ç”¨\n4. è®°å½•本填写完整',
  inspectCycle: 'DAILY',
  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: '请输入点位编码' }],
  name: [{ required: true, whitespace: true, message: '请输入点位名称' }],
  pointType: [{ required: true, message: '请选择点位类型' }],
};
const columns = [
  { title: '点位编码', dataIndex: 'code', width: 130, fixed: 'left' as const },
  { title: '点位名称', dataIndex: 'name', width: 170 },
  { title: '类型', dataIndex: 'pointType', width: 130 },
  { title: '所属区域', dataIndex: 'workshopName', width: 130 },
  { title: '具体位置', dataIndex: 'location', width: 170, ellipsis: true },
  { title: '巡检周期', dataIndex: 'inspectCycle', width: 110, align: 'center' as const },
  { title: '责任人', dataIndex: 'ownerName', width: 110 },
  { title: '最近巡检', dataIndex: 'lastInspectTime', width: 165 },
  { title: '状态', dataIndex: 'status', width: 90, align: 'center' as const },
  { title: '操作', key: 'action', width: 150, fixed: 'right' as const },
];
async function load(reset = false) {
  if (reset) pagination.current = 1;
  loading.value = true;
  try {
    const data = await getInspectionPointPage({
      ...filters,
      pageNo: pagination.current,
      pageSize: pagination.pageSize,
    });
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    message.error('加载巡检点位失败');
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: any) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
function showForm(row?: SafetyApi.InspectionPoint) {
  Object.assign(model, initial(), row ?? {});
  open.value = true;
  formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate();
  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;
    await load();
  } finally {
    saving.value = false;
  }
}
async function remove(row: SafetyApi.InspectionPoint) {
  await deleteInspectionPoint(row.id!);
  message.success('已删除');
  await load();
}
onMounted(() => {
  void loadWorkshops();
  void load();
});
</script>
<template>
  <Page
    title="巡检点位"
    description="高压配电室、机组区、危化品库等日常安全巡检点位的定义与巡检周期。"
    auto-content-height
  >
    <Card>
      <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
        <FormItem label="编码 / åç§°">
          <Input v-model:value="filters.code" allow-clear placeholder="点位编码" style="width: 130px" />
        </FormItem>
        <FormItem label="类型">
          <Select
            v-model:value="filters.pointType"
            :options="POINT_TYPE_OPTIONS"
            allow-clear
            placeholder="全部类型"
            style="width: 160px"
          />
        </FormItem>
        <FormItem label="区域">
          <Input v-model:value="filters.workshopName" allow-clear placeholder="所属区域" style="width: 130px" />
        </FormItem>
        <FormItem>
          <Space>
            <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
            <Button @click="resetFilters">重置</Button>
          </Space>
        </FormItem>
      </Form>
      <div class="mb-4 flex items-center justify-between">
        <span class="text-base font-semibold">巡检点位列表</span>
        <Button
          v-access:code="['safety:inspection-point:create']"
          type="primary"
          @click="showForm()"
        >
          <template #icon>
            <IconifyIcon icon="lucide:plus" />
          </template>
          æ–°å¢žç‚¹ä½
        </Button>
      </div>
      <Table
        :columns="columns"
        :data-source="rows"
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1450 }"
        size="middle"
        :locale="{ emptyText: '暂无巡检点位,请先新增' }"
        :header-cell-style="{
          background: '#fafafa',
          color: '#606266',
          fontWeight: 600,
          padding: '12px 16px',
        }"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'pointType'">
            <Tag color="blue">{{ record.pointTypeName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'inspectCycle'">
            <Tag>{{ record.inspectCycleName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'lastInspectTime'">
            <span v-if="record.lastInspectTime">{{ String(record.lastInspectTime).replace('T', ' ') }}</span>
            <span v-else class="text-gray-400">从未巡检</span>
          </template>
          <template v-else-if="column.dataIndex === 'status'">
            <Tag :color="record.status === 0 ? 'green' : 'default'">
              {{ record.status === 0 ? '启用' : '停用' }}
            </Tag>
          </template>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
              <Button
                v-access:code="['safety:inspection-point:update']"
                type="link"
                size="small"
                @click="showForm(record as SafetyApi.InspectionPoint)"
              >编辑</Button>
              <Popconfirm
                title="确定删除该巡检点位?"
                ok-text="确定"
                cancel-text="取消"
                @confirm="remove(record as SafetyApi.InspectionPoint)"
              >
                <Button
                  v-access:code="['safety:inspection-point:delete']"
                  type="link"
                  danger
                  size="small"
                >删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal
      v-model:open="open"
      :title="model.id ? '编辑巡检点位' : '新增巡检点位'"
      :width="720"
      :confirm-loading="saving"
      ok-text="保存"
      cancel-text="取消"
      @ok="save"
    >
      <Form
        ref="formRef"
        :model="model"
        :rules="rules"
        :disabled="saving"
        layout="vertical"
        class="safety-form"
      >
        <FormItem label="点位编码" name="code">
          <Input v-model:value="model.code" :maxlength="64" placeholder="如 PD-001" />
        </FormItem>
        <FormItem label="点位名称" name="name">
          <Input v-model:value="model.name" :maxlength="100" placeholder="如 1#高压配电室" />
        </FormItem>
        <FormItem label="点位类型" name="pointType">
          <Select v-model:value="model.pointType" :options="POINT_TYPE_OPTIONS" />
        </FormItem>
        <FormItem label="巡检周期" name="inspectCycle">
          <Select v-model:value="model.inspectCycle" :options="INSPECT_CYCLE_OPTIONS" />
        </FormItem>
        <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="ownerId">
          <EmployeeSelect v-model="model.ownerId" placeholder="输入姓名或工号搜索责任人" />
        </FormItem>
        <FormItem label="具体位置" name="location" class="col-span-full">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 åŽ‚åŒºè¥¿åŒ—è§’ 1 å·é…ç”µæˆ¿" />
        </FormItem>
        <FormItem label="巡检项目清单" name="inspectItems" class="col-span-full">
          <Input.TextArea
            v-model:value="model.inspectItems"
            :maxlength="2000"
            :rows="5"
            show-count
            placeholder="每行一项检查内容"
          />
        </FormItem>
        <FormItem label="启用" name="status">
          <Switch
            :checked="model.status === 0"
            @change="(v: any) => (model.status = v ? 0 : 1)"
          />
        </FormItem>
        <FormItem label="备注" name="remark">
          <Input v-model:value="model.remark" :maxlength="500" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.safety-filters {
  gap: 8px 0;
  margin-bottom: 20px;
}
.safety-form {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0 20px;
  margin-top: 16px;
}
@media (max-width: 640px) {
  .safety-form {
    grid-template-columns: minmax(0, 1fr);
  }
}
</style>
src/views/safety/inspection/record/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,452 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Card,
  DatePicker,
  Form,
  FormItem,
  Image,
  Input,
  InputNumber,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Table,
  Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import {
  createInspection,
  deleteInspection,
  getInspectionPage,
  getInspectionPointSimpleList,
  updateInspection,
  uploadSafetyFiles,
} from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { INSPECTION_RESULT_OPTIONS, splitUrls } from '../../shared';
defineOptions({ name: 'SafetyInspectionRecord' });
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.Inspection[]>([]);
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const points = ref<SafetyApi.InspectionPoint[]>([]);
const open = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
const uploading = ref(false);
const photoList = ref<string[]>([]);
const initial = (): SafetyApi.Inspection => ({
  pointId: 0,
  inspectTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
  inspectorId: undefined,
  // å·¡æ£€äººå§“名为后端回填的快照字段,仅用于展示
  inspector: undefined,
  result: 'NORMAL',
  content: '',
  photoUrls: '',
  hazardCount: 0,
  remark: '',
});
const model = reactive<SafetyApi.Inspection>(initial());
const rules = {
  pointId: [{ required: true, message: '请选择巡检点位' }],
  inspectTime: [{ required: true, message: '请选择巡检时间' }],
  inspectorId: [{ required: true, message: '请选择巡检人' }],
};
const pointOptions = computed(() =>
  points.value.map((p) => ({
    label: `${p.name}(${p.pointTypeName})`,
    value: p.id,
  })),
);
const columns = [
  { title: '巡检时间', dataIndex: 'inspectTime', width: 165, fixed: 'left' as const },
  { title: '点位名称', dataIndex: 'pointName', width: 170 },
  { title: '类型', dataIndex: 'pointType', width: 130 },
  { title: '巡检人', dataIndex: 'inspector', width: 110 },
  { title: '结果', dataIndex: 'result', width: 95, align: 'center' as const },
  { title: '巡检情况', dataIndex: 'content', minWidth: 240, ellipsis: true },
  { title: '现场照片', dataIndex: 'photoUrls', width: 130, align: 'center' as const },
  { title: '隐患数', dataIndex: 'hazardCount', width: 90, align: 'center' as const },
  { title: '操作', key: 'action', width: 150, fixed: 'right' as const },
];
async function load(reset = false) {
  if (reset) pagination.current = 1;
  loading.value = true;
  try {
    const params: any = {
      ...filters,
      pageNo: pagination.current,
      pageSize: pagination.pageSize,
    };
    if (params.inspectTimeRange?.length === 2) {
      params.inspectTime = params.inspectTimeRange;
      delete params.inspectTimeRange;
    }
    const data = await getInspectionPage(params);
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    message.error('加载巡检记录失败');
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
async function loadPoints() {
  try {
    points.value = await getInspectionPointSimpleList();
  } catch {
    message.error('加载巡检点位失败');
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: any) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
function showForm(row?: SafetyApi.Inspection) {
  Object.assign(model, initial(), row ?? {});
  photoList.value = splitUrls(model.photoUrls);
  open.value = true;
  formRef.value?.clearValidate();
}
/** ç§»åŠ¨ç«¯é£Žæ ¼ï¼šé€‰æ‹©ç…§ç‰‡å³ä¸Šä¼ ï¼Œè¿”å›ž URL ç´¯ç§¯åˆ° photoList */
async function handleUpload(e: Event) {
  const input = e.target as HTMLInputElement;
  const files = Array.from(input.files ?? []);
  if (files.length === 0) return;
  uploading.value = true;
  try {
    const urls = await uploadSafetyFiles(files, 'safety/inspection');
    photoList.value = [...photoList.value, ...urls];
    message.success(`已上传 ${urls.length} å¼ ç…§ç‰‡`);
  } catch {
    message.error('照片上传失败');
  } finally {
    uploading.value = false;
    input.value = '';
  }
}
function removePhoto(url: string) {
  photoList.value = photoList.value.filter((u) => u !== url);
}
async function save() {
  await formRef.value?.validate();
  saving.value = true;
  try {
    const data = {
      ...model,
      photoUrls: photoList.value.join(','),
    };
    // å·¡æ£€äººå§“名为只读快照,不再提交
    delete data.inspector;
    await (data.id ? updateInspection(data) : createInspection(data));
    message.success('巡检记录已保存');
    open.value = false;
    await load();
  } finally {
    saving.value = false;
  }
}
async function remove(row: SafetyApi.Inspection) {
  await deleteInspection(row.id!);
  message.success('已删除');
  await load();
}
onMounted(() => {
  void loadPoints();
  void load();
});
</script>
<template>
  <Page
    title="巡检记录"
    description="高压配电室、机组区等点位的日常巡检记录,现场可拍照上传。异常情况请到「隐患台账」上报。"
    auto-content-height
  >
    <Card>
      <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
        <FormItem label="巡检时间">
          <DatePicker.RangePicker
            v-model:value="filters.inspectTimeRange"
            show-time
            format="YYYY-MM-DD HH:mm:ss"
            value-format="YYYY-MM-DD HH:mm:ss"
          />
        </FormItem>
        <FormItem label="点位">
          <Select
            v-model:value="filters.pointId"
            :options="pointOptions"
            allow-clear
            show-search
            placeholder="全部点位"
            style="width: 210px"
            :filter-option="(input: string, opt: any) => String(opt.label).includes(input)"
          />
        </FormItem>
        <FormItem label="巡检人">
          <Input v-model:value="filters.inspector" allow-clear placeholder="姓名" style="width: 120px" />
        </FormItem>
        <FormItem label="结果">
          <Select
            v-model:value="filters.result"
            :options="INSPECTION_RESULT_OPTIONS"
            allow-clear
            placeholder="全部"
            style="width: 110px"
          />
        </FormItem>
        <FormItem>
          <Space>
            <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
            <Button @click="resetFilters">重置</Button>
          </Space>
        </FormItem>
      </Form>
      <div class="mb-4 flex items-center justify-between">
        <span class="text-base font-semibold">巡检记录</span>
        <Button
          v-access:code="['safety:inspection:create']"
          type="primary"
          @click="showForm()"
        >
          <template #icon>
            <IconifyIcon icon="lucide:plus" />
          </template>
          æ–°å¢žå·¡æ£€
        </Button>
      </div>
      <Table
        :columns="columns"
        :data-source="rows"
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1350 }"
        size="middle"
        :locale="{ emptyText: '暂无巡检记录' }"
        :header-cell-style="{
          background: '#fafafa',
          color: '#606266',
          fontWeight: 600,
          padding: '12px 16px',
        }"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'inspectTime'">
            {{ String(record.inspectTime).replace('T', ' ') }}
          </template>
          <template v-else-if="column.dataIndex === 'pointType'">
            <Tag color="blue">{{ record.pointTypeName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'result'">
            <Tag :color="record.result === 'NORMAL' ? 'green' : 'red'">
              {{ record.resultName }}
            </Tag>
          </template>
          <template v-else-if="column.dataIndex === 'photoUrls'">
            <Image.PreviewGroup v-if="splitUrls(record.photoUrls).length">
              <Image
                v-for="(url, idx) in splitUrls(record.photoUrls)"
                :key="url"
                :src="url"
                :width="28"
                :height="28"
                class="mr-1 rounded object-cover"
                :style="{ display: idx > 2 ? 'none' : 'inline-block' }"
              />
            </Image.PreviewGroup>
            <span v-else class="text-gray-300">-</span>
          </template>
          <template v-else-if="column.dataIndex === 'hazardCount'">
            <Tag v-if="(record.hazardCount ?? 0) > 0" color="red">{{ record.hazardCount }}</Tag>
            <span v-else class="text-gray-300">0</span>
          </template>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
              <Button
                v-access:code="['safety:inspection:update']"
                type="link"
                size="small"
                @click="showForm(record as SafetyApi.Inspection)"
              >编辑</Button>
              <Popconfirm
                title="确定删除该巡检记录?"
                ok-text="确定"
                cancel-text="取消"
                @confirm="remove(record as SafetyApi.Inspection)"
              >
                <Button
                  v-access:code="['safety:inspection:delete']"
                  type="link"
                  danger
                  size="small"
                >删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal
      v-model:open="open"
      :title="model.id ? '编辑巡检记录' : '新增巡检记录'"
      :width="720"
      :confirm-loading="saving"
      ok-text="保存"
      cancel-text="取消"
      @ok="save"
    >
      <Form
        ref="formRef"
        :model="model"
        :rules="rules"
        :disabled="saving"
        layout="vertical"
        class="safety-form"
      >
        <FormItem label="巡检点位" name="pointId">
          <Select
            v-model:value="model.pointId"
            :options="pointOptions"
            show-search
            placeholder="请选择巡检点位"
            :filter-option="(input: string, opt: any) => String(opt.label).includes(input)"
          />
        </FormItem>
        <FormItem label="巡检时间" name="inspectTime">
          <DatePicker
            v-model:value="model.inspectTime"
            show-time
            format="YYYY-MM-DD HH:mm:ss"
            value-format="YYYY-MM-DD HH:mm:ss"
            class="w-full"
          />
        </FormItem>
        <FormItem label="巡检人" name="inspectorId">
          <EmployeeSelect v-model="model.inspectorId" placeholder="输入姓名或工号搜索巡检人" />
        </FormItem>
        <FormItem label="巡检结果" name="result">
          <Select v-model:value="model.result" :options="INSPECTION_RESULT_OPTIONS" />
        </FormItem>
        <FormItem label="本次发现隐患数" name="hazardCount">
          <InputNumber v-model:value="model.hazardCount" :min="0" :max="999" style="width: 100%" />
        </FormItem>
        <FormItem label="巡检情况" name="content" class="col-span-full">
          <Input.TextArea
            v-model:value="model.content"
            :maxlength="2000"
            :rows="3"
            show-count
            placeholder="记录巡检过程中的实际情况,异常项请具体描述"
          />
        </FormItem>
        <FormItem label="现场照片(移动端可直接拍照上传)" class="col-span-full">
          <div class="flex flex-wrap items-center gap-2">
            <Image.PreviewGroup>
              <div
                v-for="url in photoList"
                :key="url"
                class="group relative"
              >
                <Image :src="url" :width="72" :height="72" class="rounded object-cover" />
                <button
                  type="button"
                  class="absolute -right-1 -top-1 hidden size-5 items-center justify-center rounded-full bg-red-500 text-xs text-white group-hover:flex"
                  @click="removePhoto(url)"
                >
                  Ã—
                </button>
              </div>
            </Image.PreviewGroup>
            <label
              class="flex size-[72px] cursor-pointer flex-col items-center justify-center rounded border border-dashed border-[hsl(var(--border))] text-gray-400 hover:border-blue-400 hover:text-blue-500"
            >
              <IconifyIcon class="size-5" icon="lucide:camera" />
              <span class="mt-1 text-xs">{{ uploading ? '上传中' : '拍照' }}</span>
              <input
                type="file"
                accept="image/*"
                capture="environment"
                multiple
                class="hidden"
                :disabled="uploading"
                @change="handleUpload"
              />
            </label>
          </div>
          <div class="text-muted-foreground mt-1 text-xs">
            æ‰‹æœºä¸Šç‚¹å‡»ã€Œæ‹ç…§ã€å¯ç›´æŽ¥è°ƒç”¨æ‘„像头,最多一次上传 9 å¼ ã€‚
          </div>
        </FormItem>
        <FormItem label="备注" name="remark" class="col-span-full">
          <Input v-model:value="model.remark" :maxlength="500" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.safety-filters {
  gap: 8px 0;
  margin-bottom: 20px;
}
.safety-form {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0 20px;
  margin-top: 16px;
}
@media (max-width: 640px) {
  .safety-form {
    grid-template-columns: minmax(0, 1fr);
  }
}
</style>
src/views/safety/ledger/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,160 @@
<script lang="ts" setup>
import type { SafetyApi } from '#/api/safety';
import { computed, onMounted, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { downloadFileFromBlobPart } from '@vben/utils';
import { IconifyIcon } from '@vben/icons';
import {
  Alert,
  Button,
  Card,
  DatePicker,
  message,
  Select,
  Space,
  Table,
} from 'ant-design-vue';
import { exportSafetyLedger, getSafetyOverview } from '#/api/safety';
defineOptions({ name: 'SafetyLedgerExport' });
/** å°è´¦ç±»åž‹é€‰é¡¹ */
const LEDGER_TYPES = [
  { label: '全套标准化台账', value: 'ALL' },
  { label: '特种作业证台账', value: 'CERT' },
  { label: '安全巡检台账', value: 'INSPECTION' },
  { label: '隐患台账', value: 'HAZARD' },
  { label: '高危作业票台账', value: 'PERMIT' },
  { label: '应急物资台账', value: 'MATERIAL' },
  { label: '安全培训台账', value: 'TRAINING' },
  { label: '消防演练台账', value: 'DRILL' },
  { label: '事故登记台账', value: 'ACCIDENT' },
];
const exportType = ref('ALL');
const dateRange = ref<[string, string] | undefined>();
const exporting = ref(false);
/** é¦–页统计 */
const overview = ref<null | SafetyApi.Overview>(null);
const loadingOverview = ref(false);
const summaryRows = computed(() => {
  const v = overview.value;
  if (!v) return [];
  return [
    { name: '特种作业证台账', total: v.certTotal, detail: `即将到期 ${v.certExpiring} æœ¬ / å·²è¿‡æœŸ ${v.certExpired} æœ¬` },
    { name: '隐患台账', total: v.hazardTotal, detail: `未闭环 ${v.hazardOpen} æ¡ / è¶…期 ${v.hazardOverdue} æ¡` },
    { name: '高危作业票台账', total: (v.permitPending ?? 0) + (v.permitInProgress ?? 0), detail: `待审批 ${v.permitPending} å¼  / ä½œä¸šä¸­ ${v.permitInProgress} å¼ ` },
    { name: '应急物资台账', total: v.materialBelowSafeStock, detail: `低于安全库存 ${v.materialBelowSafeStock} é¡¹ / ä¸´è¿‘有效期 ${v.materialExpiring} é¡¹` },
    { name: '安全培训台账', total: v.trainingThisMonth, detail: '本月场次' },
    { name: '消防演练台账', total: v.drillThisMonth, detail: '本月场次' },
    { name: '事故登记台账', total: v.accidentThisMonth, detail: '本月登记' },
  ];
});
const columns = [
  { title: '台账名称', dataIndex: 'name', width: 220 },
  { title: '数量', dataIndex: 'total', width: 120, align: 'center' as const },
  { title: '明细', dataIndex: 'detail' },
];
async function loadOverview() {
  loadingOverview.value = true;
  try {
    overview.value = await getSafetyOverview();
  } catch {
    message.error('加载台账统计失败');
  } finally {
    loadingOverview.value = false;
  }
}
async function handleExport() {
  exporting.value = true;
  try {
    const params: any = { type: exportType.value };
    if (dateRange.value?.length === 2) {
      params.startDate = dateRange.value[0];
      params.endDate = dateRange.value[1];
    }
    const fileName =
      LEDGER_TYPES.find((t) => t.value === exportType.value)?.label ??
      '安全生产标准化台账';
    downloadFileFromBlobPart({
      fileName: fileName + '.xlsx',
      source: await exportSafetyLedger(params),
    });
    message.success('台账已开始下载');
  } catch {
    message.error('台账导出失败,请重试');
  } finally {
    exporting.value = false;
  }
}
onMounted(() => loadOverview());
</script>
<template>
  <Page
    title="标准化台账"
    description="电力行业安全标准化检查所需的各类台账,支持整套或按单类导出为 Excel。"
    auto-content-height
  >
    <Card title="台账导出" class="mb-4">
      <div class="mb-4 flex flex-wrap items-center gap-3">
        <span class="text-sm">台账类型:</span>
        <Select
          v-model:value="exportType"
          :options="LEDGER_TYPES"
          style="width: 200px"
        />
        <span class="ml-4 text-sm">统计周期:</span>
        <DatePicker.RangePicker
          v-model:value="dateRange"
          value-format="YYYY-MM-DD"
          :placeholder="['开始日期', '结束日期']"
        />
        <Space>
          <Button type="primary" :loading="exporting" @click="handleExport">
            <template #icon>
              <IconifyIcon icon="lucide:download" />
            </template>
            å¯¼å‡ºå°è´¦
          </Button>
        </Space>
      </div>
      <Alert
        type="info"
        show-icon
        message="导出说明"
      >
        <template #description>
          <ul class="m-0 pl-4 leading-6">
            <li>选择「全套标准化台账」会导出多 Sheet çš„单个 Excel,包含全部台账;</li>
            <li>选择单类台账则只导出该类数据,便于单独归档或送检;</li>
            <li>巡检、隐患、作业票、物资流水、培训、演练、事故支持按日期范围筛选;</li>
            <li>特种作业证与物资台账不分时间范围,始终导出全部。</li>
          </ul>
        </template>
      </Alert>
    </Card>
    <Card title="台账数据概览">
      <Table
        :columns="columns"
        :data-source="summaryRows"
        :loading="loadingOverview"
        :pagination="false"
        row-key="name"
        size="middle"
      />
    </Card>
  </Page>
</template>
src/views/safety/material/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,480 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Alert,
  Button,
  Card,
  DatePicker,
  Form,
  FormItem,
  Input,
  InputNumber,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Table,
  Tabs,
  Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import {
  createMaterial,
  createMaterialStock,
  deleteMaterial,
  getMaterialPage,
  getMaterialStockPage,
  updateMaterial,
} from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { MATERIAL_CATEGORY_COLOR, MATERIAL_CATEGORY_OPTIONS, numberText } from '../shared';
defineOptions({ name: 'SafetyMaterial' });
/** å½“前页签 */
const activeTab = ref('material');
/* ==================== ç‰©èµ„台账 ==================== */
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.Material[]>([]);
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const editOpen = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
/** æœ‰æ•ˆæœŸä»…在绝缘用具/危化品时必填 */
const expireRequired = computed(() =>
  ['INSULATING', 'HAZARD_CHEMICAL'].includes(model.category),
);
const initial = (): SafetyApi.Material => ({
  code: '',
  name: '',
  category: 'INSULATING',
  spec: '',
  unit: '个',
  stockQty: 0,
  safeStock: 0,
  storageLocation: '',
  expireDate: undefined,
  validityMonths: undefined,
  managerId: undefined,
  status: 0,
  remark: '',
});
const model = reactive<SafetyApi.Material>(initial());
const rules = {
  code: [{ required: true, whitespace: true, message: '请输入物资编码' }],
  name: [{ required: true, whitespace: true, message: '请输入物资名称' }],
  category: [{ required: true, message: '请选择物资类别' }],
};
/* ==================== å‡ºå…¥åº“ ==================== */
const stockOpen = ref(false);
const stockModel = reactive<SafetyApi.MaterialStock>({
  materialId: 0,
  bizType: 'IN',
  quantity: 1,
  bizDate: dayjs().format('YYYY-MM-DD'),
  operator: '',
  receiver: '',
  purpose: '',
  remark: '',
});
const stockFilters = reactive<Record<string, any>>({});
const stockRows = ref<SafetyApi.MaterialStock[]>([]);
const stockLoading = ref(false);
const stockPagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const materialColumns = [
  { title: '物资编码', dataIndex: 'code', width: 140, fixed: 'left' as const },
  { title: '物资名称', dataIndex: 'name', width: 190 },
  { title: '类别', dataIndex: 'category', width: 130 },
  { title: '规格型号', dataIndex: 'spec', width: 150, ellipsis: true },
  { title: '单位', dataIndex: 'unit', width: 80, align: 'center' as const },
  { title: '库存', dataIndex: 'stockQty', width: 110, align: 'right' as const },
  { title: '安全库存', dataIndex: 'safeStock', width: 110, align: 'right' as const },
  { title: '有效期至', dataIndex: 'expireDate', width: 120 },
  { title: '存放位置', dataIndex: 'storageLocation', width: 130 },
  { title: '保管人', dataIndex: 'manager', width: 100 },
  { title: '操作', key: 'action', width: 200, fixed: 'right' as const },
];
const stockColumns = [
  { title: '业务日期', dataIndex: 'bizDate', width: 130, fixed: 'left' as const },
  { title: '物资名称', dataIndex: 'materialName', width: 190 },
  { title: '类别', dataIndex: 'category', width: 130 },
  { title: '业务类型', dataIndex: 'bizType', width: 100, align: 'center' as const },
  { title: '数量', dataIndex: 'quantity', width: 100, align: 'right' as const },
  { title: '操作前库存', dataIndex: 'stockBefore', width: 120, align: 'right' as const },
  { title: '操作后库存', dataIndex: 'stockAfter', width: 120, align: 'right' as const },
  { title: '经办人', dataIndex: 'operator', width: 100 },
  { title: '领用人', dataIndex: 'receiver', width: 100 },
  { title: '用途/去向', dataIndex: 'purpose', minWidth: 160, ellipsis: true },
];
async function load(reset = false) {
  if (reset) pagination.current = 1;
  loading.value = true;
  try {
    const data = await getMaterialPage({
      ...filters,
      pageNo: pagination.current,
      pageSize: pagination.pageSize,
    });
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    message.error('加载物资台账失败');
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
async function loadStock(reset = false) {
  if (reset) stockPagination.current = 1;
  stockLoading.value = true;
  try {
    const params: any = {
      ...stockFilters,
      pageNo: stockPagination.current,
      pageSize: stockPagination.pageSize,
    };
    if (params.bizDateRange?.length === 2) {
      params.bizDate = params.bizDateRange;
      delete params.bizDateRange;
    }
    const data = await getMaterialStockPage(params);
    stockRows.value = data.list;
    stockPagination.total = data.total;
  } catch {
    message.error('加载出入库流水失败');
    stockRows.value = [];
  } finally {
    stockLoading.value = false;
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: any) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
function stockTableChange(page: any) {
  stockPagination.current = page.current ?? 1;
  stockPagination.pageSize = page.pageSize ?? 10;
  void loadStock();
}
function showEdit(row?: SafetyApi.Material) {
  Object.assign(model, initial(), row ?? {});
  editOpen.value = true;
  formRef.value?.clearValidate();
}
async function saveEdit() {
  await formRef.value?.validate();
  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;
    await load();
  } finally {
    saving.value = false;
  }
}
function openStock(row: SafetyApi.Material, bizType: string) {
  stockModel.materialId = row.id!;
  stockModel.bizType = bizType;
  stockModel.quantity = 1;
  stockModel.bizDate = dayjs().format('YYYY-MM-DD');
  stockModel.operator = '';
  stockModel.receiver = '';
  stockModel.purpose = '';
  stockModel.remark = '';
  stockOpen.value = true;
}
async function saveStock() {
  if (!stockModel.quantity || stockModel.quantity <= 0) {
    message.warning('数量必须大于零');
    return;
  }
  if (!stockModel.operator.trim()) {
    message.warning('请填写经办人');
    return;
  }
  saving.value = true;
  try {
    await createMaterialStock({ ...stockModel, operator: stockModel.operator.trim() });
    message.success(stockModel.bizType === 'IN' ? '入库成功' : '出库成功');
    stockOpen.value = false;
    await load();
    if (activeTab.value === 'stock') await loadStock();
  } finally {
    saving.value = false;
  }
}
async function remove(row: SafetyApi.Material) {
  await deleteMaterial(row.id!);
  message.success('已删除');
  await load();
}
onMounted(() => {
  void load();
  void loadStock();
});
</script>
<template>
  <Page
    title="应急物资"
    description="电力绝缘用具、危化品、消防物资的电子台账与出入库流水,落库自动盘点。条件:物资台账 + å‡ºå…¥åº“流水。"
    auto-content-height
  >
    <Card>
      <Tabs v-model:active-key="activeTab">
        <Tabs.TabPane key="material" tab="物资台账">
          <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
            <FormItem label="编码/名称">
              <Input v-model:value="filters.name" allow-clear placeholder="物资名称" style="width: 160px" />
            </FormItem>
            <FormItem label="类别">
              <Select v-model:value="filters.category" :options="MATERIAL_CATEGORY_OPTIONS" allow-clear placeholder="全部" style="width: 150px" />
            </FormItem>
            <FormItem label="存放位置">
              <Input v-model:value="filters.storageLocation" allow-clear placeholder="位置" style="width: 130px" />
            </FormItem>
            <FormItem>
              <Space>
                <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
                <Button @click="resetFilters">重置</Button>
              </Space>
            </FormItem>
          </Form>
          <div class="mb-4 flex items-center justify-between">
            <span class="text-base font-semibold">物资台账</span>
            <Button v-access:code="['safety:material:create']" type="primary" @click="showEdit()">
              <template #icon><IconifyIcon icon="lucide:plus" /></template>
              æ–°å¢žç‰©èµ„
            </Button>
          </div>
          <Table
            :columns="materialColumns"
            :data-source="rows"
            row-key="id"
            :loading="loading"
            :pagination="pagination"
            :scroll="{ x: 1600 }"
            size="middle"
            :cell-style="{ padding: '12px 16px' }"
            @change="tableChange"
          >
            <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 }">
                  {{ numberText(record.stockQty) }}
                </span>
                <Tag v-if="record.belowSafeStock" color="red" class="ml-1">低库存</Tag>
              </template>
              <template v-else-if="column.dataIndex === 'safeStock'">
                {{ numberText(record.safeStock) }}
              </template>
              <template v-else-if="column.dataIndex === 'expireDate'">
                <span
                  v-if="record.expireDate"
                  :class="{ 'font-semibold text-orange-500': (record.expireRemainingDays ?? 999) <= 30, 'text-red-500': (record.expireRemainingDays ?? 999) < 0 }"
                >
                  {{ record.expireDate }}
                </span>
                <span v-else class="text-gray-300">-</span>
              </template>
              <template v-else-if="column.key === 'action'">
                <Space :size="0">
                  <Button v-access:code="['safety:material-stock:in']" type="link" size="small" @click="openStock(record as SafetyApi.Material, 'IN')">入库</Button>
                  <Button v-access:code="['safety:material-stock:out']" type="link" size="small" @click="openStock(record as SafetyApi.Material, 'OUT')">出库</Button>
                  <Button v-access:code="['safety:material:update']" type="link" size="small" @click="showEdit(record as SafetyApi.Material)">编辑</Button>
                  <Popconfirm title="确定删除该物资?" ok-text="确定" cancel-text="取消" @confirm="remove(record as SafetyApi.Material)">
                    <Button v-access:code="['safety:material:delete']" type="link" danger size="small">删除</Button>
                  </Popconfirm>
                </Space>
              </template>
            </template>
          </Table>
        </Tabs.TabPane>
        <Tabs.TabPane key="stock" tab="出入库流水">
          <Form :model="stockFilters" layout="inline" class="safety-filters" @finish="loadStock(true)">
            <FormItem label="业务日期">
              <DatePicker.RangePicker v-model:value="stockFilters.bizDateRange" value-format="YYYY-MM-DD" />
            </FormItem>
            <FormItem label="物资名称">
              <Input v-model:value="stockFilters.materialName" allow-clear placeholder="名称" style="width: 150px" />
            </FormItem>
            <FormItem label="业务类型">
              <Select
                v-model:value="stockFilters.bizType"
                :options="[
                  { label: '入库', value: 'IN' },
                  { label: '出库', value: 'OUT' },
                ]"
                allow-clear
                placeholder="全部"
                style="width: 110px"
              />
            </FormItem>
            <FormItem>
              <Space>
                <Button type="primary" :loading="stockLoading" @click="loadStock(true)">查询</Button>
              </Space>
            </FormItem>
          </Form>
          <Table
            :columns="stockColumns"
            :data-source="stockRows"
            row-key="id"
            :loading="stockLoading"
            :pagination="stockPagination"
            :scroll="{ x: 1400 }"
            size="middle"
            :cell-style="{ padding: '12px 16px' }"
            @change="stockTableChange"
          >
            <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 === 'bizType'">
                <Tag :color="record.bizType === 'IN' ? 'green' : 'orange'">{{ record.bizTypeName }}</Tag>
              </template>
              <template v-else-if="column.dataIndex === 'quantity'">
                {{ numberText(record.quantity) }}
              </template>
              <template v-else-if="column.dataIndex === 'stockBefore'">
                {{ numberText(record.stockBefore) }}
              </template>
              <template v-else-if="column.dataIndex === 'stockAfter'">
                <span class="font-medium">{{ numberText(record.stockAfter) }}</span>
              </template>
            </template>
          </Table>
        </Tabs.TabPane>
      </Tabs>
    </Card>
    <Modal
      v-model:open="editOpen"
      :title="model.id ? '编辑物资' : '新增物资'"
      :width="720"
      :confirm-loading="saving"
      ok-text="保存"
      cancel-text="取消"
      @ok="saveEdit"
    >
      <Form ref="formRef" :model="model" :rules="rules" :disabled="saving" layout="vertical" class="safety-form">
        <FormItem label="物资编码" name="code">
          <Input v-model:value="model.code" :maxlength="64" placeholder="如 PM-001" />
        </FormItem>
        <FormItem label="物资名称" name="name">
          <Input v-model:value="model.name" :maxlength="100" placeholder="如 10kV ç»ç¼˜æ‰‹å¥—" />
        </FormItem>
        <FormItem label="类别" name="category">
          <Select v-model:value="model.category" :options="MATERIAL_CATEGORY_OPTIONS" />
        </FormItem>
        <FormItem label="规格型号" name="spec">
          <Input v-model:value="model.spec" :maxlength="100" placeholder="如 12kV/2级" />
        </FormItem>
        <FormItem label="计量单位" name="unit">
          <Input v-model:value="model.unit" :maxlength="20" placeholder="如 åŒ / ç“¶ / å…·" />
        </FormItem>
        <FormItem label="安全库存" name="safeStock">
          <InputNumber v-model:value="model.safeStock" :min="0" :precision="3" style="width: 100%" />
        </FormItem>
        <FormItem label="存放位置" name="storageLocation">
          <Input v-model:value="model.storageLocation" :maxlength="100" placeholder="如 å®‰å…¨å·¥å™¨å…·å®¤ A æŸœ" />
        </FormItem>
        <FormItem label="保管人" name="managerId">
          <EmployeeSelect 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" />
        </FormItem>
        <FormItem label="有效期月数" name="validityMonths">
          <InputNumber v-model:value="model.validityMonths" :min="0" style="width: 100%" placeholder="如绝缘手套 6 ä¸ªæœˆ" />
        </FormItem>
        <FormItem label="备注" name="remark" class="col-span-full">
          <Input.TextArea v-model:value="model.remark" :maxlength="500" :rows="2" placeholder="选填" />
        </FormItem>
      </Form>
      <Alert v-if="model.id" type="info" show-icon class="mt-2" message="库存数量只能通过出入库登记变动,编辑时不可直接修改。" />
    </Modal>
    <Modal v-model:open="stockOpen" :title="stockModel.bizType === 'IN' ? '物资入库' : '物资出库'" :confirm-loading="saving" :ok-text="stockModel.bizType === 'IN' ? '确认入库' : '确认出库'" cancel-text="取消" @ok="saveStock">
      <Form layout="vertical" class="mt-4">
        <FormItem label="数量" required>
          <InputNumber v-model:value="stockModel.quantity" :min="0.001" :precision="3" style="width: 100%" />
        </FormItem>
        <FormItem label="业务日期">
          <DatePicker v-model:value="stockModel.bizDate" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
        <FormItem label="经办人" required>
          <Input v-model:value="stockModel.operator" :maxlength="50" placeholder="请输入姓名" />
        </FormItem>
        <FormItem v-if="stockModel.bizType === 'OUT'" label="领用人">
          <Input v-model:value="stockModel.receiver" :maxlength="50" placeholder="请输入姓名" />
        </FormItem>
        <FormItem label="用途/去向">
          <Input v-model:value="stockModel.purpose" :maxlength="200" placeholder="如 çŽ°åœºæ£€ä¿®é¢†ç”¨" />
        </FormItem>
        <FormItem label="备注">
          <Input.TextArea v-model:value="stockModel.remark" :maxlength="500" :rows="2" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.safety-filters { gap: 8px 0; margin-bottom: 20px; }
.safety-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; margin-top: 16px; }
@media (max-width: 640px) { .safety-form { grid-template-columns: minmax(0, 1fr); } }
</style>
src/views/safety/material/stock/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,137 @@
<script lang="ts" setup>
import type { SafetyApi } from '#/api/safety';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { Button, Card, DatePicker, Form, FormItem, Input, Select, Space, Table, Tag, message } from 'ant-design-vue';
import { getMaterialStockPage } from '#/api/safety';
import { MATERIAL_CATEGORY_COLOR, numberText } from '../../shared';
defineOptions({ name: 'SafetyMaterialStock' });
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.MaterialStock[]>([]);
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 20, total: 0, showSizeChanger: true });
const columns = [
  { title: '业务日期', dataIndex: 'bizDate', width: 130, fixed: 'left' as const },
  { title: '物资名称', dataIndex: 'materialName', width: 200 },
  { title: '类别', dataIndex: 'category', width: 140 },
  { title: '业务类型', dataIndex: 'bizType', width: 110, align: 'center' as const },
  { title: '数量', dataIndex: 'quantity', width: 110, align: 'right' as const },
  { title: '操作前库存', dataIndex: 'stockBefore', width: 130, align: 'right' as const },
  { title: '操作后库存', dataIndex: 'stockAfter', width: 130, align: 'right' as const },
  { title: '经办人', dataIndex: 'operator', width: 110 },
  { title: '领用人', dataIndex: 'receiver', width: 110 },
  { title: '用途/去向', dataIndex: 'purpose', minWidth: 180, ellipsis: true },
];
async function load(reset = false) {
  if (reset) pagination.current = 1;
  loading.value = true;
  try {
    const params: any = { ...filters, pageNo: pagination.current, pageSize: pagination.pageSize };
    if (params.bizDateRange?.length === 2) {
      params.bizDate = params.bizDateRange;
      delete params.bizDateRange;
    }
    const data = await getMaterialStockPage(params);
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    message.error('加载出入库流水失败');
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: any) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 20;
  void load();
}
onMounted(() => load());
</script>
<template>
  <Page
    title="出入库流水"
    description="应急物资的入库、出库明细台账,每一次变动都记录操作前后库存,可追溯可核对。"
    auto-content-height
  >
    <Card>
      <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
        <FormItem label="业务日期">
          <DatePicker.RangePicker v-model:value="filters.bizDateRange" value-format="YYYY-MM-DD" />
        </FormItem>
        <FormItem label="物资名称">
          <Input v-model:value="filters.materialName" allow-clear placeholder="名称" style="width: 160px" />
        </FormItem>
        <FormItem label="业务类型">
          <Select
            v-model:value="filters.bizType"
            :options="[
              { label: '入库', value: 'IN' },
              { label: '出库', value: 'OUT' },
            ]"
            allow-clear
            placeholder="全部"
            style="width: 110px"
          />
        </FormItem>
        <FormItem>
          <Space>
            <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
            <Button @click="resetFilters">重置</Button>
          </Space>
        </FormItem>
      </Form>
      <Table
        :columns="columns"
        :data-source="rows"
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1450 }"
        size="middle"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
      >
        <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 === 'bizType'">
            <Tag :color="record.bizType === 'IN' ? 'green' : 'orange'">{{ record.bizTypeName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'quantity'">
            {{ numberText(record.quantity) }}
          </template>
          <template v-else-if="column.dataIndex === 'stockBefore'">
            {{ numberText(record.stockBefore) }}
          </template>
          <template v-else-if="column.dataIndex === 'stockAfter'">
            <span class="font-medium">{{ numberText(record.stockAfter) }}</span>
          </template>
        </template>
      </Table>
    </Card>
  </Page>
</template>
<style scoped>
.safety-filters { gap: 8px 0; margin-bottom: 20px; }
</style>
src/views/safety/org/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,332 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Card,
  Empty,
  Form,
  FormItem,
  Input,
  InputNumber,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Switch,
  Table,
  Tag,
  Tree,
} from 'ant-design-vue';
import { createOrg, deleteOrg, getOrgTree, updateOrg } from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select.vue';
defineOptions({ name: 'SafetyOrg' });
const treeData = ref<SafetyApi.Org[]>([]);
const expandedKeys = ref<number[]>([]);
const loading = ref(false);
/** å½“前选中节点 */
const selected = ref<null | SafetyApi.Org>(null);
const open = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
const initial = (): SafetyApi.Org => ({
  parentId: 0,
  name: '',
  leaderId: undefined,
  // è´Ÿè´£äººå§“名 / ç”µè¯ä¸ºåŽç«¯å›žå¡«çš„快照字段,仅用于展示
  leaderName: undefined,
  leaderPhone: undefined,
  memberNames: '',
  duty: '',
  sort: 1,
  status: 0,
  remark: '',
});
const model = reactive<SafetyApi.Org>(initial());
const rules = {
  name: [{ required: true, whitespace: true, message: '请输入组织名称' }],
  sort: [{ required: true, message: '请输入显示顺序' }],
};
/** è¡¨æ ¼å±•示:把树拍平,便于浏览 */
function flatten(nodes: SafetyApi.Org[], depth = 0): any[] {
  const out: any[] = [];
  nodes.forEach((node) => {
    out.push({ ...node, depth });
    if (node.children?.length) out.push(...flatten(node.children, depth + 1));
  });
  return out;
}
const flatRows = ref<any[]>([]);
async function load() {
  loading.value = true;
  try {
    treeData.value = await getOrgTree();
    flatRows.value = flatten(treeData.value);
    // é»˜è®¤å±•开全部父节点
    const parents: number[] = [];
    const walk = (nodes: SafetyApi.Org[]) => {
      nodes.forEach((n) => {
        if (n.children?.length) {
          parents.push(n.id!);
          walk(n.children);
        }
      });
    };
    walk(treeData.value);
    expandedKeys.value = parents;
  } catch {
    message.error('加载安全组织架构失败');
  } finally {
    loading.value = false;
  }
}
function showForm(node?: SafetyApi.Org, parentId?: number) {
  Object.assign(model, initial());
  if (node) {
    Object.assign(model, node, { parentId: node.parentId ?? 0 });
  } else if (parentId !== undefined) {
    model.parentId = parentId;
  }
  open.value = true;
  formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate();
  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;
    await load();
  } finally {
    saving.value = false;
  }
}
async function remove(node: SafetyApi.Org) {
  await deleteOrg(node.id!);
  message.success('已删除');
  await load();
}
const columns = [
  { title: '组织名称', dataIndex: 'name', minWidth: 240 },
  { title: '负责人', dataIndex: 'leaderName', width: 120 },
  { title: '联系电话', dataIndex: 'leaderPhone', width: 140 },
  { title: '成员', dataIndex: 'memberNames', width: 200, ellipsis: true },
  { title: '安全职责', dataIndex: 'duty', minWidth: 240, ellipsis: true },
  { title: '状态', dataIndex: 'status', width: 90, align: 'center' as const },
  { title: '操作', key: 'action', width: 210, fixed: 'right' as const },
];
/** è½¬æˆ ant-design-vue Tree éœ€è¦çš„ { key, title, children } ç»“æž„ */
function toTreeNodes(nodes: SafetyApi.Org[]): any[] {
  return nodes.map((n) => ({
    key: n.id!,
    title: n.name,
    children: n.children?.length ? toTreeNodes(n.children) : undefined,
  }));
}
const treeNodes = computed(() => toTreeNodes(treeData.value));
onMounted(() => load());
</script>
<template>
  <Page
    title="安全组织架构"
    description="安全生产委员会、安全管理部及车间安全小组的层级结构与职责分工。"
    auto-content-height
  >
    <div class="flex gap-4">
      <!-- å·¦ä¾§æ ‘ -->
      <Card title="组织层级" class="w-[300px] flex-shrink-0">
        <Button
          v-access:code="['safety:org:create']"
          type="primary"
          block
          class="mb-3"
          @click="showForm()"
        >
          <template #icon>
            <IconifyIcon icon="lucide:plus" />
          </template>
          æ–°å¢žé¡¶çº§ç»„织
        </Button>
        <Empty
          v-if="!treeData.length && !loading"
          :image="Empty.PRESENTED_IMAGE_SIMPLE"
          description="暂无组织,请先新增"
        />
        <Tree
          v-else
          v-model:expanded-keys="expandedKeys"
          :tree-data="treeNodes"
          block-node
          default-expand-all
        />
      </Card>
      <!-- å³ä¾§è¡¨æ ¼ -->
      <Card class="min-w-0 flex-1" title="组织成员与职责">
        <Table
          :columns="columns"
          :data-source="flatRows"
          :loading="loading"
          :pagination="false"
          row-key="id"
          size="middle"
          :scroll="{ x: 1300 }"
          :default-expand-all-rows="true"
          :locale="{ emptyText: '暂无安全组织,请先新增' }"
          :header-cell-style="{
            background: '#fafafa',
            color: '#606266',
            fontWeight: 600,
            padding: '12px 16px',
          }"
          :cell-style="{ padding: '12px 16px' }"
        >
          <template #bodyCell="{ column, record }">
            <template v-if="column.dataIndex === 'name'">
              <span
                class="font-medium"
                :style="{ paddingLeft: (record.depth ?? 0) * 20 + 'px' }"
              >
                <IconifyIcon
                  v-if="record.depth === 0"
                  class="mr-1 inline text-blue-500"
                  icon="lucide:building-2"
                />
                {{ record.name }}
              </span>
            </template>
            <template v-else-if="column.dataIndex === 'status'">
              <Tag :color="record.status === 0 ? 'green' : 'default'">
                {{ record.status === 0 ? '启用' : '停用' }}
              </Tag>
            </template>
            <template v-else-if="column.key === 'action'">
              <Space :size="0">
                <Button
                  v-access:code="['safety:org:create']"
                  type="link"
                  size="small"
                  @click="showForm(undefined, record.id)"
                >新增下级</Button>
                <Button
                  v-access:code="['safety:org:update']"
                  type="link"
                  size="small"
                  @click="showForm(record as SafetyApi.Org)"
                >编辑</Button>
                <Popconfirm
                  title="确定删除该组织?存在下级组织时无法删除。"
                  ok-text="确定"
                  cancel-text="取消"
                  @confirm="remove(record as SafetyApi.Org)"
                >
                  <Button
                    v-access:code="['safety:org:delete']"
                    type="link"
                    danger
                    size="small"
                  >删除</Button>
                </Popconfirm>
              </Space>
            </template>
          </template>
        </Table>
      </Card>
    </div>
    <Modal
      v-model:open="open"
      :title="model.id ? '编辑安全组织' : '新增安全组织'"
      :width="680"
      :confirm-loading="saving"
      ok-text="保存"
      cancel-text="取消"
      @ok="save"
    >
      <Form
        ref="formRef"
        :model="model"
        :rules="rules"
        :disabled="saving"
        layout="vertical"
        class="safety-form"
      >
        <FormItem label="组织名称" name="name">
          <Input v-model:value="model.name" :maxlength="100" placeholder="如 å®‰å…¨ç”Ÿäº§å§”员会" />
        </FormItem>
        <FormItem label="上级组织" name="parentId">
          <Select
            v-model:value="model.parentId"
            :field-names="{ label: 'name', value: 'id', options: 'children' }"
            :options="[{ id: 0, name: '无(顶级组织)', children: [] }, ...treeData]"
            placeholder="选择上级组织"
          />
        </FormItem>
        <FormItem label="负责人" name="leaderId">
          <EmployeeSelect v-model="model.leaderId" placeholder="输入姓名或工号搜索负责人" />
        </FormItem>
        <FormItem label="显示顺序" name="sort">
          <InputNumber v-model:value="model.sort" :min="0" :max="9999" style="width: 100%" />
        </FormItem>
        <FormItem label="启用" name="status">
          <Switch
            :checked="model.status === 0"
            @change="(v: any) => (model.status = v ? 0 : 1)"
          />
        </FormItem>
        <FormItem label="成员姓名" name="memberNames" class="col-span-full">
          <Input v-model:value="model.memberNames" :maxlength="500" placeholder="多个姓名用逗号分隔,如 å¼ ä¸‰,李四,王五" />
        </FormItem>
        <FormItem label="安全职责" name="duty" class="col-span-full">
          <Input.TextArea v-model:value="model.duty" :maxlength="500" :rows="3" show-count placeholder="描述该组织的安全生产职责" />
        </FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.safety-form {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 0 20px;
  margin-top: 16px;
}
@media (max-width: 640px) {
  .safety-form {
    grid-template-columns: minmax(0, 1fr);
  }
}
</style>
src/views/safety/permit/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,682 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { computed, onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Card,
  DatePicker,
  Descriptions,
  Drawer,
  Form,
  FormItem,
  Image,
  Input,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Table,
  Tabs,
  Tag,
  Timeline,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import { getWorkshopSimpleList } from '#/api/mes/md/workstation/workshop/index';
import {
  approveWorkPermit,
  cancelWorkPermit,
  closeWorkPermit,
  createMonitor,
  createWorkPermit,
  deleteMonitor,
  deleteWorkPermit,
  getMonitorList,
  getWorkPermitPage,
  startWorkPermit,
  updateWorkPermit,
  uploadSafetyFiles,
} from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select.vue';
import {
  PERMIT_STATUS_COLOR,
  PERMIT_STATUS_OPTIONS,
  PERMIT_TYPE_OPTIONS,
  RISK_LEVEL_COLOR,
  RISK_LEVEL_OPTIONS,
  splitUrls,
} from '../shared';
defineOptions({ name: 'SafetyPermit' });
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.WorkPermit[]>([]);
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>();
const uploading = ref(false);
const photoList = ref<string[]>([]);
const initial = (): SafetyApi.WorkPermit => ({
  permitType: 'HOT_WORK',
  title: '',
  workshopId: undefined,
  location: '',
  workContent: '',
  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: '',
  guardianId: undefined,
  remark: '',
});
const model = reactive<SafetyApi.WorkPermit>(initial());
const editRules = {
  permitType: [{ required: true, message: '请选择作业类型' }],
  title: [{ required: true, whitespace: true, message: '请输入作业名称' }],
  applicantId: [{ required: true, message: '请选择申请人' }],
  planStartTime: [{ required: true, message: '请选择计划开始时间' }],
  planEndTime: [{ required: true, message: '请选择计划结束时间' }],
};
/** éœ€è¦ç›‘护人的作业类型 */
const GUARDIAN_REQUIRED_TYPES = [
  'HOT_WORK',
  'LIVE_HIGH_VOLTAGE',
  'LIMITED_SPACE',
];
const guardianRequired = computed(() => GUARDIAN_REQUIRED_TYPES.includes(model.permitType));
/* å®¡æ‰¹ */
const approveOpen = ref(false);
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,
  guardianId: undefined,
  monitorContent: '',
  abnormal: '',
});
const monitorPhotos = ref<string[]>([]);
const columns = [
  { title: '作业票编号', dataIndex: 'code', width: 165, fixed: 'left' as const },
  { 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 },
  { title: '风险等级', dataIndex: 'riskLevel', width: 100, align: 'center' as const },
  { title: '监护人', dataIndex: 'guardian', width: 100 },
  { title: '状态', dataIndex: 'status', width: 110, align: 'center' as const },
  { title: '操作', key: 'action', width: 260, fixed: 'right' as const },
];
async function load(reset = false) {
  if (reset) pagination.current = 1;
  loading.value = true;
  try {
    const data = await getWorkPermitPage({
      ...filters,
      pageNo: pagination.current,
      pageSize: pagination.pageSize,
    });
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    message.error('加载作业票失败');
    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 = [];
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: any) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
function showEdit(row?: SafetyApi.WorkPermit) {
  Object.assign(model, initial(), row ?? {});
  photoList.value = splitUrls(model.photoUrls);
  editOpen.value = true;
  formRef.value?.clearValidate();
}
async function handleUpload(e: Event) {
  const input = e.target as HTMLInputElement;
  const files = Array.from(input.files ?? []);
  if (!files.length) return;
  uploading.value = true;
  try {
    const urls = await uploadSafetyFiles(files, 'safety/permit');
    photoList.value = [...photoList.value, ...urls];
    message.success(`已上传 ${urls.length} å¼ ç…§ç‰‡`);
  } catch {
    message.error('照片上传失败');
  } finally {
    uploading.value = false;
    input.value = '';
  }
}
async function saveEdit() {
  if (guardianRequired.value && !model.guardianId) {
    message.warning('动火 / é«˜åŽ‹å¸¦ç”µ / æœ‰é™ç©ºé—´ä½œä¸šå¿…须指定监护人');
    return;
  }
  await formRef.value?.validate();
  saving.value = true;
  try {
    const data = {
      ...model,
      title: model.title.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;
    await load();
  } finally {
    saving.value = false;
  }
}
/* ---------- å®¡æ‰¹ ---------- */
function openApprove(row: SafetyApi.WorkPermit) {
  // è®°å½•审批目标行,用于校验“申请人不能审批自己的作业票”
  detailRow.value = row;
  approveModel.id = row.id!;
  approveModel.approverId = undefined;
  approveModel.decision = 'APPROVED';
  approveModel.approveComment = '';
  approveOpen.value = true;
}
async function saveApprove() {
  const { approverId } = approveModel;
  if (!approverId) {
    message.warning('请选择审批人');
    return;
  }
  if (approverId === detailRow.value?.applicantId) {
    message.warning('申请人不能审批自己的作业票');
    return;
  }
  saving.value = true;
  try {
    await approveWorkPermit({
      id: approveModel.id,
      approverId,
      decision: approveModel.decision,
      approveComment: approveModel.approveComment,
    });
    message.success(approveModel.decision === 'APPROVED' ? '已批准' : '已驳回');
    approveOpen.value = false;
    await load();
  } finally {
    saving.value = false;
  }
}
/* ---------- å¼€å§‹ / ç»“束 / ä½œåºŸ ---------- */
async function handleStart(row: SafetyApi.WorkPermit) {
  await startWorkPermit(row.id!);
  message.success('作业已开始');
  await load();
}
async function handleClose(row: SafetyApi.WorkPermit) {
  await closeWorkPermit(row.id!);
  message.success('作业已完结并归档');
  await load();
}
async function handleCancel(row: SafetyApi.WorkPermit) {
  await cancelWorkPermit(row.id!);
  message.success('作业票已作废');
  await load();
}
/* ---------- è¯¦æƒ… / ç›‘护记录 ---------- */
async function openDetail(row: SafetyApi.WorkPermit) {
  detailRow.value = row;
  detailOpen.value = true;
  monitorForm.permitId = row.id!;
  monitorForm.guardianId = row.guardianId;
  monitorForm.monitorContent = '';
  monitorForm.abnormal = '';
  monitorPhotos.value = [];
  await loadMonitors(row.id!);
}
async function loadMonitors(permitId: number) {
  monitorLoading.value = true;
  try {
    monitors.value = await getMonitorList(permitId);
  } catch {
    message.error('加载监护记录失败');
  } finally {
    monitorLoading.value = false;
  }
}
async function handleMonitorUpload(e: Event) {
  const input = e.target as HTMLInputElement;
  const files = Array.from(input.files ?? []);
  if (!files.length) return;
  uploading.value = true;
  try {
    const urls = await uploadSafetyFiles(files, 'safety/permit-monitor');
    monitorPhotos.value = [...monitorPhotos.value, ...urls];
  } catch {
    message.error('照片上传失败');
  } finally {
    uploading.value = false;
    input.value = '';
  }
}
async function saveMonitor() {
  if (!monitorForm.guardianId) {
    message.warning('请选择监护人');
    return;
  }
  saving.value = true;
  try {
    await createMonitor({
      ...monitorForm,
      guardianId: monitorForm.guardianId,
      monitorTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
      photoUrls: monitorPhotos.value.join(','),
    });
    message.success('监护记录已添加');
    monitorForm.monitorContent = '';
    monitorForm.abnormal = '';
    monitorPhotos.value = [];
    await loadMonitors(monitorForm.permitId);
  } finally {
    saving.value = false;
  }
}
async function removeMonitor(id: number) {
  await deleteMonitor(id);
  message.success('已删除');
  await loadMonitors(monitorForm.permitId);
}
async function remove(row: SafetyApi.WorkPermit) {
  await deleteWorkPermit(row.id!);
  message.success('已删除');
  await load();
}
onMounted(() => {
  void load();
  void loadWorkshops();
});
</script>
<template>
  <Page
    title="高危作业票"
    description="动火、高压带电、有限空间等高危作业的线上审批,作业票与监护记录全程电子留存。生效条件:作业票 + ç›‘护记录。"
    auto-content-height
  >
    <Card>
      <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
        <FormItem label="作业名称">
          <Input v-model:value="filters.title" allow-clear placeholder="名称" style="width: 160px" />
        </FormItem>
        <FormItem label="作业类型">
          <Select v-model:value="filters.permitType" :options="PERMIT_TYPE_OPTIONS" allow-clear placeholder="全部" style="width: 150px" />
        </FormItem>
        <FormItem label="状态">
          <Select v-model:value="filters.status" :options="PERMIT_STATUS_OPTIONS" allow-clear placeholder="全部" style="width: 130px" />
        </FormItem>
        <FormItem label="申请人">
          <Input v-model:value="filters.applicant" allow-clear placeholder="姓名" style="width: 120px" />
        </FormItem>
        <FormItem>
          <Space>
            <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
            <Button @click="resetFilters">重置</Button>
          </Space>
        </FormItem>
      </Form>
      <div class="mb-4 flex items-center justify-between">
        <span class="text-base font-semibold">作业票列表</span>
        <Button v-access:code="['safety:permit:create']" type="primary" @click="showEdit()">
          <template #icon><IconifyIcon icon="lucide:plus" /></template>
          æäº¤ä½œä¸šç”³è¯·
        </Button>
      </div>
      <Table
        :columns="columns"
        :data-source="rows"
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1900 }"
        size="middle"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
      >
        <template #bodyCell="{ column, record }">
          <template v-if="column.dataIndex === 'permitType'">
            <Tag color="blue">{{ record.permitTypeName }}</Tag>
          </template>
          <template v-else-if="column.dataIndex === 'planStartTime'">
            {{ record.planStartTime ? String(record.planStartTime).replace('T', ' ') : '-' }}
          </template>
          <template v-else-if="column.dataIndex === 'planEndTime'">
            {{ record.planEndTime ? String(record.planEndTime).replace('T', ' ') : '-' }}
          </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>
            <Tag v-else-if="record.guardianRequired" color="red">未指定</Tag>
            <span v-else class="text-gray-300">-</span>
          </template>
          <template v-else-if="column.dataIndex === 'status'">
            <Tag :color="PERMIT_STATUS_COLOR[record.status ?? '']">{{ record.statusName }}</Tag>
          </template>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
              <Button type="link" size="small" @click="openDetail(record as SafetyApi.WorkPermit)">详情</Button>
              <Button
                v-if="record.status === 'PENDING'"
                v-access:code="['safety:permit:approve']"
                type="link"
                size="small"
                @click="openApprove(record as SafetyApi.WorkPermit)"
              >审批</Button>
              <Button
                v-if="record.status === 'DRAFT' || record.status === 'PENDING'"
                v-access:code="['safety:permit:update']"
                type="link"
                size="small"
                @click="showEdit(record as SafetyApi.WorkPermit)"
              >编辑</Button>
              <Button
                v-if="record.status === 'APPROVED'"
                v-access:code="['safety:permit:update']"
                type="link"
                size="small"
                @click="handleStart(record as SafetyApi.WorkPermit)"
              >开始作业</Button>
              <Popconfirm
                v-if="record.status === 'IN_PROGRESS'"
                title="确认作业已结束并归档?"
                ok-text="确认"
                cancel-text="取消"
                @confirm="handleClose(record as SafetyApi.WorkPermit)"
              >
                <Button v-access:code="['safety:permit:update']" type="link" size="small">结束作业</Button>
              </Popconfirm>
              <Popconfirm
                title="确定删除该作业票?监护记录会一并删除。"
                ok-text="确定"
                cancel-text="取消"
                @confirm="remove(record as SafetyApi.WorkPermit)"
              >
                <Button v-access:code="['safety:permit:delete']" type="link" danger size="small">删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal
      v-model:open="editOpen"
      :title="model.id ? '编辑作业票' : '提交高危作业申请'"
      :width="760"
      :confirm-loading="saving"
      ok-text="提交"
      cancel-text="取消"
      @ok="saveEdit"
    >
      <Form ref="formRef" :model="model" :rules="editRules" :disabled="saving" layout="vertical" class="safety-form">
        <FormItem label="作业类型" name="permitType">
          <Select v-model:value="model.permitType" :options="PERMIT_TYPE_OPTIONS" />
        </FormItem>
        <FormItem label="风险等级" name="riskLevel">
          <Select v-model:value="model.riskLevel" :options="RISK_LEVEL_OPTIONS" />
        </FormItem>
        <FormItem label="作业名称" name="title" class="col-span-full">
          <Input v-model:value="model.title" :maxlength="200" placeholder="如 2#主变 10kV å¼€å…³æŸœæ£€ä¿®åŠ¨ç«" />
        </FormItem>
        <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#高压配电室" />
        </FormItem>
        <FormItem label="计划开始时间" name="planStartTime">
          <DatePicker v-model:value="model.planStartTime" show-time format="YYYY-MM-DD HH:mm:ss" value-format="YYYY-MM-DD HH:mm:ss" class="w-full" />
        </FormItem>
        <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="applicantId">
          <EmployeeSelect v-model="model.applicantId" placeholder="输入姓名或工号搜索申请人" />
        </FormItem>
        <FormItem label="监护人" name="guardianId">
          <EmployeeSelect 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="说明作业内容、涉及设备与作业范围" />
        </FormItem>
        <FormItem label="安全措施" name="safetyMeasures" class="col-span-full">
          <Input.TextArea v-model:value="model.safetyMeasures" :maxlength="2000" :rows="3" show-count placeholder="如 åœç”µéªŒç”µæŽ¥åœ°ã€ä½©æˆ´ç»ç¼˜æ‰‹å¥—、设置警戒围栏等" />
        </FormItem>
        <FormItem label="现场照片(作业票附件)" class="col-span-full">
          <div class="flex flex-wrap items-center gap-2">
            <Image.PreviewGroup>
              <Image v-for="url in photoList" :key="url" :src="url" :width="72" :height="72" class="rounded object-cover" />
            </Image.PreviewGroup>
            <label class="photo-btn">
              <IconifyIcon class="size-5" icon="lucide:camera" />
              <span class="mt-1 text-xs">{{ uploading ? '上传中' : '拍照' }}</span>
              <input type="file" accept="image/*" capture="environment" multiple class="hidden" :disabled="uploading" @change="handleUpload" />
            </label>
          </div>
        </FormItem>
        <FormItem label="备注" name="remark" class="col-span-full">
          <Input v-model:value="model.remark" :maxlength="500" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
    <Modal v-model:open="approveOpen" title="作业票审批" :confirm-loading="saving" ok-text="提交审批" cancel-text="取消" @ok="saveApprove">
      <Form layout="vertical" class="mt-4">
        <FormItem label="审批人" required>
          <EmployeeSelect v-model="approveModel.approverId" placeholder="输入姓名或工号搜索审批人(不能与申请人相同)" />
        </FormItem>
        <FormItem label="审批结论" required>
          <Select
            v-model:value="approveModel.decision"
            :options="[
              { label: '同意,批准作业', value: 'APPROVED' },
              { label: '驳回', value: 'REJECTED' },
            ]"
          />
        </FormItem>
        <FormItem label="审批意见">
          <Input.TextArea v-model:value="approveModel.approveComment" :maxlength="500" :rows="2" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
    <Drawer v-model:open="detailOpen" title="作业票详情" :width="760">
      <template v-if="detailRow">
        <Tabs default-active-key="info">
          <Tabs.TabPane key="info" tab="作业票信息">
            <Descriptions bordered size="small" :column="2">
              <Descriptions.Item label="作业票编号">{{ detailRow.code }}</Descriptions.Item>
              <Descriptions.Item label="状态">
                <Tag :color="PERMIT_STATUS_COLOR[detailRow.status ?? '']">{{ detailRow.statusName }}</Tag>
              </Descriptions.Item>
              <Descriptions.Item label="作业类型">{{ detailRow.permitTypeName }}</Descriptions.Item>
              <Descriptions.Item label="风险等级">
                <Tag :color="RISK_LEVEL_COLOR[detailRow.riskLevel ?? '']">{{ detailRow.riskLevelName }}</Tag>
              </Descriptions.Item>
              <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.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>
              <Descriptions.Item label="实际开始">{{ detailRow.actualStartTime ? String(detailRow.actualStartTime).replace('T', ' ') : '-' }}</Descriptions.Item>
              <Descriptions.Item label="实际结束">{{ detailRow.actualEndTime ? String(detailRow.actualEndTime).replace('T', ' ') : '-' }}</Descriptions.Item>
              <Descriptions.Item label="监护人">{{ detailRow.guardian || '-' }}</Descriptions.Item>
              <Descriptions.Item label="审批人">{{ detailRow.approver || '-' }}</Descriptions.Item>
              <Descriptions.Item label="审批时间">{{ detailRow.approveTime ? String(detailRow.approveTime).replace('T', ' ') : '-' }}</Descriptions.Item>
              <Descriptions.Item label="审批意见">{{ detailRow.approveComment || '-' }}</Descriptions.Item>
              <Descriptions.Item label="作业内容" :span="2">{{ detailRow.workContent || '-' }}</Descriptions.Item>
              <Descriptions.Item label="安全措施" :span="2">{{ detailRow.safetyMeasures || '-' }}</Descriptions.Item>
              <Descriptions.Item label="现场照片" :span="2">
                <Image.PreviewGroup v-if="splitUrls(detailRow.photoUrls).length">
                  <Image v-for="url in splitUrls(detailRow.photoUrls)" :key="url" :src="url" :width="80" :height="80" class="mr-2 rounded object-cover" />
                </Image.PreviewGroup>
                <span v-else>-</span>
              </Descriptions.Item>
            </Descriptions>
          </Tabs.TabPane>
          <Tabs.TabPane key="monitor" :tab="`监护记录 (${monitors.length})`">
            <div class="mb-4 rounded-lg border border-solid border-[hsl(var(--border))] p-3">
              <div class="mb-2 text-sm font-semibold">添加监护记录</div>
              <Form layout="vertical">
                <FormItem label="监护人">
                  <EmployeeSelect v-model="monitorForm.guardianId" placeholder="输入姓名或工号搜索监护人" />
                </FormItem>
                <FormItem label="监护内容与情况">
                  <Input.TextArea v-model:value="monitorForm.monitorContent" :maxlength="1000" :rows="2" placeholder="记录监护时的现场情况" />
                </FormItem>
                <FormItem label="异常情况与处置">
                  <Input.TextArea v-model:value="monitorForm.abnormal" :maxlength="500" :rows="2" placeholder="无异常可留空" />
                </FormItem>
                <div class="mb-2 flex items-center gap-2">
                  <Image.PreviewGroup>
                    <Image v-for="url in monitorPhotos" :key="url" :src="url" :width="56" :height="56" class="rounded object-cover" />
                  </Image.PreviewGroup>
                  <label class="photo-btn !h-14 !w-14">
                    <IconifyIcon class="size-4" icon="lucide:camera" />
                    <span class="text-xs">{{ uploading ? '上传中' : '拍照' }}</span>
                    <input type="file" accept="image/*" capture="environment" multiple class="hidden" :disabled="uploading" @change="handleMonitorUpload" />
                  </label>
                  <Button type="primary" :loading="saving" @click="saveMonitor">添加记录</Button>
                </div>
              </Form>
            </div>
            <div v-if="monitorLoading" class="py-4 text-center text-gray-400">加载中…</div>
            <Timeline v-else-if="monitors.length">
              <Timeline.Item v-for="m in monitors" :key="m.id">
                <div class="flex items-start justify-between">
                  <div>
                    <div class="font-medium">{{ m.guardian }}</div>
                    <div class="text-muted-foreground text-xs">
                      {{ m.monitorTime ? String(m.monitorTime).replace('T', ' ') : '' }}
                    </div>
                    <div class="mt-1 text-sm">{{ m.monitorContent || '(无描述)' }}</div>
                    <div v-if="m.abnormal" class="mt-1 text-sm text-red-500">异常:{{ m.abnormal }}</div>
                    <Image.PreviewGroup v-if="splitUrls(m.photoUrls).length" class="mt-1">
                      <Image v-for="u in splitUrls(m.photoUrls)" :key="u" :src="u" :width="48" :height="48" class="mr-1 rounded object-cover" />
                    </Image.PreviewGroup>
                  </div>
                  <Button type="link" danger size="small" @click="removeMonitor(m.id!)">删除</Button>
                </div>
              </Timeline.Item>
            </Timeline>
            <div v-else class="py-8 text-center text-gray-400">暂无监护记录</div>
          </Tabs.TabPane>
        </Tabs>
      </template>
    </Drawer>
  </Page>
</template>
<style scoped>
.safety-filters { gap: 8px 0; margin-bottom: 20px; }
.safety-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; margin-top: 16px; }
@media (max-width: 640px) { .safety-form { grid-template-columns: minmax(0, 1fr); } }
.photo-btn {
  display: flex;
  width: 72px;
  height: 72px;
  cursor: pointer;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  border-radius: 6px;
  border: 1px dashed hsl(var(--border));
  color: #909399;
}
.photo-btn:hover { border-color: #60a5fa; color: #3b82f6; }
</style>
src/views/safety/shared.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,246 @@
/** å®‰å…¨ç”Ÿäº§æ¨¡å— - æžšä¸¾ã€é€‰é¡¹ä¸Žé€šç”¨å·¥å…· */
// ==================== æžšä¸¾å®šä¹‰ ====================
/** ç‰¹ç§ä½œä¸šè¯ç±»åž‹ */
export const CERT_TYPE_OPTIONS = [
  { label: '低压电工证', value: 'LOW_VOLTAGE_ELECTRICIAN' },
  { label: '高压操作证', value: 'HIGH_VOLTAGE_OPERATION' },
  { label: '焊接与热切割作业证', value: 'WELDING' },
  { label: '起重机械作业证', value: 'HOIST' },
  { label: '高处作业证', value: 'HEIGHT_WORK' },
  { label: '有限空间作业证', value: 'LIMITED_SPACE' },
  { label: '叉车作业证', value: 'FORKLIFT' },
  { label: '锅炉作业证', value: 'BOILER' },
  { label: '其他特种作业证', value: 'OTHER' },
];
/** è¯ä»¶çŠ¶æ€ */
export const CERT_STATUS_OPTIONS = [
  { label: '有效', value: 'VALID' },
  { label: '即将到期', value: 'EXPIRING' },
  { label: '已过期', value: 'EXPIRED' },
  { label: '已注销', value: 'REVOKED' },
];
export const CERT_STATUS_COLOR: Record<string, string> = {
  VALID: 'green',
  EXPIRING: 'orange',
  EXPIRED: 'red',
  REVOKED: 'default',
};
/** å·¡æ£€ç‚¹ä½ç±»åž‹ */
export const POINT_TYPE_OPTIONS = [
  { label: '高压配电室', value: 'HIGH_VOLTAGE_ROOM' },
  { label: '机组区', value: 'UNIT_AREA' },
  { label: '危化品库', value: 'DANGER_CHEMICAL' },
  { label: '消防重点部位', value: 'FIRE_AREA' },
  { label: '生产车间', value: 'WORKSHOP' },
  { label: '其他', value: 'OTHER' },
];
/** å·¡æ£€å‘¨æœŸ */
export const INSPECT_CYCLE_OPTIONS = [
  { label: '每班', value: 'SHIFT' },
  { label: '每日', value: 'DAILY' },
  { label: '每周', value: 'WEEKLY' },
  { label: '每月', value: 'MONTHLY' },
];
/** å·¡æ£€ç»“æžœ */
export const INSPECTION_RESULT_OPTIONS = [
  { label: '正常', value: 'NORMAL' },
  { label: '异常', value: 'ABNORMAL' },
];
/** é𐿂£ç­‰çº§ */
export const HAZARD_LEVEL_OPTIONS = [
  { label: '一般隐患', value: 'GENERAL' },
  { label: '重大隐患', value: 'MAJOR' },
];
export const HAZARD_LEVEL_COLOR: Record<string, string> = {
  GENERAL: 'blue',
  MAJOR: 'red',
};
/** é𐿂£æ¥æº */
export const HAZARD_SOURCE_OPTIONS = [
  { label: '巡检发现', value: 'INSPECTION' },
  { label: '自查上报', value: 'SELF_CHECK' },
  { label: '员工上报', value: 'REPORT' },
  { label: '外部检查', value: 'EXTERNAL' },
];
/** é𐿂£çŠ¶æ€ï¼ˆé—­çŽ¯æµè½¬ï¼‰ */
export const HAZARD_STATUS_OPTIONS = [
  { label: '已上报', value: 'REPORTED' },
  { label: '已分派', value: 'ASSIGNED' },
  { label: '整改中', value: 'RECTIFYING' },
  { label: '待复查', value: 'RECHECKING' },
  { label: '复查不通过', value: 'REJECTED' },
  { label: '已闭环', value: 'CLOSED' },
];
export const HAZARD_STATUS_COLOR: Record<string, string> = {
  REPORTED: 'default',
  ASSIGNED: 'blue',
  RECTIFYING: 'processing',
  RECHECKING: 'orange',
  REJECTED: 'red',
  CLOSED: 'success',
};
/** ä½œä¸šç¥¨ç±»åž‹ */
export const PERMIT_TYPE_OPTIONS = [
  { label: '动火作业', value: 'HOT_WORK' },
  { label: '高压带电作业', value: 'LIVE_HIGH_VOLTAGE' },
  { label: '有限空间作业', value: 'LIMITED_SPACE' },
  { label: '高处作业', value: 'WORK_AT_HEIGHT' },
  { label: '吊装作业', value: 'LIFTING' },
  { label: '临时用电作业', value: 'TEMP_ELECTRIC' },
  { label: '其他作业', value: 'OTHER' },
];
/** ä½œä¸šç¥¨çŠ¶æ€ */
export const PERMIT_STATUS_OPTIONS = [
  { label: '草稿', value: 'DRAFT' },
  { label: '待审批', value: 'PENDING' },
  { label: '已批准', value: 'APPROVED' },
  { label: '已驳回', value: 'REJECTED' },
  { label: '作业中', value: 'IN_PROGRESS' },
  { label: '已完结', value: 'CLOSED' },
  { label: '已作废', value: 'CANCELLED' },
];
export const PERMIT_STATUS_COLOR: Record<string, string> = {
  DRAFT: 'default',
  PENDING: 'orange',
  APPROVED: 'blue',
  REJECTED: 'red',
  IN_PROGRESS: 'processing',
  CLOSED: 'success',
  CANCELLED: 'default',
};
/** é£Žé™©ç­‰çº§ */
export const RISK_LEVEL_OPTIONS = [
  { label: '低', value: 'LOW' },
  { label: '一般', value: 'NORMAL' },
  { label: '高', value: 'HIGH' },
  { label: '极高', value: 'VERY_HIGH' },
];
export const RISK_LEVEL_COLOR: Record<string, string> = {
  LOW: 'green',
  NORMAL: 'blue',
  HIGH: 'orange',
  VERY_HIGH: 'red',
};
/** ç‰©èµ„类别 */
export const MATERIAL_CATEGORY_OPTIONS = [
  { label: '电力绝缘用具', value: 'INSULATING' },
  { label: '危化品', value: 'HAZARD_CHEMICAL' },
  { label: '消防物资', value: 'FIRE' },
  { label: '劳保用品', value: 'PPE' },
  { label: '其他', value: 'OTHER' },
];
export const MATERIAL_CATEGORY_COLOR: Record<string, string> = {
  INSULATING: 'blue',
  HAZARD_CHEMICAL: 'red',
  FIRE: 'orange',
  PPE: 'green',
  OTHER: 'default',
};
/** åŸ¹è®­ç±»åž‹ */
export const TRAINING_TYPE_OPTIONS = [
  { label: '安全培训', value: 'SAFETY' },
  { label: '技能培训', value: 'SKILL' },
  { label: '法规宣贯', value: 'LAW' },
  { label: '应急演练培训', value: 'EMERGENCY' },
  { label: '三级安全教育', value: 'INDUCTION' },
  { label: '其他', value: 'OTHER' },
];
/** æ¼”练类型 */
export const DRILL_TYPE_OPTIONS = [
  { label: '消防演练', value: 'FIRE' },
  { label: '应急疏散演练', value: 'EMERGENCY_EVAC' },
  { label: '触电事故演练', value: 'ELECTRIC_ACCIDENT' },
  { label: '危化品泄漏演练', value: 'CHEMICAL_LEAK' },
  { label: '其他演练', value: 'OTHER' },
];
/** äº‹æ•…类型 */
export const ACCIDENT_TYPE_OPTIONS = [
  { label: '触电', value: 'ELECTRIC_SHOCK' },
  { label: '火灾', value: 'FIRE' },
  { label: '高处坠落', value: 'FALL' },
  { label: '物体打击', value: 'OBJECT_STRIKE' },
  { label: '机械伤害', value: 'MECHANICAL' },
  { label: '中毒窒息', value: 'CHEMICAL' },
  { label: '车辆伤害', value: 'VEHICLE' },
  { label: '其他', value: 'OTHER' },
];
/** äº‹æ•…等级 */
export const ACCIDENT_LEVEL_OPTIONS = [
  { label: '轻伤事故', value: 'SLIGHT' },
  { label: '一般事故', value: 'GENERAL' },
  { label: '重伤事故', value: 'SEVERE' },
  { label: '重大事故', value: 'MAJOR' },
];
export const ACCIDENT_LEVEL_COLOR: Record<string, string> = {
  SLIGHT: 'default',
  GENERAL: 'blue',
  SEVERE: 'orange',
  MAJOR: 'red',
};
/** äº‹æ•…处理状态 */
export const ACCIDENT_STATUS_OPTIONS = [
  { label: '已登记', value: 'REPORTED' },
  { label: '调查中', value: 'INVESTIGATING' },
  { label: '已处理', value: 'HANDLED' },
  { label: '已结案', value: 'CLOSED' },
];
// ==================== å·¥å…·å‡½æ•° ====================
/** ä»Žé€‰é¡¹æ•°ç»„取 label */
export function optionLabel(
  options: { label: string; value: string }[],
  value?: string,
): string {
  if (!value) return '-';
  return options.find((o) => o.value === value)?.label ?? value;
}
/** é€—号串转数组 */
export function splitUrls(urls?: string): string[] {
  if (!urls) return [];
  return urls.split(',').filter(Boolean);
}
/** æ•°ç»„转逗号串 */
export function joinUrls(urls: string[]): string {
  return urls.filter(Boolean).join(',');
}
/** æ•°å­—格式化,去掉多余的 0 */
export function numberText(value?: number, precision = 3): string {
  if (value === null || value === undefined) return '-';
  return Number(value)
    .toFixed(precision)
    .replace(/\.?0+$/, '');
}
/** åˆ¤æ–­æ˜¯å¦ä¸ºå›¾ç‰‡ URL */
export function isImage(url: string): boolean {
  return /\.(jpg|jpeg|png|webp|gif|bmp|heic)$/i.test(url);
}
src/views/safety/training/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,266 @@
<script lang="ts" setup>
import type { FormInstance } from 'ant-design-vue';
import type { SafetyApi } from '#/api/safety';
import { onMounted, reactive, ref } from 'vue';
import { Page } from '@vben/common-ui';
import { IconifyIcon } from '@vben/icons';
import {
  Button,
  Card,
  DatePicker,
  Form,
  FormItem,
  Input,
  InputNumber,
  message,
  Modal,
  Popconfirm,
  Select,
  Space,
  Table,
  Tag,
} from 'ant-design-vue';
import dayjs from 'dayjs';
import {
  createTraining,
  deleteTraining,
  getTrainingPage,
  updateTraining,
} from '#/api/safety';
import EmployeeSelect from '#/views/hrm/employee/components/select.vue';
import { TRAINING_TYPE_OPTIONS } from '../shared';
defineOptions({ name: 'SafetyTraining' });
const filters = reactive<Record<string, any>>({});
const rows = ref<SafetyApi.Training[]>([]);
const loading = ref(false);
const pagination = reactive({ current: 1, pageSize: 10, total: 0, showSizeChanger: true });
const open = ref(false);
const saving = ref(false);
const formRef = ref<FormInstance>();
const initial = (): SafetyApi.Training => ({
  title: '',
  trainingType: 'SAFETY',
  trainDate: dayjs().format('YYYY-MM-DD'),
  durationHours: 2,
  trainerId: undefined,
  location: '',
  participants: '',
  participantCount: 0,
  content: '',
  examResult: '',
  attachment: '',
  remark: '',
});
const model = reactive<SafetyApi.Training>(initial());
const rules = {
  title: [{ required: true, whitespace: true, message: '请输入培训主题' }],
  trainDate: [{ required: true, message: '请选择培训日期' }],
};
const columns = [
  { title: '培训日期', dataIndex: 'trainDate', width: 130, fixed: 'left' as const },
  { title: '培训主题', dataIndex: 'title', minWidth: 220, ellipsis: true },
  { title: '类型', dataIndex: 'trainingType', width: 140 },
  { title: '学时', dataIndex: 'durationHours', width: 90, align: 'center' as const },
  { title: '讲师/组织人', dataIndex: 'trainer', width: 130 },
  { title: '培训地点', dataIndex: 'location', width: 160, ellipsis: true },
  { title: '参加人数', dataIndex: 'participantCount', width: 110, align: 'center' as const },
  { title: '操作', key: 'action', width: 150, fixed: 'right' as const },
];
async function load(reset = false) {
  if (reset) pagination.current = 1;
  loading.value = true;
  try {
    const params: any = { ...filters, pageNo: pagination.current, pageSize: pagination.pageSize };
    if (params.trainDateRange?.length === 2) {
      params.trainDate = params.trainDateRange;
      delete params.trainDateRange;
    }
    const data = await getTrainingPage(params);
    rows.value = data.list;
    pagination.total = data.total;
  } catch {
    message.error('加载培训记录失败');
    rows.value = [];
  } finally {
    loading.value = false;
  }
}
function resetFilters() {
  Object.keys(filters).forEach((k) => delete filters[k]);
  void load(true);
}
function tableChange(page: any) {
  pagination.current = page.current ?? 1;
  pagination.pageSize = page.pageSize ?? 10;
  void load();
}
function showForm(row?: SafetyApi.Training) {
  Object.assign(model, initial(), row ?? {});
  open.value = true;
  formRef.value?.clearValidate();
}
async function save() {
  await formRef.value?.validate();
  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;
    await load();
  } finally {
    saving.value = false;
  }
}
async function remove(row: SafetyApi.Training) {
  await deleteTraining(row.id!);
  message.success('已删除');
  await load();
}
onMounted(() => load());
</script>
<template>
  <Page
    title="安全培训"
    description="安全培训、三级安全教育、法规宣贯等培训记录的电子化归档,支持签到表与课件附件留存。"
    auto-content-height
  >
    <Card>
      <Form :model="filters" layout="inline" class="safety-filters" @finish="load(true)">
        <FormItem label="培训日期">
          <DatePicker.RangePicker v-model:value="filters.trainDateRange" value-format="YYYY-MM-DD" />
        </FormItem>
        <FormItem label="主题">
          <Input v-model:value="filters.title" allow-clear placeholder="培训主题" style="width: 170px" />
        </FormItem>
        <FormItem label="类型">
          <Select v-model:value="filters.trainingType" :options="TRAINING_TYPE_OPTIONS" allow-clear placeholder="全部" style="width: 150px" />
        </FormItem>
        <FormItem>
          <Space>
            <Button type="primary" :loading="loading" @click="load(true)">查询</Button>
            <Button @click="resetFilters">重置</Button>
          </Space>
        </FormItem>
      </Form>
      <div class="mb-4 flex items-center justify-between">
        <span class="text-base font-semibold">培训记录</span>
        <Button v-access:code="['safety:training:create']" type="primary" @click="showForm()">
          <template #icon><IconifyIcon icon="lucide:plus" /></template>
          æ–°å¢žåŸ¹è®­
        </Button>
      </div>
      <Table
        :columns="columns"
        :data-source="rows"
        row-key="id"
        :loading="loading"
        :pagination="pagination"
        :scroll="{ x: 1300 }"
        size="middle"
        :cell-style="{ padding: '12px 16px' }"
        @change="tableChange"
      >
        <template #bodyCell="{ column, record }">
          <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>
          <template v-else-if="column.key === 'action'">
            <Space :size="0">
              <Button v-access:code="['safety:training:update']" type="link" size="small" @click="showForm(record as SafetyApi.Training)">编辑</Button>
              <Popconfirm title="确定删除该培训记录?" ok-text="确定" cancel-text="取消" @confirm="remove(record as SafetyApi.Training)">
                <Button v-access:code="['safety:training:delete']" type="link" danger size="small">删除</Button>
              </Popconfirm>
            </Space>
          </template>
        </template>
      </Table>
    </Card>
    <Modal
      v-model:open="open"
      :title="model.id ? '编辑培训记录' : '新增培训记录'"
      :width="720"
      :confirm-loading="saving"
      ok-text="保存"
      cancel-text="取消"
      @ok="save"
    >
      <Form ref="formRef" :model="model" :rules="rules" :disabled="saving" layout="vertical" class="safety-form">
        <FormItem label="培训主题" name="title" class="col-span-full">
          <Input v-model:value="model.title" :maxlength="200" placeholder="如 2026 å¹´åº¦å®‰å…¨ç”Ÿäº§è§„程培训" />
        </FormItem>
        <FormItem label="培训类型" name="trainingType">
          <Select v-model:value="model.trainingType" :options="TRAINING_TYPE_OPTIONS" />
        </FormItem>
        <FormItem label="培训日期" name="trainDate">
          <DatePicker v-model:value="model.trainDate" value-format="YYYY-MM-DD" class="w-full" />
        </FormItem>
        <FormItem label="培训学时" name="durationHours">
          <InputNumber v-model:value="model.durationHours" :min="0" :step="0.5" style="width: 100%" />
        </FormItem>
        <FormItem label="培训地点" name="location">
          <Input v-model:value="model.location" :maxlength="200" placeholder="如 ä¸‰æ¥¼åŸ¹è®­å®¤" />
        </FormItem>
        <FormItem label="讲师/组织人" name="trainerId">
          <EmployeeSelect v-model="model.trainerId" placeholder="输入姓名或工号搜索讲师/组织人" />
        </FormItem>
        <FormItem label="参加人数" name="participantCount">
          <InputNumber v-model:value="model.participantCount" :min="0" style="width: 100%" placeholder="留空按名单自动统计" />
        </FormItem>
        <FormItem label="参加人员" name="participants" class="col-span-full">
          <Input v-model:value="model.participants" :maxlength="2000" placeholder="多个姓名用逗号分隔,如 å¼ ä¸‰,李四,王五" />
        </FormItem>
        <FormItem label="培训内容" name="content" class="col-span-full">
          <Input.TextArea v-model:value="model.content" :maxlength="2000" :rows="3" show-count placeholder="培训的主要内容和要求" />
        </FormItem>
        <FormItem label="考核情况" name="examResult" class="col-span-full">
          <Input.TextArea v-model:value="model.examResult" :maxlength="2000" :rows="2" placeholder="如 å…¨å‘˜è€ƒæ ¸åˆæ ¼ï¼Œå¹³å‡åˆ† 92" />
        </FormItem>
        <FormItem label="附件(签到表/课件/照片)" name="attachment" class="col-span-full">
          <Input v-model:value="model.attachment" :maxlength="2000" placeholder="多个 URL ç”¨é€—号分隔" />
        </FormItem>
        <FormItem label="备注" name="remark" class="col-span-full">
          <Input v-model:value="model.remark" :maxlength="500" placeholder="选填" />
        </FormItem>
      </Form>
    </Modal>
  </Page>
</template>
<style scoped>
.safety-filters { gap: 8px 0; margin-bottom: 20px; }
.safety-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 20px; margin-top: 16px; }
@media (max-width: 640px) { .safety-form { grid-template-columns: minmax(0, 1fr); } }
</style>