zjk
2026-05-28 368e38b43e604fff8e2676c74f572ee1e48e5b51
feat(operations/personnel): 新增安全员管理功能

新增安全员管理完整业务功能:
- 新增静态路由配置,包含列表页和新增/编辑子页面
- 新增全套安全员后端API接口,支持查询、增改、状态切换、导入导出
- 实现安全员列表页面,支持搜索、分页、批量操作与导入导出
- 实现安全员新增/编辑页面,包含表单校验和编辑数据回显
2个文件已添加
2个文件已修改
1059 ■■■■■ 已修改文件
src/router/routes.static.ts 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/attendance.ts 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/personnel/safetyOfficerInfo/add.vue 507 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/personnel/safetyOfficerInfo/index.vue 479 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts
@@ -1228,6 +1228,33 @@
    activeMenu: '/operations/personnel/personnelManagement',
  },
  {
    name: 'operationsSafetyOfficerInfo',
    path: '/operations/personnel/safetyOfficerInfo',
    title: '安全员管理',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/operations/personnel/safetyOfficerInfo/index.vue',
    id: 7012,
    keepAlive: false,
    pid: 701,
  },
  {
    name: 'operationsSafetyOfficerInfoAdd',
    path: '/operations/personnel/safetyOfficerInfo/add',
    title: '添加安全员',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/operations/personnel/safetyOfficerInfo/add.vue',
    id: 70121,
    keepAlive: false,
    pid: 701,
    hide: true,
    activeMenu: '/operations/personnel/safetyOfficerInfo',
    fullScreen: true,
  },
  {
    name: 'operationsAttendance',
    path: '/operations/attendance',
    title: '排班考勤',
src/service/api/attendance.ts
@@ -115,6 +115,52 @@
/** 运维人员管理 */
// 获取安全员列表
export function getSafetyOfficerList(params: any) {
  return request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/safetyOfficer/pageList', params)
}
// 新增安全员
export function addSafetyOfficer(params: any) {
  return request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/safetyOfficer/add', params)
}
// 编辑安全员
export function editSafetyOfficer(params: any) {
  return request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/safetyOfficer/edit', params)
}
// 获取安全员详情
export function getSafetyOfficerDetailById(id: number | string) {
  return request.Get<Service.ResponseResult<any>>(`/vehicleConvoy/api/v1/safetyOfficer/detailById?id=${id}`)
}
// 启用禁用安全员
export function updateSafetyOfficerStatus(params: { id: number | string, status: 0 | 1 }) {
  return request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/safetyOfficer/enableDisableById', params)
}
// 安全员导出
export function exportSafetyOfficer(params: any) {
  const method = request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/safetyOfficer/exportExcel', params, { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
// 安全员导入模板
export function exportSafetyOfficerTemplate() {
  const method = request.Get<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/safetyOfficer/importTemplate', {}, { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
// 安全员导入
export function importSafetyOfficer(file: File) {
  const formData = new FormData()
  formData.append('file', file)
  return request.Post<Service.ResponseResult<any>>('/vehicleConvoy/api/v1/safetyOfficer/importExcel', formData)
}
// 获取运维人员列表
export function getOperationsUserList(params: any) {
  return request.Post<Service.ResponseResult<any>>('/account/api/v1/operationsUser/pageList', params)
src/views/operations/personnel/safetyOfficerInfo/add.vue
New file
@@ -0,0 +1,507 @@
<script setup lang="tsx">
import { computed, onMounted, reactive, ref } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NRadio, NRadioGroup, NSpace } from 'naive-ui'
import dayjs from 'dayjs'
import { addSafetyOfficer, editSafetyOfficer, getSafetyOfficerDetailById } from '@/service/api/attendance'
const route = useRoute()
const router = useRouter()
const formRef = ref<FormInst | null>(null)
const saving = ref(false)
const isNationSelectOpen = ref(false)
const isPoliticalStatusSelectOpen = ref(false)
const formModel = reactive({
  name: '',
  idCardNo: '',
  phoneNumber: '',
  birthDate: null as number | null,
  nation: null as number | null,
  politicalStatus: null as number | null,
  contactAddress: '',
  drivingLicenseNo: '',
  firstLicenseDate: null as number | null,
  drivingLicenseDateRange: null as [number, number] | null,
  gender: 1 as 1 | 2,
  status: 1 as 1 | 2,
})
const safetyOfficerId = computed(() => {
  const id = route.query.id
  if (!id)
    return undefined
  return Number(id)
})
const isEditMode = computed(() => route.query.mode === 'edit' && safetyOfficerId.value !== undefined && !Number.isNaN(safetyOfficerId.value))
const nationOptions = [
  { label: '汉族', value: 1 },
  { label: '蒙古族', value: 2 },
  { label: '回族', value: 3 },
  { label: '藏族', value: 4 },
  { label: '维吾尔族', value: 5 },
  { label: '苗族', value: 6 },
  { label: '彝族', value: 7 },
  { label: '壮族', value: 8 },
  { label: '布依族', value: 9 },
  { label: '朝鲜族', value: 10 },
  { label: '满族', value: 11 },
  { label: '侗族', value: 12 },
  { label: '瑶族', value: 13 },
  { label: '白族', value: 14 },
  { label: '土家族', value: 15 },
  { label: '哈尼族', value: 16 },
  { label: '哈萨克族', value: 17 },
  { label: '傣族', value: 18 },
  { label: '黎族', value: 19 },
  { label: '傈僳族', value: 20 },
  { label: '佤族', value: 21 },
  { label: '畲族', value: 22 },
  { label: '高山族', value: 23 },
  { label: '拉祜族', value: 24 },
  { label: '水族', value: 25 },
  { label: '东乡族', value: 26 },
  { label: '纳西族', value: 27 },
  { label: '景颇族', value: 28 },
  { label: '柯尔克孜族', value: 29 },
  { label: '土族', value: 30 },
  { label: '达斡尔族', value: 31 },
  { label: '仫佬族', value: 32 },
  { label: '羌族', value: 33 },
  { label: '布朗族', value: 34 },
  { label: '撒拉族', value: 35 },
  { label: '毛南族', value: 36 },
  { label: '仡佬族', value: 37 },
  { label: '锡伯族', value: 38 },
  { label: '阿昌族', value: 39 },
  { label: '普米族', value: 40 },
  { label: '塔吉克族', value: 41 },
  { label: '怒族', value: 42 },
  { label: '乌孜别克族', value: 43 },
  { label: '俄罗斯族', value: 44 },
  { label: '鄂温克族', value: 45 },
  { label: '德昂族', value: 46 },
  { label: '保安族', value: 47 },
  { label: '裕固族', value: 48 },
  { label: '京族', value: 49 },
  { label: '塔塔尔族', value: 50 },
  { label: '独龙族', value: 51 },
  { label: '鄂伦春族', value: 52 },
  { label: '赫哲族', value: 53 },
  { label: '门巴族', value: 54 },
  { label: '珞巴族', value: 55 },
  { label: '基诺族', value: 56 },
]
const politicalStatusOptions = [
  { label: '中共党员', value: 1 },
  { label: '中共预备党员', value: 2 },
  { label: '共青团员', value: 3 },
  { label: '民革党员', value: 4 },
  { label: '民盟盟员', value: 5 },
]
const rules: FormRules = {
  name: [
    { required: true, message: '请输入安全员姓名', trigger: ['blur', 'input'] },
  ],
  idCardNo: [
    { required: true, message: '请输入身份证号', trigger: ['blur', 'input'] },
  ],
  phoneNumber: [
    { required: true, message: '请输入联系电话', trigger: ['blur', 'input'] },
  ],
  birthDate: [
    { required: true, type: 'number', message: '请选择出生日期', trigger: ['change', 'blur'] },
  ],
  gender: [
    { required: true, type: 'number', message: '请选择性别', trigger: 'change' },
  ],
  nation: [
    { required: true, type: 'number', message: '请选择民族', trigger: 'change' },
  ],
  politicalStatus: [
    { required: true, type: 'number', message: '请选择政治面貌', trigger: 'change' },
  ],
  contactAddress: [
    { required: true, message: '请输入通信地址', trigger: ['blur', 'input'] },
  ],
  drivingLicenseNo: [
    { required: true, message: '请输入机动车驾驶证号', trigger: ['blur', 'input'] },
  ],
  firstLicenseDate: [
    { required: true, type: 'number', message: '请选择初次领证日期', trigger: ['change', 'blur'] },
  ],
  drivingLicenseDateRange: [
    { required: true, type: 'array', message: '请选择驾驶证有效期', trigger: 'change' },
  ],
  status: [
    { required: true, type: 'number', message: '请选择状态', trigger: 'change' },
  ],
}
const pageTitle = computed(() => isEditMode.value ? '编辑安全员' : '添加安全员')
function formatDate(value: number | null) {
  if (!value)
    return ''
  return dayjs(value).format('YYYY-MM-DD')
}
function parseDate(value?: string | null) {
  if (!value)
    return null
  const dateValue = dayjs(value)
  return dateValue.isValid() ? dateValue.valueOf() : null
}
function setFormData(detail: any) {
  formModel.name = detail?.name || ''
  formModel.idCardNo = detail?.idCardNo || ''
  formModel.phoneNumber = detail?.phoneNumber || ''
  formModel.birthDate = parseDate(detail?.birthDate)
  formModel.nation = detail?.nation ? Number(detail.nation) : null
  formModel.politicalStatus = detail?.politicalStatus ? Number(detail.politicalStatus) : null
  formModel.contactAddress = detail?.contactAddress || ''
  formModel.drivingLicenseNo = detail?.drivingLicenseNo || ''
  formModel.firstLicenseDate = parseDate(detail?.firstLicenseDate)
  formModel.drivingLicenseDateRange = detail?.drivingLicenseStartDate && detail?.drivingLicenseEndDate
    ? [parseDate(detail.drivingLicenseStartDate) as number, parseDate(detail.drivingLicenseEndDate) as number]
    : null
  formModel.gender = detail?.gender === 2 ? 2 : 1
  if (typeof detail?.status === 'number')
    formModel.status = detail.status === 0 ? 2 : 1
  else if (typeof detail?.deleted === 'boolean')
    formModel.status = detail.deleted ? 2 : 1
}
async function getDetail() {
  if (!isEditMode.value || safetyOfficerId.value === undefined)
    return
  try {
    const res = await getSafetyOfficerDetailById(safetyOfficerId.value)
    if ((res.code === 200 || res.code === 0) && res.data) {
      setFormData(res.data)
      return
    }
    window.$message?.warning(res.msg || '获取安全员详情失败')
  }
  catch (error) {
    console.error('获取安全员详情失败:', error)
    window.$message?.error('获取安全员详情失败')
  }
}
async function handleSave() {
  try {
    saving.value = true
    await formRef.value?.validate()
    const [drivingLicenseStartDate, drivingLicenseEndDate] = formModel.drivingLicenseDateRange || []
    const params = {
      birthDate: formatDate(formModel.birthDate),
      contactAddress: formModel.contactAddress.trim(),
      drivingLicenseEndDate: formatDate(drivingLicenseEndDate ?? null),
      drivingLicenseNo: formModel.drivingLicenseNo.trim(),
      drivingLicenseStartDate: formatDate(drivingLicenseStartDate ?? null),
      firstLicenseDate: formatDate(formModel.firstLicenseDate),
      gender: formModel.gender,
      idCardNo: formModel.idCardNo.trim(),
      name: formModel.name.trim(),
      nation: String(formModel.nation ?? ''),
      phoneNumber: formModel.phoneNumber.trim(),
      politicalStatus: String(formModel.politicalStatus ?? ''),
      status: formModel.status,
    }
    const res = isEditMode.value
      ? await editSafetyOfficer({
          ...params,
          id: safetyOfficerId.value,
        })
      : await addSafetyOfficer(params)
    if (res.code === 200 || res.code === 0) {
      window.sessionStorage.setItem('safetyOfficerInfoNeedRefresh', '1')
      window.$message?.success(isEditMode.value ? '编辑安全员成功' : '新增安全员成功')
      router.back()
    }
  }
  finally {
    saving.value = false
  }
}
function handleCancel() {
  router.back()
}
onMounted(() => {
  getDetail()
})
</script>
<template>
  <div class="w-full h-full relative content flex flex-col">
    <div class="p-12px main flex flex-col flex-1 overflow-hidden">
      <n-form
        ref="formRef"
        :model="formModel"
        :rules="rules"
        label-placement="left"
        require-mark-placement="left"
      >
        <div class="box">
          <div class="section-header">
            <div class="section-title">
              {{ pageTitle }}
            </div>
          </div>
          <n-grid :cols="3" :x-gap="24" :y-gap="8">
            <n-gi>
              <n-form-item label="安全员姓名" path="name">
                <n-input v-model:value="formModel.name" placeholder="请输入" clearable />
              </n-form-item>
            </n-gi>
            <n-gi>
              <n-form-item label="身份证号" path="idCardNo">
                <n-input v-model:value="formModel.idCardNo" placeholder="请输入" clearable />
              </n-form-item>
            </n-gi>
            <n-gi>
              <n-form-item label="联系电话" path="phoneNumber">
                <n-input v-model:value="formModel.phoneNumber" placeholder="请输入" clearable />
              </n-form-item>
            </n-gi>
            <n-gi>
              <n-form-item label="出生日期" path="birthDate">
                <n-date-picker
                  v-model:value="formModel.birthDate"
                  type="date"
                  placeholder="请选择"
                  clearable
                  format="yyyy/MM/dd"
                  :actions="[]"
                  style="width: 100%"
                />
              </n-form-item>
            </n-gi>
            <n-gi>
              <n-form-item label="性别" path="gender">
                <NRadioGroup v-model:value="formModel.gender">
                  <NSpace>
                    <NRadio :value="1">
                      男
                    </NRadio>
                    <NRadio :value="2">
                      女
                    </NRadio>
                  </NSpace>
                </NRadioGroup>
              </n-form-item>
            </n-gi>
            <n-gi>
              <n-form-item label="民族" path="nation">
                <n-select
                  v-model:value="formModel.nation"
                  placeholder="请选择"
                  clearable
                  :options="nationOptions"
                  @update:show="(val: boolean) => (isNationSelectOpen = val)"
                >
                  <template #arrow>
                    <img
                      src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
                      class="arrow-icon"
                      :class="{ 'arrow-icon--open': isNationSelectOpen }"
                      style="width: 16px; height: 16px"
                      alt="arrow"
                    >
                  </template>
                  <template #empty>
                    <div class="flex-center">
                      <img
                        src="/src/assets/element-img/no-data.png"
                        style="width: 50%"
                        alt="no-data"
                      >
                    </div>
                  </template>
                </n-select>
              </n-form-item>
            </n-gi>
            <n-gi>
              <n-form-item label="政治面貌" path="politicalStatus">
                <n-select
                  v-model:value="formModel.politicalStatus"
                  :options="politicalStatusOptions"
                  placeholder="请选择"
                  clearable
                  @update:show="(val: boolean) => (isPoliticalStatusSelectOpen = val)"
                >
                  <template #arrow>
                    <img
                      src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
                      class="arrow-icon"
                      :class="{ 'arrow-icon--open': isPoliticalStatusSelectOpen }"
                      style="width: 16px; height: 16px"
                      alt="arrow"
                    >
                  </template>
                  <template #empty>
                    <div class="flex-center">
                      <img
                        src="/src/assets/element-img/no-data.png"
                        style="width: 50%"
                        alt="no-data"
                      >
                    </div>
                  </template>
                </n-select>
              </n-form-item>
            </n-gi>
            <n-gi>
              <n-form-item label="通信地址" path="contactAddress">
                <n-input v-model:value="formModel.contactAddress" placeholder="请输入" clearable />
              </n-form-item>
            </n-gi>
          </n-grid>
        </div>
        <div class="box mt-12px">
          <n-grid :cols="3" :x-gap="24" :y-gap="8">
            <n-gi>
              <n-form-item label="机动车驾驶证号" path="drivingLicenseNo">
                <n-input v-model:value="formModel.drivingLicenseNo" placeholder="请输入" clearable />
              </n-form-item>
            </n-gi>
            <n-gi>
              <n-form-item label="初次领证日期" path="firstLicenseDate">
                <n-date-picker
                  v-model:value="formModel.firstLicenseDate"
                  type="date"
                  placeholder="请选择"
                  clearable
                  format="yyyy/MM/dd"
                  :actions="[]"
                  style="width: 100%"
                />
              </n-form-item>
            </n-gi>
            <n-gi>
              <n-form-item label="驾驶证有效期" path="drivingLicenseDateRange">
                <n-date-picker
                  v-model:value="formModel.drivingLicenseDateRange"
                  type="daterange"
                  placeholder="开始时间 - 结束时间"
                  clearable
                  format="yyyy/MM/dd"
                  :actions="[]"
                  style="width: 100%"
                  close-on-select
                />
              </n-form-item>
            </n-gi>
          </n-grid>
        </div>
        <div class="box mt-12px">
          <n-form-item label="状态" path="status">
            <NRadioGroup v-model:value="formModel.status">
              <NSpace>
                <NRadio :value="1">
                  启用
                </NRadio>
                <NRadio :value="2">
                  停用
                </NRadio>
              </NSpace>
            </NRadioGroup>
          </n-form-item>
        </div>
      </n-form>
    </div>
    <div class="footer">
      <NSpace justify="end" :size="8">
        <NButton class="w-84px" @click="handleCancel">
          取消
        </NButton>
        <NButton type="primary" class="w-84px" :loading="saving" @click="handleSave">
          保存
        </NButton>
      </NSpace>
    </div>
  </div>
</template>
<style scoped>
.content {
  height: calc(100vh - 76px);
  width: 100%;
  overflow-y: auto;
  scrollbar-width: none;
  font-size: 13px;
}
.box {
  padding: 24px;
  padding-bottom: 16px;
  border-radius: 6px;
  background: #fff;
}
.content::-webkit-scrollbar {
  display: none;
}
.main {
  padding-bottom: 88px;
}
.section-card {
  border-radius: 8px;
}
.section-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 12px;
}
.section-title {
  position: relative;
  padding-left: 10px;
  font-size: 16px;
  font-weight: 600;
  color: #171f26;
}
.section-title::before {
  content: '';
  position: absolute;
  left: 0;
  top: 50%;
  transform: translateY(-50%);
  width: 3px;
  height: 16px;
  background: #0068ff;
  border-radius: 2px;
}
.footer {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  padding: 12px 24px;
  background: #fff;
  border-top: 1px solid #f0f0f0;
}
</style>
src/views/operations/personnel/safetyOfficerInfo/index.vue
New file
@@ -0,0 +1,479 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { computed, onActivated, onMounted, ref } from 'vue'
import dayjs from 'dayjs'
import { NButton, NTag, useDialog } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { useRouter } from 'vue-router'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import Pagination from '@/components/common/Pagination.vue'
import OperationLogModal from '@/components/custom/OperationLogModal.vue'
import { exportSafetyOfficer, exportSafetyOfficerTemplate, getSafetyOfficerList, importSafetyOfficer, updateSafetyOfficerStatus } from '@/service/api/attendance'
import { downloadFile } from '@/utils'
interface SafetyOfficerItem {
  id: number
  name: string
  idCardNo: string
  phoneNumber: string
  gender: number
  birthDate: string
  drivingLicenseNo: string
  createBy: string
  createTime: string
  deleted: boolean
  contactAddress?: string
  drivingLicenseEndDate?: string
  drivingLicenseStartDate?: string
  firstLicenseDate?: string
  nation?: string
  politicalStatus?: string
  updateBy?: string
  updateTime?: string
}
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const router = useRouter()
const dialog = useDialog()
const searchModel = ref({
  id: null as number | null,
  name: '',
  idCardNo: '',
  phoneNumber: '',
  drivingLicenseNo: '',
  createTimeRange: null as [number, number] | null,
  status: null as number | null,
})
const pageParams = ref({
  pageNum: 1,
  pageSize: 10,
})
const isStatusSelectOpen = ref(false)
const showOperationLogModal = ref(false)
const checkedRowKeys = ref<Array<string | number>>([])
const listData = ref<SafetyOfficerItem[]>([])
const total = ref(0)
const fileInputRef = ref<HTMLInputElement | null>(null)
const statusOptions = [
  { label: '启用', value: 1 },
  { label: '停用', value: 2 },
]
function buildQueryParams() {
  return {
    id: searchModel.value.id ?? undefined,
    name: searchModel.value.name?.trim() || undefined,
    idCardNo: searchModel.value.idCardNo?.trim() || undefined,
    phoneNumber: searchModel.value.phoneNumber?.trim() || undefined,
    drivingLicenseNo: searchModel.value.drivingLicenseNo?.trim() || undefined,
    pageNum: pageParams.value.pageNum,
    pageSize: pageParams.value.pageSize,
  }
}
async function getList() {
  startLoading()
  try {
    const res = await getSafetyOfficerList(buildQueryParams())
    if (res.code === 200 || res.code === 0) {
      listData.value = res.data?.records || []
      total.value = Number(res.data?.total ?? 0)
      return
    }
    listData.value = []
    total.value = 0
  }
  catch (error) {
    console.error('获取安全员列表失败:', error)
    listData.value = []
    total.value = 0
  }
  finally {
    endLoading()
  }
}
function handleSearch() {
  pageParams.value.pageNum = 1
  getList()
}
function handleReset() {
  searchModel.value = {
    id: null,
    name: '',
    idCardNo: '',
    phoneNumber: '',
    drivingLicenseNo: '',
    createTimeRange: null,
    status: null,
  }
  pageParams.value.pageNum = 1
  getList()
}
function changePage(page: number, pageSize: number) {
  pageParams.value.pageNum = page
  pageParams.value.pageSize = pageSize
  getList()
}
function handleCheck(rowKeys: Array<string | number>) {
  checkedRowKeys.value = rowKeys
}
async function handleBatchExport() {
  if (checkedRowKeys.value.length === 0) {
    window.$message?.warning('请先选择需要导出的记录')
    return
  }
  try {
    const response = await exportSafetyOfficer({
      ...buildQueryParams(),
      // 按批量导出语义优先传勾选 id,同时兼容后端常见 ids 入参
      ids: checkedRowKeys.value,
    })
    if (response instanceof Blob) {
      downloadFile(response, `安全员列表_${dayjs().format('YYYYMMDDHHmmss')}.xlsx`)
      window.$message?.success('导出成功')
    }
  }
  catch (error) {
    console.error('导出安全员失败:', error)
  }
}
function handleBatchImport() {
  fileInputRef.value?.click()
}
async function handleFileChange(event: Event) {
  const target = event.target as HTMLInputElement
  const file = target.files?.[0]
  if (!file)
    return
  try {
    // 复用项目内统一的 excel 导入方式,成功后刷新列表
    const res = await importSafetyOfficer(file)
    if (res.code === 200 || res.code === 0) {
      window.$message?.success(res.msg || '导入成功')
      getList()
    }
  }
  catch (error) {
    console.error('导入安全员失败:', error)
  }
  finally {
    // 允许重复选择同一个文件时也能触发 change
    target.value = ''
  }
}
async function handleDownloadTemplate() {
  try {
    const response = await exportSafetyOfficerTemplate()
    if (response instanceof Blob) {
      downloadFile(response, '安全员导入模板.xlsx')
      window.$message?.success('下载成功')
    }
  }
  catch (error) {
    console.error('下载安全员导入模板失败:', error)
  }
}
function handleOpenOperationLog() {
  showOperationLogModal.value = true
}
function handleAdd() {
  router.push({ name: 'operationsSafetyOfficerInfoAdd' })
}
function handleEdit(row: SafetyOfficerItem) {
  router.push({
    name: 'operationsSafetyOfficerInfoAdd',
    query: {
      mode: 'edit',
      id: String(row.id),
    },
  })
}
async function handleToggleStatus(row: SafetyOfficerItem) {
  const nextStatus = row.deleted ? 1 : 0
  const actionText = nextStatus === 1 ? '启用' : '停用'
  dialog.warning({
    title: `是否确认${actionText}该安全员?`,
    content: ``,
    positiveText: '确定',
    negativeText: '取消',
    maskClosable: false,
    autoFocus: false,
    onPositiveClick: async () => {
      try {
        const res = await updateSafetyOfficerStatus({
          id: row.id,
          status: nextStatus as 0 | 1,
        })
        if (res.code === 200 || res.code === 0) {
          window.$message?.success(`${actionText}成功`)
          getList()
        }
      }
      catch (error) {
        console.error(`${actionText}安全员失败:`, error)
      }
    },
  })
}
const filterItems = computed(() => [
  {
    label: 'ID',
    path: 'id',
    render: () => <n-input v-model:value={searchModel.value.id} placeholder="请输入" clearable />,
  },
  {
    label: '安全员姓名',
    path: 'name',
    render: () => <n-input v-model:value={searchModel.value.name} placeholder="请输入" clearable />,
  },
  {
    label: '身份证号',
    path: 'idCardNo',
    render: () => <n-input v-model:value={searchModel.value.idCardNo} placeholder="请输入" clearable />,
  },
  {
    label: '联系电话',
    path: 'phoneNumber',
    render: () => <n-input v-model:value={searchModel.value.phoneNumber} placeholder="请输入" clearable />,
  },
  {
    label: '机动车驾驶证号',
    path: 'drivingLicenseNo',
    render: () => <n-input v-model:value={searchModel.value.drivingLicenseNo} placeholder="请输入" clearable />,
  },
  {
    label: '创建时间',
    path: 'createTimeRange',
    render: () => (
      <n-date-picker
        v-model:value={searchModel.value.createTimeRange}
        type="daterange"
        clearable
        format="yyyy/MM/dd"
        placeholder="开始时间 - 结束时间"
        style={{ width: '320px' }}
        actions={null}
      />
    ),
  },
  {
    label: '状态',
    path: 'status',
    render: () => (
      <n-select
        v-model:value={searchModel.value.status}
        options={statusOptions}
        placeholder="请选择"
        clearable
        onUpdate:show={(val: boolean) => isStatusSelectOpen.value = val}
      >
        {{
          arrow: () => (
            <img
              src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
              class={`arrow-icon ${isStatusSelectOpen.value ? 'arrow-icon--open' : ''}`}
              style={{ width: '16px', height: '16px' }}
              alt="arrow"
            />
          ),
          empty: () => (
            <div class="flex-center">
              <img src="/src/assets/element-img/no-data.png" style={{ width: '50%' }} alt="no-data" />
            </div>
          ),
        }}
      </n-select>
    ),
  },
])
const columns = computed<DataTableColumns<SafetyOfficerItem>>(() => [
  { type: 'selection', width: 50, fixed: 'left' },
  { title: 'ID', key: 'id', width: 80, resizable: true, align: 'left' },
  { title: '安全员姓名', key: 'name', width: 140, resizable: true, align: 'left' },
  { title: '身份证号', key: 'idCardNo', width: 160, resizable: true, align: 'left' },
  { title: '联系电话', key: 'phoneNumber', width: 140, resizable: true, align: 'left' },
  {
    title: '性别',
    key: 'gender',
    width: 80,
    resizable: true,
    align: 'left',
    render: row => row.gender === 1 ? '男' : row.gender === 2 ? '女' : '-',
  },
  {
    title: '出生日期',
    key: 'birthDate',
    width: 120,
    resizable: true,
    align: 'left',
    render: row => row.birthDate ? dayjs(row.birthDate).format('YYYY/MM/DD') : '-',
  },
  { title: '机动车驾驶证号', key: 'drivingLicenseNo', width: 180, resizable: true, align: 'left' },
  { title: '创建人', key: 'createBy', width: 120, resizable: true, align: 'left' },
  {
    title: '创建时间',
    key: 'createTime',
    width: 160,
    resizable: true,
    align: 'left',
    render: row => row.createTime ? dayjs(row.createTime).format('YYYY/MM/DD HH:mm') : '-',
  },
  {
    title: '状态',
    key: 'deleted',
    width: 100,
    resizable: true,
    align: 'left',
    render: row => (
      <NTag bordered={false} type={row.deleted ? 'default' : 'success'}>
        {row.deleted ? '停用' : '启用'}
      </NTag>
    ),
  },
  {
    title: '操作',
    key: 'actions',
    width: 140,
    fixed: 'right',
    align: 'left',
    render: row => (
      <n-space size={12}>
        <NButton text type="primary" onClick={() => handleEdit(row)}>
          编辑
        </NButton>
        <NButton text type="primary" onClick={() => handleToggleStatus(row)}>
          {row.deleted ? '启用' : '停用'}
        </NButton>
      </n-space>
    ),
  },
])
onMounted(() => {
  getList()
})
onActivated(() => {
  if (window.sessionStorage.getItem('safetyOfficerInfoNeedRefresh') === '1') {
    window.sessionStorage.removeItem('safetyOfficerInfoNeedRefresh')
    getList()
  }
})
</script>
<template>
  <div class="roboTaxi w-full h-full">
    <div class="roboTaxi-form-card mb-12px">
      <n-form :model="searchModel" label-placement="left" inline :show-feedback="false">
        <SearchPanel :default-collapsed="false">
          <n-gi v-for="item in filterItems" :key="item.path" class="n-grid-item">
            <n-form-item :label="item.label" :path="item.path">
              <component :is="item.render" />
            </n-form-item>
          </n-gi>
          <template #action>
            <NButton @click="handleReset">
              重置
            </NButton>
            <NButton type="primary" @click="handleSearch">
              查询
            </NButton>
          </template>
        </SearchPanel>
      </n-form>
    </div>
    <n-card
      class="flex-1 robotaxi-table-card"
      content-style="display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0;"
    >
      <template #header>
        <n-flex justify="space-between" align="center">
          <n-flex align="center">
            <span v-if="checkedRowKeys.length > 0" style="color: rgba(23, 31, 38, 0.6); font-weight: 400;">
              已选择{{ checkedRowKeys.length }}条记录
            </span>
            <NButton :disabled="checkedRowKeys.length === 0" @click="handleBatchExport">
              批量导出
            </NButton>
            <NButton @click="handleBatchImport">
              批量导入
            </NButton>
            <NButton @click="handleDownloadTemplate">
              下载导入模板
            </NButton>
            <NButton @click="handleOpenOperationLog">
              操作日志
            </NButton>
          </n-flex>
          <NButton type="primary" secondary @click="handleAdd">
            新增
          </NButton>
        </n-flex>
      </template>
      <div class="flex-1 flex flex-col h-full" style="min-width: 0; overflow: hidden;">
        <n-data-table
          class="flex-1"
          :columns="columns"
          :data="listData"
          :loading="loading"
          :bordered="false"
          :row-key="(row: SafetyOfficerItem) => row.id"
          flex-height
          :scroll-x="1560"
          :header-height="18"
          :row-height="18"
          @update:checked-row-keys="handleCheck"
        >
          <template #empty>
            <div class="flex-center">
              <img
                src="/src/assets/element-img/no-data.png"
                style="width: 123px; height: 125px"
                @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }"
              >
            </div>
          </template>
        </n-data-table>
        <Pagination
          :count="total"
          :page="pageParams.pageNum"
          :page-size="pageParams.pageSize"
          class="robotaxi-pagination mt-12px"
          @change="changePage"
        />
      </div>
    </n-card>
    <OperationLogModal v-model:show="showOperationLogModal" :business-type="57" />
    <input
      ref="fileInputRef"
      type="file"
      style="display: none"
      accept=".xlsx, .xls"
      @change="handleFileChange"
    >
  </div>
</template>