zjk
2026-04-10 2f3d4610a475a68f1cfb220463d33ba3f3c08d8e
feat: 新增车型管理和点位现场信息模块并优化现有功能
4个文件已添加
4个文件已修改
1379 ■■■■■ 已修改文件
src/router/routes.static.ts 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleManagement/inventoryVehicle/add.vue 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleModel/components/AddOrEditModal.vue 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/assetManagement/vehicleModel/index.vue 231 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/pointManagement/list.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/siteInfo/detail.vue 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/businessOperations/siteInfo/index.vue 534 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/attendance/shift/components/ShiftFormModal.vue 255 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts
@@ -525,6 +525,32 @@
    activeMenu: 'businessOperations/pointManagement/map',
  },
  {
    name: 'siteInfoList',
    path: '/businessOperations/siteInfoList',
    title: '点位现场信息',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/businessOperations/siteInfo/index.vue',
    id: 4062,
    keepAlive: true,
    pid: 4,
  },
  {
    name: 'siteInfoDetail',
    path: '/businessOperations/siteInfoList/detail',
    title: '点位现场情况',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/businessOperations/siteInfo/detail.vue',
    id: 4063,
    keepAlive: false,
    hide: true,
    activeMenu: '/businessOperations/siteInfoList',
    pid: 4,
  },
  {
    name: 'cruise',
    path: '/businessOperations/cruise',
    title: '巡游',
@@ -705,6 +731,18 @@
    pid: 5,
  },
  {
    name: 'vehicleModel',
    path: '/assetManagement/vehicleModel',
    title: '车型管理',
    requiresAuth: true,
    icon: 'icon-park-outline:car',
    menuType: 'page',
    componentPath: '/assetManagement/vehicleModel/index.vue',
    id: 504,
    keepAlive: false,
    pid: 5,
  },
  {
    name: 'fleetManagementDetail',
    path: '/assetManagement/fleetManagement/detail',
    title: '车队详情',
src/views/assetManagement/vehicleManagement/inventoryVehicle/add.vue
@@ -34,7 +34,7 @@
  vehicleCode: '', // 车辆编码
  vin: '', // 车辆VIN码
  plateNumber: '', // 车牌号
  model: '', // 型号
  model: null, // 型号
  vehicleColor: '', // 车辆颜色
  vehiclePhotos: [] as UploadFileInfo[], // 车辆照片
  purchaseSource: '', // 采购来源
@@ -55,6 +55,24 @@
// 运营区域选项
const operationAreaOptions = ref<{ label: string, value: string | number }[]>([])
const isOperationAreaSelectOpen = ref(false)
// 车辆型号选项
const modelOptions = ref<{ label: string, value: string | number }[]>([])
const isModelSelectOpen = ref(false)
// 获取车辆型号列表(预留接口)
async function getVehicleModelList() {
  try {
    // const { data } = await fetchVehicleModelList() // TODO: 对接获取车辆型号接口
    // modelOptions.value = data.map((item: any) => ({
    //   label: item.modelName,
    //   value: item.modelId
    // }))
  }
  catch (error) {
    console.error('获取型号列表失败:', error)
  }
}
// 获取运营区域列表
async function getOperationAreaList() {
@@ -77,6 +95,7 @@
onMounted(() => {
  getOperationAreaList()
  getVehicleModelList()
})
// 表单验证规则
@@ -107,8 +126,7 @@
    { required: true, type: 'array', message: '请上传车辆照片', trigger: ['change'] },
  ],
  model: [
    { required: true, message: '请输入型号', trigger: ['blur', 'input'] },
    { min: 1, max: 50, message: '型号长度为1-50位', trigger: ['blur', 'input'] },
    { required: true, message: '请选择型号', trigger: ['blur', 'change'] },
  ],
  purchaseSource: [
    { required: true, message: '请输入采购来源', trigger: ['blur', 'input'] },
@@ -507,11 +525,34 @@
                <!-- 型号 -->
                <n-gi>
                  <n-form-item label="型号" path="model">
                    <n-input
                    <n-select
                      v-model:value="formModel.model"
                      placeholder="请输入"
                      :options="modelOptions"
                      placeholder="请选择"
                      clearable
                    />
                      @update:show="isModelSelectOpen = $event"
                    >
                      <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': isModelSelectOpen,
                          }"
                          style="width: 16px; height: 16px"
                          alt="arrow"
                        >
                      </template>
                      <template #empty>
                        <div class="flex-center">
                          <img
                            :src="noDataIcon"
                            style="width: 50%"
                            alt="no-data"
                          >
                        </div>
                      </template>
                    </n-select>
                  </n-form-item>
                </n-gi>
src/views/assetManagement/vehicleModel/components/AddOrEditModal.vue
New file
@@ -0,0 +1,124 @@
<script setup lang="ts">
import { ref } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
import { NButton, NForm, NFormItem, NInput, NInputNumber, NModal, NSpace, useMessage } from 'naive-ui'
const emit = defineEmits<{
  refresh: []
}>()
const message = useMessage()
const visible = ref(false)
const loading = ref(false)
const isEdit = ref(false)
const formRef = ref<FormInst | null>(null)
const defaultModel = {
  id: undefined as string | undefined,
  modelName: '',
  seats: null as number | null,
}
const formModel = ref({ ...defaultModel })
const rules: FormRules = {
  modelName: {
    required: true,
    message: '请输入车型名称',
    trigger: ['blur', 'input'],
  },
  seats: {
    required: true,
    type: 'number',
    message: '请输入座位数',
    trigger: ['blur', 'change'],
  },
}
function openModal(row?: any) {
  isEdit.value = !!row
  if (row) {
    formModel.value = { ...row }
  }
  else {
    formModel.value = { ...defaultModel }
  }
  visible.value = true
}
function handleCancel() {
  visible.value = false
}
function handleSubmit() {
  formRef.value?.validate((errors) => {
    if (!errors) {
      loading.value = true
      // 模拟提交
      setTimeout(() => {
        loading.value = false
        message.success(isEdit.value ? '编辑成功' : '新增成功')
        visible.value = false
        emit('refresh')
      }, 500)
    }
  })
}
defineExpose({
  openModal,
})
</script>
<template>
  <NModal
    v-model:show="visible"
    preset="card"
    :title="isEdit ? '编辑车型' : '新增车型'"
    style="width: 400px; border-radius: 8px;"
    :segmented="{ content: true }"
    content-style="padding: 24px;"
    footer-style="padding: 16px 24px;"
  >
    <NForm
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="top"
      require-mark-placement="left"
    >
      <NFormItem label="车型名称" path="modelName">
        <NInput
          v-model:value="formModel.modelName"
          placeholder="请输入"
          clearable
        />
      </NFormItem>
      <NFormItem label="座位数" path="seats">
        <NInputNumber
          v-model:value="formModel.seats"
          placeholder="请输入"
          clearable
          :min="1"
          :show-button="false"
          style="width: 100%"
        />
      </NFormItem>
    </NForm>
    <template #footer>
      <NSpace justify="end">
        <NButton @click="handleCancel">
          取消
        </NButton>
        <NButton
          type="primary"
          :loading="loading"
          @click="handleSubmit"
        >
          保存
        </NButton>
      </NSpace>
    </template>
  </NModal>
</template>
src/views/assetManagement/vehicleModel/index.vue
New file
@@ -0,0 +1,231 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { computed, onMounted, reactive, ref } from 'vue'
import { NButton, NSpace } from 'naive-ui'
import { useBoolean } from '@/hooks'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import Pagination from '@/components/common/Pagination.vue'
import AddOrEditModal from './components/AddOrEditModal.vue'
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const dialog = useDialog()
const addOrEditModalRef = ref()
const initialModel = {
  modelName: '',
}
const model = ref({ ...initialModel })
const pagination = reactive({
  page: 1,
  pageSize: 10,
})
function handleResetSearch() {
  model.value = { ...initialModel }
  pagination.page = 1
  pagination.pageSize = 10
  handleSearch()
}
function handleSearch() {
  pagination.page = 1
  getList()
}
const filterItems = computed(() => [
  {
    label: '车型名称',
    path: 'modelName',
    render: () => <n-input v-model:value={model.value.modelName} placeholder="请输入" clearable />,
  },
])
const count = ref(0)
const listData = ref<any[]>([])
// 表格列定义
const columns = computed<DataTableColumns<any>>(() => [
  {
    title: '序号',
    key: 'index',
    width: 60,
    resizable: true,
    render: (_, index) => {
      return (pagination.page - 1) * pagination.pageSize + index + 1
    },
  },
  {
    title: '创建时间',
    key: 'createTime',
    width: 160,
    resizable: true,
  },
  {
    title: '车型名称',
    key: 'modelName',
    width: 300,
    resizable: true,
  },
  {
    title: '座位数',
    key: 'seats',
    width: 100,
    resizable: true,
  },
  {
    title: '操作',
    key: 'actions',
    width: 100,
    fixed: 'right',
    render: (row) => {
      return (
        <NSpace wrap={false} size={8}>
          <NButton
            text
            type="primary"
            onClick={() => handleEdit(row)}
          >
            编辑
          </NButton>
          <NButton
            text
            type="primary"
            onClick={() => handleDelete(row)}
          >
            删除
          </NButton>
        </NSpace>
      )
    },
  },
])
async function getList() {
  startLoading()
  // Mock data simulation
  setTimeout(() => {
    const mockData = Array.from({ length: pagination.pageSize }).map((_, index) => {
      const idx = (pagination.page - 1) * pagination.pageSize + index + 1
      return {
        id: `mock-id-${idx}`,
        createTime: '2025-05-07 14:53',
        modelName: idx === 1 ? 'Robotaxi R1' : 'Robotaxi X1',
        seats: idx === 1 ? 4 : (idx === 2 ? 6 : 4),
      }
    })
    count.value = 1000
    listData.value = mockData
    endLoading()
  }, 500)
}
function handleAdd() {
  addOrEditModalRef.value?.openModal()
}
function handleEdit(row: any) {
  addOrEditModalRef.value?.openModal(row)
}
function handleDelete(row: any) {
  dialog.create({
    title: '是否确认删除',
    content: `确认删除车型 ${row.modelName} 吗?`,
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: () => {
      window.$message?.success('删除成功')
      getList()
    },
  })
}
function changePage(page: number, pageSize: number) {
  pagination.page = page
  pagination.pageSize = pageSize
  getList()
}
onMounted(() => {
  getList()
})
</script>
<template>
  <div class="roboTaxi w-full h-full">
    <div class="flex-1 flex flex-col">
      <div class="roboTaxi-form-card mb-12px">
        <n-form
          :model="model"
          label-placement="left"
          inline
          :show-feedback="false"
        >
          <SearchPanel :default-collapsed="false">
            <n-gi v-for="item in filterItems" :key="item.path">
              <n-form-item :label="item.label" :path="item.path">
                <component :is="item.render" />
              </n-form-item>
            </n-gi>
            <template #action>
              <NButton @click="handleResetSearch">
                重置
              </NButton>
              <NButton type="primary" @click="handleSearch">
                查询
              </NButton>
            </template>
          </SearchPanel>
        </n-form>
      </div>
      <n-card class="flex-1 robotaxi-table-card">
        <template #header>
          <n-flex justify="flex-end">
            <NButton
              type="primary"
              secondary
              @click="handleAdd"
            >
              新增车型
            </NButton>
          </n-flex>
        </template>
        <div class="flex-1 flex flex-col h-full">
          <n-data-table
            class="flex-1"
            :columns="columns"
            :data="listData"
            :loading="loading"
            :row-key="(row: any) => row.id"
            flex-height
            :bordered="false"
            :header-height="18"
            :row-height="18"
          >
            <template #empty>
              <div class="flex-center">
                <img
                  src="/src/assets/element-img/no-data.png"
                  style="width: 123px; height: 125px"
                >
              </div>
            </template>
          </n-data-table>
          <Pagination
            :count="count"
            :page="pagination.page"
            :page-size="pagination.pageSize"
            class="robotaxi-pagination mt-12px"
            @change="changePage"
          />
        </div>
      </n-card>
    </div>
    <AddOrEditModal ref="addOrEditModalRef" @refresh="getList" />
  </div>
</template>
<style scoped lang="scss">
</style>
src/views/businessOperations/pointManagement/list.vue
@@ -128,7 +128,7 @@
  {
    type: 'selection',
    width: columnWidths.selection,
    align: 'left',
    align: 'center',
  },
  {
    title: '点位ID',
@@ -408,7 +408,7 @@
      positiveText: '确定',
      negativeText: '取消',
      maskClosable: false,
                autoFocus: false,
      autoFocus: false,
      onPositiveClick: async () => {
        try {
          const res = await deletePoint(row.id)
@@ -443,7 +443,7 @@
    positiveText: '确定',
    negativeText: '取消',
    maskClosable: false,
                autoFocus: false,
    autoFocus: false,
    onPositiveClick: async () => {
      try {
        const res = await deletePoint(row.id)
@@ -470,7 +470,7 @@
    positiveText: '确定',
    negativeText: '取消',
    maskClosable: false,
                autoFocus: false,
    autoFocus: false,
    onPositiveClick: async () => {
      try {
        const res = await enableDisablePoint({
src/views/businessOperations/siteInfo/detail.vue
New file
@@ -0,0 +1,136 @@
<script setup lang="tsx">
import { onMounted, ref } from 'vue'
import { NCard, NSpin, NTag } from 'naive-ui'
import { useBoolean } from '@/hooks'
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const detailData = ref<any>({})
async function getDetail() {
  startLoading()
  try {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 500))
    // 模拟 Mock 数据
    detailData.value = {
      pointNumber: '00000000000000000000',
      pointName: '各种名称各种名称各种名称',
      statusText: '可接单',
      reporter: '张三',
      role: '网格员',
      fleet: '分队名称111',
      reportTime: '2026-03-23 22:03:17',
      sourceChannel: '异常',
      abnormalDesc: '描述内容的容描述内容的容描述内容的容描述内容的容描述内容的容描述内容的容',
      photos: [
        'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png',
        'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png',
      ],
    }
  }
  finally {
    endLoading()
  }
}
onMounted(() => {
  getDetail()
})
</script>
<template>
  <div class="w-full h-full p-16px bg-[#f5f7fa]">
    <NSpin :show="loading">
      <NCard :bordered="false" class="rounded-8px shadow-sm min-h-[calc(100vh-140px)]">
        <!-- 点位信息 -->
        <div class="mb-32px">
          <div class="flex items-center mb-16px">
            <div class="w-4px h-16px bg-[#1890FF] rounded-sm mr-8px" />
            <span class="text-16px font-bold text-[#1F2D3D]">点位信息</span>
          </div>
          <div class="grid grid-cols-3 gap-x-24px gap-y-16px pl-12px">
            <div class="flex items-center">
              <span class="text-[#909399] w-80px">点位ID:</span>
              <span class="text-[#1F2D3D]">{{ detailData.pointNumber || '-' }}</span>
            </div>
            <div class="flex items-center">
              <span class="text-[#909399] w-80px">点位名称:</span>
              <span class="text-[#1F2D3D]">{{ detailData.pointName || '-' }}</span>
            </div>
            <div class="flex items-center">
              <span class="text-[#909399] w-80px">点位状态:</span>
              <NTag
                v-if="detailData.statusText"
                :bordered="false"
                style="border-radius: 4px; padding: 0 8px; height: 22px; font-size: 12px; color: #0068FF; background-color: rgba(0, 104, 255, 0.12);"
              >
                {{ detailData.statusText }}
              </NTag>
              <span v-else class="text-[#1F2D3D]">-</span>
            </div>
          </div>
        </div>
        <!-- 上报信息 -->
        <div>
          <div class="flex items-center mb-16px">
            <div class="w-4px h-16px bg-[#1890FF] rounded-sm mr-8px" />
            <span class="text-16px font-bold text-[#1F2D3D]">上报信息</span>
          </div>
          <div class="grid grid-cols-3 gap-x-24px gap-y-24px pl-12px">
            <div class="flex items-start">
              <span class="text-[#909399] w-80px">上报人:</span>
              <span class="text-[#1F2D3D]">{{ detailData.reporter || '-' }}</span>
            </div>
            <div class="flex items-start">
              <span class="text-[#909399] w-80px">角色:</span>
              <span class="text-[#1F2D3D]">{{ detailData.role || '-' }}</span>
            </div>
            <div class="flex items-start">
              <span class="text-[#909399] w-80px">所属分队:</span>
              <span class="text-[#1F2D3D]">{{ detailData.fleet || '-' }}</span>
            </div>
            <div class="flex items-start">
              <span class="text-[#909399] w-80px">上报时间:</span>
              <span class="text-[#1F2D3D]">{{ detailData.reportTime || '-' }}</span>
            </div>
            <div class="flex items-start">
              <span class="text-[#909399] w-80px">现场情况:</span>
              <span class="text-[#1F2D3D]">{{ detailData.sourceChannel || '-' }}</span>
            </div>
            <!-- 空位占位符 -->
            <div />
            <!-- 跨列说明 -->
            <div class="col-span-3 flex items-start">
              <span class="text-[#909399] w-100px shrink-0">现场情况描述:</span>
              <span class="text-[#1F2D3D] leading-relaxed">
                {{ detailData.abnormalDesc || '-' }}
              </span>
            </div>
            <!-- 图片 -->
            <div class="col-span-3 flex items-start">
              <span class="text-[#909399] w-80px shrink-0">现场照片:</span>
              <div class="flex flex-wrap gap-12px">
                <template v-if="detailData.photos && detailData.photos.length">
                  <img
                    v-for="(img, idx) in detailData.photos"
                    :key="idx"
                    :src="img"
                    class="w-64px h-64px rounded-4px object-cover border border-[#EBEEF5]"
                  >
                </template>
                <span v-else class="text-[#1F2D3D]">-</span>
              </div>
            </div>
          </div>
        </div>
      </NCard>
    </NSpin>
  </div>
</template>
<style scoped lang="scss">
</style>
src/views/businessOperations/siteInfo/index.vue
New file
@@ -0,0 +1,534 @@
<script setup lang="tsx">
import type { DataTableColumns, FormInst } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { NButton, NDatePicker, NInput, NSelect, NSpace } from 'naive-ui'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import dayjs from 'dayjs'
const router = useRouter()
const message = useMessage()
const dialog = useDialog()
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const { t } = useI18n()
const initialModel = {
  pointNumber: '',
  pointName: '',
  status: null,
  siteCondition: null,
  reporter: '',
  reportTime: null,
}
const model = ref({ ...initialModel })
const isStatusSelectOpen = ref(false)
const isConditionSelectOpen = ref(false)
const formRef = ref<FormInst | null>()
function getStatusClass(status: string) {
  switch (status) {
    case '待测试':
      return 'wait-test'
    case '测试中':
      return 'testing'
    case '测试不通过':
    case '失效':
      return 'test-fail'
    case '可运营':
      return 'test-pass'
    case '待生效':
      return 'test-take-effect'
    case '启用中':
      return 'test-starting'
    case '已停用':
      return 'test-stop'
    default:
      return ''
  }
}
const statusOptions = [
  { label: '待测试', value: 1 },
  { label: '测试中', value: 2 },
  { label: '测试不通过', value: 3 },
  { label: '可运营', value: 4 },
  { label: '待生效', value: 5 },
  { label: '启用中', value: 6 },
  { label: '已停用', value: 7 },
  { label: '失效', value: 8 },
]
const conditionOptions = [
  { label: '正常', value: 1 },
  { label: '异常', value: 0 },
]
function getPointStatusText(operationStatus: number, verificationStatus?: number) {
  const v = Number(verificationStatus)
  const o = Number(operationStatus)
  if (v === 1)
    return '待测试'
  if (v === 2)
    return '测试中'
  if (v === 4)
    return '测试不通过'
  if (v === 3)
    return '可运营'
  if (v === 5) {
    if (o === 1)
      return '待生效'
    if (o === 2)
      return '启用中'
    if (o === 3)
      return '已停用'
    if (o === 4)
      return '已失效'
  }
  return '待测试'
}
const columns = computed<DataTableColumns<any>>(() => [
  {
    type: 'selection',
    width: 50,
    align: 'center',
  },
  {
    title: '点位ID',
    key: 'pointNumber',
    width: 140,
    align: 'left',
    resizable: true,
    ellipsis: true,
  },
  {
    title: '点位名称',
    key: 'pointName',
    width: 180,
    align: 'left',
    resizable: true,
    ellipsis: true,
    render(row) {
      return (
        <div
          class="cursor-pointer text-ellipsis whitespace-nowrap w-full overflow-hidden text-[#0068FF]"
          onClick={() => handleToDetail(row)}
        >
          {row.pointName}
        </div>
      )
    },
  },
  {
    title: '状态',
    key: 'status',
    width: 100,
    resizable: true,
    render(row) {
      const statusText = getPointStatusText(row.operationStatus, row.verificationStatus)
      const statusClass = getStatusClass(statusText)
      return (
        <div class={`status-tag ${statusClass}`}>
          {statusText}
        </div>
      )
    },
  },
  {
    title: '现场情况',
    key: 'siteCondition',
    width: 100,
    align: 'left',
    ellipsis: true,
    resizable: true,
    render(row) {
      const isNormal = row.siteCondition === 1
      return (
        <span style={{ color: isNormal ? '#19B144' : '#E02716' }}>
          {isNormal ? '正常' : '异常'}
        </span>
      )
    },
  },
  {
    title: '异常情况说明',
    key: 'abnormalDesc',
    width: 220,
    align: 'left',
    ellipsis: true,
    resizable: true,
    render(row) {
      return row.abnormalDesc || '-'
    },
  },
  {
    title: '上报人',
    key: 'reporter',
    width: 120,
    align: 'left',
    ellipsis: true,
    resizable: true,
  },
  {
    title: '上报时间',
    key: 'reportTime',
    width: 160,
    align: 'left',
    ellipsis: true,
    render(row) {
      return row.reportTime ? dayjs(row.reportTime).format('YYYY/MM/DD HH:mm') || '-' : '-'
    },
  },
  {
    title: '操作',
    key: 'actions',
    width: 90,
    align: 'left',
    fixed: 'right',
    render: (row) => {
      return (
        <NSpace wrap={false}>
          <NButton
            text
            type="primary"
            onClick={() => handleToDetail(row)}
          >
            详情
          </NButton>
          <NButton
            text
            type="primary"
            onClick={() => handleDelete(row)}
          >
            删除
          </NButton>
        </NSpace>
      )
    },
  },
])
const filterItems = computed(() => [
  {
    label: '点位ID',
    path: 'pointNumber',
    render: () => <NInput v-model:value={model.value.pointNumber} placeholder="请输入" clearable />,
  },
  {
    label: '点位名称',
    path: 'pointName',
    render: () => <NInput v-model:value={model.value.pointName} placeholder="请输入" clearable />,
  },
  {
    label: '点位状态',
    path: 'status',
    render: () => (
      <NSelect
        v-model:value={model.value.status}
        options={statusOptions}
        placeholder="请选择"
        clearable
        onUpdate:show={(value: boolean) => isStatusSelectOpen.value = value}
      >
        {{
          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"
            />
          ),
        }}
      </NSelect>
    ),
  },
  {
    label: '现场情况',
    path: 'siteCondition',
    render: () => (
      <NSelect
        v-model:value={model.value.siteCondition}
        options={conditionOptions}
        placeholder="请选择"
        clearable
        onUpdate:show={(value: boolean) => isConditionSelectOpen.value = value}
      >
        {{
          arrow: () => (
            <img
              src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
              class={`arrow-icon ${isConditionSelectOpen.value ? 'arrow-icon--open' : ''}`}
              style={{ width: '16px', height: '16px' }}
              alt="arrow"
            />
          ),
        }}
      </NSelect>
    ),
  },
  {
    label: '上报人',
    path: 'reporter',
    render: () => <NInput v-model:value={model.value.reporter} placeholder="请输入" clearable />,
  },
  {
    label: '上报时间',
    path: 'reportTime',
    render: () => <NDatePicker v-model:value={model.value.reportTime} type="daterange" format="yyyy/MM/dd" clearable actions={[]} close-on-select={true} start-placeholder="开始时间" end-placeholder="结束时间" separator="-" />,
  },
])
const listData = ref<any>([])
const checkedRowKeys = ref<Array<string | number>>([])
const pagination = reactive({
  page: 1,
  pageSize: 10,
  itemCount: 0,
  showSizePicker: true,
  pageSizes: [10, 20, 50, 100],
  prefix: ({ itemCount }: { itemCount: number | undefined }) => `共 ${itemCount || 0} 条`,
  onChange: (page: number) => {
    pagination.page = page
    getList()
  },
  onUpdatePageSize: (pageSize: number) => {
    pagination.pageSize = pageSize
    pagination.page = 1
    getList()
  },
})
function handleSearch() {
  pagination.page = 1
  getList()
}
function handleResetSearch() {
  model.value = { ...initialModel }
  handleSearch()
}
async function getList() {
  startLoading()
  try {
    // Mock API Call
    await new Promise(resolve => setTimeout(resolve, 500))
    const mockData = []
    const statuses = ['待测试', '测试中', '测试不通过', '可运营', '待生效', '启用中', '已停用', '已停用', '已停用', '失效']
    const reporters = ['刘晓燕', '刘晓燕', '王明', '王明', '张三', '张三', '王明明', '王明明', '王珂', '王珂']
    const conditions = [1, 1, 0, 0, 0, 0, 0, 0, 0, 0] // 1: 正常, 0: 异常
    for (let i = 0; i < pagination.pageSize; i++) {
      const index = (pagination.page - 1) * pagination.pageSize + i
      if (index >= 1000)
        break
      mockData.push({
        id: `mock-id-${index}`,
        pointNumber: 'XP676543457',
        pointName: '国贸大厦北门停车点',
        statusText: statuses[i % 10],
        siteCondition: conditions[i % 10],
        abnormalDesc: '异常情况说明内容内容内容内容内容内容内容...',
        reporter: reporters[i % 10],
        reportTime: '2021-07-29T10:59:00',
      })
    }
    listData.value = mockData
    pagination.itemCount = 1000
  }
  finally {
    endLoading()
  }
}
function handleToDetail(row: any) {
  router.push({ name: 'siteInfoDetail', query: { id: row.id } })
}
function handleDelete(row: any) {
  dialog.warning({
    title: '提示',
    content: '确认删除该点位现场信息吗?',
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: () => {
      console.warn(row)
      message.success('删除成功')
      getList()
    },
  })
}
async function handleBatchExport() {
  if (checkedRowKeys.value.length === 0) {
    message.warning('请先选择记录')
    return
  }
  message.success('导出成功')
}
function handleCheck(rowKeys: Array<string | number>) {
  checkedRowKeys.value = rowKeys
}
function changePage(page: number, size: number) {
  pagination.page = page
  pagination.pageSize = size
  getList()
}
onMounted(() => {
  getList()
})
</script>
<template>
  <div class="roboTaxi w-full h-full px-[12px] py-[12px]">
    <div class="roboTaxi-form-card mb-12px">
      <n-form ref="formRef" :model="model" label-placement="left" inline :show-feedback="false">
        <SearchPanel>
          <n-gi v-for="item in filterItems" :key="item.path">
            <n-form-item :label="item.label" :path="item.path">
              <component :is="item.render" />
            </n-form-item>
          </n-gi>
          <template #action>
            <NButton @click="handleResetSearch">
              {{ t('common.reset') }}
            </NButton>
            <NButton type="primary" @click="handleSearch">
              {{ t('common.search') }}
            </NButton>
          </template>
        </SearchPanel>
      </n-form>
    </div>
    <n-card
      class="flex-1 robotaxi-table-card point-management-list"
      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>
          </n-flex>
        </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"
          flex-height
          :bordered="false"
          :header-height="18"
          :row-height="18"
          :scroll-x="1160"
          :row-key="(row) => row.id"
          @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="pagination.itemCount"
          :page="pagination.page"
          :page-size="pagination.pageSize"
          class="robotaxi-pagination mt-12px"
          @change="changePage"
        />
      </div>
    </n-card>
  </div>
</template>
<style lang="scss">
.point-management-list {
  .n-data-table-th {
    background-color: #fafafc;
    font-weight: 500;
  }
  .status-tag {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 4px;
    font-family: PingFang SC;
    font-size: 12px;
    font-weight: normal;
    line-height: 16px;
    text-align: center;
    margin: 0 4px;
    // 待测试
    &.wait-test {
      background: #FFF6EA;
      color: #FFAA2A;
    }
    // 测试中
    &.testing {
      background: #E6F4FF;
      color: #0068FF;
    }
    // 测试失败---运营失效
    &.test-fail {
      background: #FFF3F1;
      color: #E02716;
    }
    // 可运营
    &.test-pass {
      color: #0068FF;
      background: transparent;
      border: 1px dashed #0068FF;
    }
    // 待生效--待启用
    &.test-take-effect {
      background: #E6F4FF;
      color: #0068FF;
    }
    //启用中
    &.test-starting {
      background: #E9F9ED;
      color: #19B144;
    }
    // 已停用
    &.test-stop {
      background: #E8E9E9;
      color: #171F26;
    }
    // 失效
    &.test-invalid {
      background: #E8E9E9;
      color: #171F26;
    }
  }
}
</style>
src/views/operations/attendance/shift/components/ShiftFormModal.vue
@@ -1,12 +1,25 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { computed, ref } from 'vue'
import type { FormInst, FormRules } from 'naive-ui'
interface FormModel {
  shiftName: string
  shiftColor: string
  // 第一行
  firstPunchStartTime: string | null
  firstPunchEndTime: string | null
  // 第二行
  startTime: string | null
  secondPunchStartTime: string | null
  secondPunchEndTime: string | null
  isSecondPunchCrossDay: boolean // 是否跨天
  // 第三行
  endTime: string | null
  thirdPunchStartTime: string | null
  thirdPunchEndTime: string | null
  isRest: boolean
  restStartTime: string | null
  restEndTime: string | null
@@ -19,50 +32,56 @@
const emit = defineEmits<{
  'update:show': [value: boolean]
  confirm: [data: FormModel]
  'confirm': [data: FormModel]
}>()
const show = computed({
  get: () => props.show,
  set: (value) => emit('update:show', value)
  set: value => emit('update:show', value),
})
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  shiftName: '',
  shiftColor: '',
  firstPunchStartTime: null,
  firstPunchEndTime: null,
  startTime: null,
  secondPunchStartTime: null,
  secondPunchEndTime: null,
  isSecondPunchCrossDay: false,
  endTime: null,
  thirdPunchStartTime: null,
  thirdPunchEndTime: null,
  isRest: false,
  restStartTime: null,
  restEndTime: null
  restEndTime: null,
})
const rules: FormRules = {
  shiftName: [
    { required: true, message: '请输入班次名称', trigger: 'blur' }
    { required: true, message: '请输入班次名称', trigger: 'blur' },
  ],
  // shiftColor: [
  //   { required: true, message: '请选择班次颜色', trigger: 'change' }
  // ],
  firstPunchStartTime: [
    { required: true, message: '请选择最早打卡时间', trigger: 'change' },
  ],
  firstPunchEndTime: [
    { required: true, message: '请选择最晚打卡时间', trigger: 'change' },
  ],
  startTime: [
    { required: true, message: '请选择开始时间', trigger: 'change' }
    { required: true, message: '请选择开始时间', trigger: 'change' },
  ],
  secondPunchStartTime: [
    { required: true, message: '请选择最早打卡时间', trigger: 'change' },
  ],
  secondPunchEndTime: [
    { required: true, message: '请选择最晚打卡时间', trigger: 'change' },
  ],
  endTime: [
    { required: true, message: '请选择结束时间', trigger: 'change' },
    {
      validator: (_rule: any, value: any) => {
        if (formModel.value.startTime && value) {
          const start = new Date(`2000-01-01 ${formModel.value.startTime}`)
          const end = new Date(`2000-01-01 ${value}`)
          if (end <= start) {
            return new Error('结束时间必须大于开始时间')
          }
        }
        return true
      },
      trigger: ['change', 'blur']
    }
    // 这里因为可能有跨天逻辑,原有的简单结束必须大于开始时间的校验可能不适用,暂时移除强制结束>开始
  ],
  thirdPunchStartTime: [
    { required: true, message: '请选择最早打卡时间', trigger: 'change' },
  ],
  restStartTime: [
    {
@@ -83,8 +102,8 @@
        }
        return true
      },
      trigger: 'change'
    }
      trigger: 'change',
    },
  ],
  restEndTime: [
    {
@@ -115,40 +134,47 @@
        }
        return true
      },
      trigger: ['change', 'blur']
    }
  ]
      trigger: ['change', 'blur'],
    },
  ],
}
const handleCancel = () => {
function handleCancel() {
  show.value = false
  resetForm()
}
const handleConfirm = async () => {
async function handleConfirm() {
  try {
    await formRef.value?.validate()
    emit('confirm', formModel.value)
    show.value = false
    resetForm()
  } catch (error) {
    console.log('验证失败', error)
  }
  catch (error) {
    console.warn('验证失败', error)
  }
}
const resetForm = () => {
function resetForm() {
  formModel.value = {
    shiftName: '',
    shiftColor: '',
    firstPunchStartTime: null,
    firstPunchEndTime: null,
    startTime: null,
    secondPunchStartTime: null,
    secondPunchEndTime: null,
    isSecondPunchCrossDay: false,
    endTime: null,
    thirdPunchStartTime: null,
    thirdPunchEndTime: null,
    isRest: false,
    restStartTime: null,
    restEndTime: null
    restEndTime: null,
  }
}
const openEdit = (data: any) => {
function openEdit(data: any) {
  formModel.value = { ...data }
}
function closeTimePicker() {
@@ -168,11 +194,12 @@
    :mask-closable="false"
    preset="card"
    :title="mode === 'edit' ? '编辑班次' : '新增班次'"
    class="w-400px"
    style="width: 800px;"
    :segmented="{
      content: true,
      action: true,
    }">
    }"
  >
    <n-form
      ref="formRef"
      :model="formModel"
@@ -181,74 +208,116 @@
      require-mark-placement="left"
      show-require-mark
    >
    <div class="flex gap-8px flex-col">
      <n-form-item label="班次" path="shiftName">
        <n-input v-model:value="formModel.shiftName" placeholder="请输入" clearable />
      </n-form-item>
      <!-- Row 1 -->
      <div class="grid grid-cols-3 gap-x-24px mb-16px">
        <n-form-item label="班次" path="shiftName">
          <n-input v-model:value="formModel.shiftName" placeholder="请输入" clearable />
        </n-form-item>
      </div>
      <!-- <n-form-item label="班次色号" path="color">
        <div class="flex gap-2">
          <div
            v-for="color in colorOptions"
            :key="color.value"
            class="w-12 h-12 rounded cursor-pointer relative"
            :style="{ backgroundColor: color.value, border: formModel.shiftColor === color.value ? '2px solid #0068FF' : '1px solid #e5e7eb' }"
            @click="formModel.shiftColor = color.value"
          >
            <div
              v-if="formModel.shiftColor === color.value"
              class="absolute top-0 right-0 w-4 h-4 bg-blue-500 rounded-bl flex items-center justify-center"
            >
              <span class="text-white text-xs">✓</span>
            </div>
          </div>
        </div>
      </n-form-item> -->
      <!-- Row 2 -->
      <div class="grid grid-cols-3 gap-x-24px mb-16px">
        <n-form-item label="开始时间" path="startTime">
          <n-time-picker
            v-model:formatted-value="formModel.startTime"
            format="HH:mm" value-format="HH:mm"
            :actions="['now']"
            placeholder="--:--"
            clearable style="width: 100%" @update:value="closeTimePicker"
          />
        </n-form-item>
        <n-form-item label="最早打卡时间" path="secondPunchStartTime">
          <n-time-picker
            v-model:formatted-value="formModel.secondPunchStartTime"
            format="HH:mm" value-format="HH:mm"
            :actions="['now']"
            placeholder="--:--"
            clearable style="width: 100%" @update:value="closeTimePicker"
          />
        </n-form-item>
        <n-form-item label="最晚打卡时间" path="secondPunchEndTime">
          <n-time-picker
            v-model:formatted-value="formModel.secondPunchEndTime"
            format="HH:mm" value-format="HH:mm"
            :actions="['now']"
            placeholder="--:--"
            clearable style="width: 100%" @update:value="closeTimePicker"
          />
        </n-form-item>
      </div>
      <n-form-item label="开始时间" path="startTime">
        <n-time-picker v-model:formatted-value="formModel.startTime"
        format="HH:mm" value-format="HH:mm"
        :actions="['now']"
        @update:value="() => { closeTimePicker();}"
        placeholder="--:--" clearable style="width: 100%" />
      </n-form-item>
      <!-- Row 3 -->
      <div class="grid grid-cols-3 gap-x-24px mb-16px">
        <n-form-item label="结束时间" path="endTime">
          <n-time-picker
            v-model:formatted-value="formModel.endTime"
            format="HH:mm" value-format="HH:mm"
            :actions="['now']"
            placeholder="--:--"
            clearable style="width: 100%" @update:value="closeTimePicker"
          />
        </n-form-item>
        <n-form-item label="最早打卡时间" path="thirdPunchStartTime">
          <n-time-picker
            v-model:formatted-value="formModel.thirdPunchStartTime"
            format="HH:mm" value-format="HH:mm"
            :actions="['now']"
            placeholder="--:--"
            clearable style="width: 100%" @update:value="closeTimePicker"
          />
        </n-form-item>
        <n-form-item path="secondPunchEndTime">
          <template #label>
            最晚打卡时间
          </template>
          <n-time-picker
            v-model:formatted-value="formModel.secondPunchEndTime"
            format="HH:mm" value-format="HH:mm"
            :actions="['now']"
            placeholder="--:--"
            clearable style="width: 100%" @update:value="closeTimePicker"
          />
        </n-form-item>
        <n-form-item label="是否跨天" path="isRest" label-placement="left" class="mb-16px">
          <n-switch v-model:value="formModel.isRest" />
        </n-form-item>
      </div>
      <n-form-item label="结束时间" path="endTime">
        <n-time-picker v-model:formatted-value="formModel.endTime"
        format="HH:mm" value-format="HH:mm"
        :actions="['now']"
        @update:value="() => { closeTimePicker();}"
        placeholder="--:--" clearable style="width: 100%" />
      </n-form-item>
      <n-form-item label="是否中途休息" path="isRest" label-placement="left">
      <!-- Rest Section -->
      <n-form-item label="是否中途休息" path="isRest" label-placement="left" class="mb-16px">
        <n-switch v-model:value="formModel.isRest" />
      </n-form-item>
      <template v-if="formModel.isRest">
      <div v-if="formModel.isRest" class="grid grid-cols-3 gap-x-24px">
        <n-form-item label="开始休息时间" path="restStartTime">
          <n-time-picker v-model:formatted-value="formModel.restStartTime"
          format="HH:mm" value-format="HH:mm"
          :actions="['now']"
          @update:value="() => { closeTimePicker();}"
          placeholder="--:--" clearable style="width: 100%" />
          <n-time-picker
            v-model:formatted-value="formModel.restStartTime"
            format="HH:mm" value-format="HH:mm"
            :actions="['now']"
            placeholder="--:--"
            clearable style="width: 100%" @update:value="closeTimePicker"
          />
        </n-form-item>
        <n-form-item label="结束时间" path="restEndTime">
          <n-time-picker v-model:formatted-value="formModel.restEndTime"
          format="HH:mm" value-format="HH:mm"
          :actions="['now']"
          @update:value="() => { closeTimePicker();}"
          placeholder="--:--" clearable style="width: 100%" />
        <n-form-item label="结束休息时间" path="restEndTime">
          <n-time-picker
            v-model:formatted-value="formModel.restEndTime"
            format="HH:mm" value-format="HH:mm"
            :actions="['now']"
            placeholder="--:--"
            clearable style="width: 100%" @update:value="closeTimePicker"
          />
        </n-form-item>
      </template>
      </div>
    </n-form>
    <template #action>
      <n-space justify="end">
        <n-button @click="handleCancel">取消</n-button>
        <n-button type="primary" @click="handleConfirm">确定</n-button>
        <n-button @click="handleCancel">
          取消
        </n-button>
        <n-button type="primary" @click="handleConfirm">
          确定
        </n-button>
      </n-space>
    </template>
  </n-modal>