董国庆
2026-02-28 bba718b8a54a45fb9010e36c8a1105a190f93b85
Merge branch 'main' of http://120.76.84.145:10101/gitblit/r/H5/xp-robotaxi-web
9个文件已添加
15个文件已修改
4470 ■■■■■ 已修改文件
locales/en_US.json 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
locales/zh_CN.json 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/hooks/usePermission.ts 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/attendance.ts 153 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/serviceCenter.ts 93 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/styles/robotaxi.scss 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/attendance/statistics/detail.vue 223 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/attendance/statistics/index.vue 163 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/message/index.vue 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/personnel/personnelManagement/components/PersonnelFormModal.vue 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/personnel/personnelManagement/detail.vue 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/personnel/personnelManagement/index.vue 358 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/taskOrder/taskOrderCenter/components/AddFormModal.vue 440 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/taskOrder/taskOrderCenter/components/TableModal.vue 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/operations/taskOrder/taskOrderCenter/index.vue 365 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/feedback/index.vue 286 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/feedbackType/index.vue 234 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/components/WorkOrderHandleModal.vue 254 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/components/WorkOrderModal.vue 323 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/detail.vue 330 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/service/ticket/workOrder/index.vue 564 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/order/orderManagement/index.vue 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/pricingManagement/detail.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
locales/en_US.json
@@ -122,13 +122,80 @@
  },
  "route": {
    "appRoot": "Home",
    "cardList": "Card list",
    "draggableList": "Draggable list",
    "transaction": "Transaction",
    "dashboard": "Dashboard",
    "system": "System",
    "accountPermission": "Account Permission",
    "accountManagement": "Account Management",
    "permissionGroup": "Permission Group Setting",
    "permissionGroupForm": "Permission Group Detail",
    "roleManagement": "Role Management",
    "roleDetail": "Role Detail",
    "appVersion": "App Version",
    "hotfix": "Hot Fix",
    "appVersionManage": "App Version Management",
    "systemConfig": "System Config",
    "userContent": "User & Content",
    "whitelistManagement": "Whitelist",
    "invitationCode": "Invitation Code Management",
    "whitelist": "Whitelist Management",
    "whitelistLog": "Whitelist Log",
    "contentCenter": "Content Center",
    "contentCenterIndex": "Content Center",
    "contentCenterActive": "Activity",
    "contentCenterConpon": "Coupon Management",
    "contentCenterConponAdd": "Add Coupon",
    "bannerManagement": "Banner Management",
    "businessOperations": "Operation & City",
    "openCity": "Open City Management",
    "geofenceManagement": "Geofence Management",
    "geofenceDetail": "Geofence Detail",
    "addFence": "Add Geofence",
    "editFence": "Edit Geofence",
    "pointManagement": "Point Management",
    "cruise": "Cruise",
    "patrolPoints": "Special Point Management",
    "patrolPlan": "Cruise Plan",
    "patrolPlanAdd": "Add Cruise Plan",
    "patrolPlanDetail": "Cruise Plan Detail",
    "assetManagement": "Asset",
    "vehicleManagement": "Vehicle",
    "inventoryVehicle": "Inventory Vehicle Management",
    "addInventoryVehicle": "Add Vehicle",
    "operatingVehicle": "Operating Vehicle Management",
    "operatingVehicleDetail": "Operating Vehicle Detail",
    "fleetManagement": "Fleet Management",
    "fleetManagementDetail": "Fleet Detail",
    "ruleConfig": "Operation Rule Configuration",
    "transaction": "Transaction & Pricing",
    "order": "Order",
    "evaluationRecord": "Evaluation record",
    "transactionStrategy": "Transaction strategy",
    "navigationAndRoutePlanning": "Navigation and route planning"
    "orderManagement": "Order Management",
    "orderDetail": "Order Detail",
    "evaluationRecord": "Evaluation Record",
    "pricingManagement": "Pricing Management",
    "setPrice": "Set Price",
    "pricingDetail": "Pricing Detail",
    "rules": "Billing Rules",
    "transactionStrategy": "Transaction Strategy",
    "navigationAndRoutePlanning": "Navigation And Route Planning",
    "operations": "Operation Execution",
    "personnel": "Personnel",
    "operationsPersonnel": "Operation Personnel Management",
    "operationsPersonnelDetail": "Operation Personnel Detail",
    "operationsAttendance": "Schedule Attendance",
    "operationsAttendanceAddress": "Clock-in Address Management",
    "operationsAttendanceStatistics": "Clock-in Statistics",
    "operationsAttendanceStatisticsDetail": "Clock-in Details",
    "operationsAttendanceShift": "Shift Management",
    "operationsAttendancePlan": "Schedule Plan Management",
    "operationsAttendancePlanEdit": "Edit Schedule",
    "operationsAttendancePlanCopy": "Copy Schedule",
    "taskOrder": "Task Order",
    "taskOrderCenter": "Task Order Center",
    "operationsMessage": "Message",
    "service": "Customer Service",
    "serviceReview": "Customer Service Review",
    "serviceReviewPersonnel": "Customer Service Personnel",
    "serviceReviewPolicyApplication": "Policy Application"
  },
  "transaction": {
    "export": "Export order",
locales/zh_CN.json
@@ -126,7 +126,79 @@
  "route": {
    "appRoot": "首页",
    "dashboard": "首页",
    "workbench": "首页"
    "system": "系统",
    "accountPermission": "账户权限",
    "accountManagement": "账号管理",
    "permissionGroup": "权限组设置",
    "permissionGroupForm": "权限组详情",
    "roleManagement": "角色管理",
    "roleDetail": "角色详情",
    "appVersion": "APP版本",
    "hotfix": "热修复",
    "appVersionManage": "APP版本管理",
    "systemConfig": "系统配置",
    "userContent": "用户&内容",
    "whitelistManagement": "白名单",
    "invitationCode": "邀请码管理",
    "whitelist": "白名单管理",
    "whitelistLog": "白名单日志",
    "contentCenter": "内容中心",
    "contentCenterIndex": "内容中心",
    "contentCenterActive": "活动",
    "contentCenterConpon": "优惠券管理",
    "contentCenterConponAdd": "添加优惠券",
    "bannerManagement": "Banner管理",
    "businessOperations": "运营&城市",
    "openCity": "开通城市管理",
    "geofenceManagement": "围栏管理",
    "geofenceDetail": "围栏详情",
    "addFence": "新增围栏",
    "editFence": "编辑围栏",
    "pointManagement": "点位管理",
    "cruise": "巡游",
    "patrolPoints": "特殊点位管理",
    "patrolPlan": "巡游计划",
    "patrolPlanAdd": "新增巡游计划",
    "patrolPlanDetail": "巡游计划详情",
    "assetManagement": "资产",
    "vehicleManagement": "车辆",
    "inventoryVehicle": "在库车辆管理",
    "addInventoryVehicle": "新增车辆",
    "operatingVehicle": "运营车辆管理",
    "operatingVehicleDetail": "运营车辆详情",
    "fleetManagement": "车队管理",
    "fleetManagementDetail": "车队详情",
    "ruleConfig": "运维规则配置",
    "transaction": "交易&定价",
    "order": "订单",
    "orderManagement": "订单管理",
    "orderDetail": "订单详情",
    "evaluationRecord": "评价记录",
    "pricingManagement": "定价管理",
    "setPrice": "设置价格",
    "pricingDetail": "定价详情",
    "rules": "计费规则",
    "transactionStrategy": "交易策略",
    "navigationAndRoutePlanning": "路线规划",
    "operations": "运维执行",
    "personnel": "人员",
    "operationsPersonnel": "运维人员管理",
    "operationsPersonnelDetail": "运维人员详情",
    "operationsAttendance": "排班考勤",
    "operationsAttendanceAddress": "打卡地址管理",
    "operationsAttendanceStatistics": "打卡统计",
    "operationsAttendanceStatisticsDetail": "打卡明细",
    "operationsAttendanceShift": "班次管理",
    "operationsAttendancePlan": "排班计划管理",
    "operationsAttendancePlanEdit": "编辑排班",
    "operationsAttendancePlanCopy": "复制排班",
    "taskOrder": "工单",
    "taskOrderCenter": "工单中心",
    "operationsMessage": "消息",
    "service": "客服",
    "serviceReview": "客服审核",
    "serviceReviewPersonnel": "客服人员",
    "serviceReviewPolicyApplication": "政策申请"
  },
  "transaction": {
    "export": "导出订单",
src/hooks/usePermission.ts
@@ -27,18 +27,18 @@
    if (isArray(permission)) {
      // 角色为数组, 判断是否有交集 或 权限是否有交集
      return permission.some(i =>
        (role && role.includes(i as Entity.RoleType)) ||
      return permission.some(i =>
        (role && role.includes(i as Entity.RoleType)) ||
        (permissions && permissions.some(p => String(p) === String(i)))
      )
    }
    if (isString(permission) || isNumber(permission)) {
      // 角色为字符串/数字, 判断是否包含 或 权限是否包含
      return (role && role.includes(permission as Entity.RoleType)) ||
      return (role && role.includes(permission as Entity.RoleType)) ||
             (permissions && permissions.some(p => String(p) === String(permission)))
    }
    return false
  }
src/router/routes.static.ts
@@ -794,7 +794,8 @@
    pid: 702,
    hide: true,
    activeMenu: '/operations/attendance/plan',
  },{
  },
  {
    name: 'operationsAttendancePlanCopy',
    path: '/operations/attendance/plan/copy',
    title: '复制排班',
@@ -806,6 +807,28 @@
    pid: 702,
    hide: true,
    activeMenu: '/operations/attendance/plan',
  },
  {
    name: 'taskOrder',
    path: '/operations/taskOrder',
    title: '任务工单',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'dir',
    componentPath: null,
    id: 705,
    pid: 7,
  },
   {
    name: 'taskOrderCenter',
    path: '/operations/taskOrderCenter',
    title: '工单中心',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/operations/taskOrder/taskOrderCenter/index.vue',
    id: 7051,
    pid: 705,
  },
  {
    name: 'operationsMessage',
@@ -842,6 +865,17 @@
    pid: 8,
  },
  {
    name: 'serviceTicket',
    path: '/service/ticket',
    title: '客服工单',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'dir',
    componentPath: null,
    id: 802,
    pid: 8,
  },
  {
    name: 'serviceReviewPersonnel',
    path: '/service/review/personnel',
    title: '人员资质审核',
@@ -863,5 +897,51 @@
    id: 8012,
    pid: 801,
  },
  {
    name: 'serviceTicketFeedback',
    path: '/service/ticket/feedback',
    title: '意见反馈',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'page',
    componentPath: '/service/ticket/feedback/index.vue',
    id: 8021,
    pid: 802,
  },
  {
    name: 'serviceTicketFeedbackType',
    path: '/service/ticket/feedbackType',
    title: '反馈类型',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'page',
    componentPath: '/service/ticket/feedbackType/index.vue',
    id: 8022,
    pid: 802,
  },
  {
    name: 'serviceTicketWorkOrder',
    path: '/service/ticket/workOrder',
    title: '工单',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'page',
    componentPath: '/service/ticket/workOrder/index.vue',
    id: 8023,
    pid: 802,
  },
  {
    name: 'serviceTicketWorkOrderDetail',
    path: '/service/ticket/workOrder/detail',
    title: '工单详情',
    requiresAuth: true,
    icon: 'icon-park-outline:message',
    menuType: 'page',
    componentPath: '/service/ticket/workOrder/detail.vue',
    id: 80231,
    pid: 802,
    hide: true,
    activeMenu: '/service/ticket/workOrder',
  },
]
src/service/api/attendance.ts
@@ -50,3 +50,156 @@
export function deleteShift(id: any) {
  return request.Delete<Service.ResponseResult<any>>('/maintenance/xp-shift/api/v1/shift/deleteById?id=' + id)
}
/** 消息通知管理 */
// 获取消息通知列表
export function getNoticeList() {
  return request.Get<Service.ResponseResult<any>>('/operation/api/v1/notification/list')
}
// 修改消息通知配置
export function updateNoticeConfig(params: any) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/notification/update', params)
}
/** 打卡统计 */
// 获取打卡统计列表
export function getClockInStatisticsList(params: any) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/clock/getClockInStatistics', params)
}
// 获取打卡统计详情列表
export function getClockInStatisticsDetailList(params: any) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/clock/getClockInStatisticsDetails', params)
}
// 导出打卡统计
export function exportClockInStatistics(params: any) {
  // return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/clock/exportClockInStatistics', params)
  const method = request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/clock/exportClockInStatistics', params, { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
// 导出打卡统计详情
export function exportClockInStatisticsDetails(params: any) {
  // return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/clock/exportClockInStatisticsDetails', params)
  const method = request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/clock/exportClockInStatisticsDetails', params, { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
/** 运维人员管理 */
// 获取运维人员列表
export function getOperationsUserList(params: any) {
  return request.Post<Service.ResponseResult<any>>('/account/api/v1/operationsUser/pageList', params)
}
// 获取运维人员不分页列表
export function getOperationsUserListNoPage() {
  return request.Post<Service.ResponseResult<any>>('/account/api/v1/operationsUser/listAll')
}
// 获取运维人员详情
export function getOperationsUserDetail(id: any) {
  return request.Get<Service.ResponseResult<any>>('/account/api/v1/operationsUser/detailById?id=' + id)
}
// 新增运维人员
export function addOperationsUser(params: any) {
  return request.Post<Service.ResponseResult<any>>('/account/api/v1/operationsUser/add', params)
}
// 编辑运维人员
export function editOperationsUser(params: any) {
  return request.Post<Service.ResponseResult<any>>('/account/api/v1/operationsUser/edit', params)
}
// 删除运维人员
export function deleteOperationsUser(id: any) {
  return request.Delete<Service.ResponseResult<any>>('/account/api/v1/operationsUser/deleteById?id=' + id)
}
// 启用禁用运维人员
export function updateOperationsUserStatus(params: any) {
  return request.Post<Service.ResponseResult<any>>('/account/api/v1/operationsUser/enableDisableById?', params)
}
// 重置运维人员密码
export function resetOperationsUserPassword(params: any) {
  return request.Post<Service.ResponseResult<any>>('/account/api/v1/operationsUser/rePassword', params)
}
// 运维人员导出
export function exportOperationsUser(params: any) {
  const method = request.Get<Service.ResponseResult<any>>('/account/api/v1/operationsUser/exportOperationsUser', params, { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
// 运维人员导出模板
export function exportOperationsUserTemplate() {
  const method = request.Get<Service.ResponseResult<any>>('/account/api/v1/operationsUser/exportTemplate', {}, { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
// 运维人员导入
export function importOperationsUser(file: File) {
  const formData = new FormData()
  formData.append('file', file)
  return request.Post<Service.ResponseResult<any>>('/account/api/v1/operationsUser/importOperationsUser', formData)
}
/** 任务工单 */
// 任务中心列表
export function getTaskOrderCenterList(params: any) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/taskManagement/pageList', params)
}
// 任务工单导出
export function exportTaskOrderCenter(params: any) {
  const method = request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/taskManagement/exportExcel', params, { responseType: 'blob' })
  method.meta = { ...method.meta, isBlob: true }
  return method
}
// 创建任务事故
export function createTaskAccident(params: any) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/taskManagement/saveTask', params)
}
// 获取任务详情
export function getTaskOrderDetail(id: any) {
  return request.Get<Service.ResponseResult<any>>('/maintenance/api/v1/taskManagement/taskDetail?id=' + id)
}
// 任务分配
export function assignTaskOrder(params: any) {
  return request.Get<Service.ResponseResult<any>>('/maintenance/api/v1/taskManagement/assigneeTask?id=' + params.id + '&handlerUserId=' + params.handlerUserId)
}
// 删除任务
export function deleteTaskOrder(id: any) {
  return request.Delete<Service.ResponseResult<any>>('/maintenance/api/v1/taskManagement/deleteTaskById?id=' + id)
}
// 事故类型列表
export function getTaskAccidentTypeList() {
  return request.Get<Service.ResponseResult<any>>('/maintenance/api/v1/taskManagement/errorTypeList')
}
// 查询车辆不分页列表
export function getVehicleListNoPage() {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/taskManagement/listVehicle')
}
src/service/api/serviceCenter.ts
@@ -42,3 +42,96 @@
export function deletePolicyApplicationById(id: number | string) {
  return request.Delete<Service.ResponseResult<any>>(`/vehicleConvoy/api/v1/policyApplication/deleteById/${id}`)
}
export function fetchFeedbackPageList(data: {
  content?: string
  pageNum: number
  pageSize: number
}) {
  return request.Post<Service.ResponseResult<any>>('/order/api/v1/orderEvaluateManagement/pageList', data)
}
export function fetchFeedbackTypeList(data: {
  pageNum: number
  pageSize: number
}) {
  return request.Get<Service.ResponseResult<any>>('/operation/api/v1/feedbackType/getList', data)
}
export function saveFeedbackType(data: { feedbackType: string }) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/feedbackType/saveFeedbackType', data)
}
export function updateFeedbackType(data: { id: number | string; feedbackType: string }) {
  return request.Post<Service.ResponseResult<any>>('/operation/api/v1/feedbackType/updateFeedbackType', data)
}
export function deleteFeedbackType(id: number | string) {
  return request.Delete<Service.ResponseResult<any>>(`/operation/api/v1/feedbackType/deleteFeedbackType?id=${id}`)
}
export function fetchWorkOrderPageList(data: {
  feedbackTypeId?: number | string
  feedbackUserPhone?: string
  handleStatus?: number | null
  pageNum: number
  pageSize: number
  priority?: number | null
  problemSource?: number | null
}) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/workOrder/pageList', data)
}
export function fetchOperationsUserListAll() {
  return request.Get<Service.ResponseResult<any>>('/account/api/v1/sys-user/listAll', {})
}
export function saveWorkOrder(data: {
  feedbackTypeId?: number | string
  feedbackUserPhone?: string
  handleStatus?: number | null
  priority?: number | null
  problemSource?: number | null
  problem?: string
  problemDescription?: string
  problemImage?: string
  handleUserId?: number | string
  handleUserName?: string
}) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/workOrder/saveWorkOrder', data)
}
export function updateWorkOrder(data: {
  id: number | string
  feedbackTypeId?: number | string
  feedbackUserPhone?: string
  handleStatus?: number | null
  priority?: number | null
  problemSource?: number | null
  problem?: string
  problemDescription?: string
  handleUserId?: number | string
  problemImage?: string
  handleUserName?: string
}) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/workOrder/updateWorkOrder', data)
}
export function deleteWorkOrder(id: number | string) {
  return request.Delete<Service.ResponseResult<any>>(`/maintenance/api/v1/workOrder/deleteWorkOrder?id=${id}`)
}
export function fetchWorkOrderDetail(id: number | string) {
  return request.Get<Service.ResponseResult<any>>(`/maintenance/api/v1/workOrder/workOrderDetail?id=${id}`)
}
export function handleWorkOrder(data: {
  handleFile?: string
  handleLog: string
  handleType: number
  handleUserId?: number | string
  handleUserName?: string
  workOrderId: number | string
}) {
  return request.Post<Service.ResponseResult<any>>('/maintenance/api/v1/workOrder/handleWorkOrder', data)
}
src/styles/robotaxi.scss
@@ -69,3 +69,36 @@
    font-size: 13px;
  }
}
.n-modal {
  max-height: 80vh;
  .n-card-header {
    padding: 16px 24px;
    .n-card-header__main {
      font-size: 16px;
      line-height: 24px;
      font-weight: 600;
    }
  }
  .n-card__content {
    overflow-y: auto;
    scrollbar-width: none;
    padding: 16px 24px !important;
    &::-webkit-scrollbar {
      display: none;
    }
  }
  .n-card__action {
    padding: 16px 24px;
    background-color: #FFF;
    box-shadow: 0px -2px 5px 0px rgba(0, 0, 0, 0.1);
    button {
      width: 84px;
    }
  }
}
src/views/operations/attendance/statistics/detail.vue
@@ -1,41 +1,21 @@
<script setup lang="tsx">
import { ref, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useBoolean } from '@/hooks'
import { useRoute } from 'vue-router'
import type { DataTableColumns } from 'naive-ui'
import { NTag } from 'naive-ui'
import { getClockInStatisticsDetailList, exportClockInStatisticsDetails } from '@/service/api/attendance'
import { downloadFile } from '@/utils'
import SearchPanel from '@/components/custom/SearchPanel.vue'
const router = useRouter()
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const route = useRoute()
interface AttendanceDetail {
  id: number
  name: string
  role: string
  fleet: string
  date: string
  shift: string
  checkInTime: string
  checkInResult: string
  checkInAddress: string
}
const searchModel = ref({
  name: '王美丽',
  role: '',
  dateRange: null as [number, number] | null
  appUserId: null as any,
  statisticDate: null as string[] | null
})
const personnelOptions = [
  { label: '王美丽', value: '王美丽' },
  { label: '张三', value: '张三' },
  { label: '李四', value: '李四' }
]
const roleOptions = [
  { label: '网格员', value: '网格员' },
  { label: '运维管理', value: '运维管理' },
  { label: '角色1', value: '角色1' }
]
// 统计数据
const statistics = ref({
@@ -45,59 +25,38 @@
  absentDays: 0
})
const detailList = ref<AttendanceDetail[]>([
  { id: 1, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '2025-05-17 07:59', checkInResult: '正常', checkInAddress: '成都市武侯区石羊街道瑞西二街' },
  { id: 2, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '2025-05-17 07:59', checkInResult: '正常', checkInAddress: '成都市武侯区石羊街道瑞西二街' },
  { id: 3, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '2025-05-17 07:59', checkInResult: '迟到', checkInAddress: '成都市武侯区石羊街道瑞西二街' },
  { id: 4, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '2025-05-17 07:59', checkInResult: '正常', checkInAddress: '成都市武侯区石羊街道瑞西二街' },
  { id: 5, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '', checkInResult: '缺卡', checkInAddress: '' },
  { id: 6, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '2025-05-17 07:59', checkInResult: '正常', checkInAddress: '成都市武侯区石羊街道瑞西二街' },
  { id: 7, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '2025-05-17 07:59', checkInResult: '迟到', checkInAddress: '成都市武侯区石羊街道瑞西二街' },
  { id: 8, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '2025-05-17 07:59', checkInResult: '迟到', checkInAddress: '成都市武侯区石羊街道瑞西二街' },
  { id: 9, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '2025-05-17 07:59', checkInResult: '正常', checkInAddress: '成都市武侯区石羊街道瑞西二街' },
  { id: 10, name: '王美丽', role: '网格员', fleet: '网格员一队', date: '2025-05-17', shift: '早 (08:00-18:00)', checkInTime: '2025-05-17 07:59', checkInResult: '正常', checkInAddress: '成都市武侯区石羊街道瑞西二街' }
])
const detailList = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(800)
const handleSearch = () => {
  console.log('搜索', searchModel.value)
}
const total = ref(0)
const pageParams = ref({
  pageNum: 1,
  pageSize: 10,
})
const handleReset = () => {
  searchModel.value = {
    name: '王美丽',
    role: '',
    dateRange: null
    appUserId: null,
    statisticDate: null
  }
}
const handleExport = () => {
  console.log('导出')
}
const handleBack = () => {
  router.back()
const handleExport = async () => {
  const params = {
    ...pageParams.value,
    statisticDate: searchModel.value.statisticDate?.join('-'),
  }
  const response = await exportClockInStatisticsDetails(params)
  if (response instanceof Blob) {
    downloadFile(response, `打卡统计列表_${new Date().getTime()}.xlsx`)
    window.$message.success('导出成功')
  }
}
const filterItems = computed(() => [
  {
    label: '人员',
    path: 'name',
    render: () => <n-select v-model:value={searchModel.value.name} options={personnelOptions} placeholder="请选择" clearable />
  },
  {
    label: '角色',
    path: 'role',
    render: () => <n-select v-model:value={searchModel.value.role} options={roleOptions} placeholder="请选择" clearable />
  },
  {
    label: '打卡日期',
    path: 'dateRange',
    render: () => <n-date-picker v-model:value={searchModel.value.dateRange} type="daterange" clearable start-placeholder="开始时间" end-placeholder="结束时间" />
    path: 'statisticDate',
    render: () => <n-date-picker v-model:value={searchModel.value.statisticDate} type="daterange" clearable start-placeholder="开始时间" end-placeholder="结束时间" />
  }
])
@@ -108,31 +67,70 @@
  return 'default'
}
const columns = computed<DataTableColumns<AttendanceDetail>>(() => [
  { title: '序号', key: 'id', width: 80, align: 'center' },
  { title: '人员', key: 'name', width: 100 },
  { title: '角色', key: 'role', width: 100 },
  { title: '所属队伍', key: 'fleet', width: 120 },
  { title: '日期', key: 'date', width: 120 },
  { title: '班次', key: 'shift', width: 150 },
  { title: '打卡时间', key: 'checkInTime', width: 160 },
const columns = computed<DataTableColumns<any>>(() => [
  { title: '序号', key: 'serialNumber', width: 80, resizable: true, fixed: 'left' },
  { title: '人员', key: 'name', width: 100, resizable: true, fixed: 'left'  },
  { title: '角色', key: 'roleName', width: 120, resizable: true, },
  { title: '所属队伍', key: 'teamName', width: 120, resizable: true, },
  { title: '日期', key: 'clockTime', width: 120 },
  { title: '班次', key: 'shiftName', width: 120 },
  { title: '上班打卡时间', key: 'clockInTime', minWidth: 120 },
  {
    title: '打卡结果',
    key: 'checkInResult',
    width: 100,
    align: 'center',
    title: '上班打卡结果',
    key: 'clockInResult',
    minWidth: 120,
    render: (row) => (
      <NTag type={getResultTagType(row.checkInResult)} size="small">
        {row.checkInResult}
      <NTag type={getResultTagType(row.clockInResult)} size="small">
        {row.clockInResult}
      </NTag>
    )
  },
  { title: '打卡地址', key: 'checkInAddress', minWidth: 200 }
  { title: '下班打卡时间', key: 'clockOutTime', minWidth: 120 },
  {
    title: '下班打卡结果',
    key: 'clockOutResult',
     minWidth: 120,
    render: (row) => (
      <NTag type={getResultTagType(row.clockOutResult)} size="small">
        {row.clockOutResult}
      </NTag>
    )
  },
  { title: '打卡地址', key: 'clockInLocation', minWidth: 200 }
])
async function getList() {
  startLoading()
  const params = {
    ...pageParams.value,
    appUserId: searchModel.value.appUserId,
    statisticDate: searchModel.value.statisticDate?.join('-'),
  }
  getClockInStatisticsDetailList(params).then((res) => {
    // statisticsList.value = res.data.records || []
    detailList.value.forEach((item: any, index: number) => {
      item.serialNumber = index + 1
    })
    total.value = res.data.total || res.data.records?.length
  }).finally(() => {
    endLoading()
  })
}
onMounted(() => {
  searchModel.value.appUserId = route.params.id
  getList()
})
function changePage(page: number, size: number) {
  pageParams.value.pageNum = page
  pageParams.value.pageSize = size
  getList()
}
</script>
<template>
  <div class="h-full flex flex-col">
  <div class="roboTaxi">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form :model="searchModel" label-placement="left" inline :show-feedback="false">
        <SearchPanel>
@@ -143,42 +141,35 @@
          </n-gi>
          <template #action>
            <n-button style="width: 84px; height: 32px" @click="handleReset">重置</n-button>
            <n-button type="primary" style="width: 84px; height: 32px" @click="handleSearch">查询</n-button>
            <n-button type="primary" style="width: 84px; height: 32px" @click="getList">查询</n-button>
          </template>
        </SearchPanel>
      </n-form>
    </n-card>
    <n-card style="border-radius: 6px; margin-top: 12px" content-style="padding: 12px">
      <div class="mb-4 flex items-center justify-between">
        <div class="flex items-center gap-4 text-sm">
          <span>应打卡次数:<span class="font-bold">{{ statistics.shouldAttendDays }}次</span></span>
          <span>正常:<span class="font-bold">{{ statistics.normalDays }}次</span></span>
          <span>迟到:<span class="font-bold">{{ statistics.lateDays }}次</span></span>
          <span>缺卡:<span class="font-bold">{{ statistics.absentDays }}次</span></span>
        </div>
        <div class="flex gap-2">
          <n-button
            style="width: 100px; height: 32px; background-color: #E6F4FF; color: #0068FF; border: 1px solid #7ABDFF"
            @click="handleExport">
            导出打卡明细
          </n-button>
        </div>
      </div>
      <n-data-table
        :columns="columns"
        :data="detailList"
        :pagination="{
          page: currentPage,
          pageSize: pageSize,
          itemCount: total,
          showSizePicker: true,
          pageSizes: [10, 20, 50, 100],
          prefix: ({ itemCount }) => `共 ${itemCount || 0} 条`
        }"
        :scroll-x="1400"
      />
    <n-card style=" margin-top: 12px">
      <template #header>
        <n-flex justify="space-between">
          <div class="flex items-center gap-4 text-sm">
            <span>应打卡次数:<span class="font-bold">{{ statistics.shouldAttendDays }}次</span></span>
            <span>正常:<span class="font-bold">{{ statistics.normalDays }}次</span></span>
            <span>迟到/早退:<span class="font-bold">{{ statistics.lateDays }}次</span></span>
            <span>缺卡:<span class="font-bold">{{ statistics.absentDays }}次</span></span>
          </div>
          <div class="flex gap-2">
            <n-button
              style="width: 100px; height: 32px; background-color: #E6F4FF; color: #0068FF; border: 1px solid #7ABDFF"
              @click="handleExport">
              导出打卡明细
            </n-button>
          </div>
        </n-flex>
      </template>
      <NSpace vertical :size="12">
        <n-data-table :columns="columns" :data="detailList" :loading="loading" :bordered="false" :header-height="18"
          :row-height="18" />
        <Pagination :count="total" @change="changePage" />
      </NSpace>
    </n-card>
  </div>
</template>
src/views/operations/attendance/statistics/index.vue
@@ -1,27 +1,21 @@
<script setup lang="tsx">
import { ref, computed } from 'vue'
import { useBoolean } from '@/hooks'
import { useRouter } from 'vue-router'
import type { DataTableColumns } from 'naive-ui'
import { getClockInStatisticsList, exportClockInStatistics } from '@/service/api/attendance'
import { NButton } from 'naive-ui'
import { downloadFile } from '@/utils'
import SearchPanel from '@/components/custom/SearchPanel.vue'
interface Statistics {
  id: number
  name: string
  role: string
  fleet: string
  shouldAttendDays: number
  actualAttendDays: number
  lateDays: number
  absentDays: number
}
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const router = useRouter()
const searchModel = ref({
  name: null as string | null,
  role: null as string | null,
  dateRange: null as [number, number] | null
  roleId: null as string | null,
  statisticDate: null as [number, number] | null
})
const personnelOptions = [
@@ -36,40 +30,46 @@
  { label: '角色1', value: '角色1' }
]
const statisticsList = ref<Statistics[]>([
  { id: 1, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 25, lateDays: 1, absentDays: 1 },
  { id: 2, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 24, lateDays: 2, absentDays: 1 },
  { id: 3, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 25, lateDays: 2, absentDays: 0 },
  { id: 4, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 25, lateDays: 2, absentDays: 1 },
  { id: 5, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 25, lateDays: 0, absentDays: 2 },
  { id: 6, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 26, lateDays: 0, absentDays: 1 },
  { id: 7, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 27, lateDays: 0, absentDays: 1 },
  { id: 8, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 26, lateDays: 1, absentDays: 0 },
  { id: 9, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 26, lateDays: 1, absentDays: 0 },
  { id: 10, name: '王美丽', role: '网格员', fleet: '网格员一队', shouldAttendDays: 27, actualAttendDays: 26, lateDays: 1, absentDays: 0 }
])
const statisticsList = ref<any[]>([{
  serialNumber: 1,
  name: '王美丽',
  roleName: '网格员',
  teamName: '队伍1',
  shouldClockInDays: 10,
  clockInDays: 8,
  notClockInDays: 2,
  lateEarly: 1,
}])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(1000)
const handleSearch = () => {
  console.log('搜索', searchModel.value)
}
const total = ref(0)
const pageParams = ref({
  pageNum: 1,
  pageSize: 10,
})
const handleReset = () => {
  searchModel.value = {
    name: null,
    role: null,
    dateRange: null
    roleId: null,
    statisticDate: null
  }
}
const handleExport = () => {
  console.log('导出')
const handleExport = async () => {
  const params = {
    ...pageParams.value,
    name: searchModel.value.name,
    roleId: searchModel.value.roleId,
    statisticDate: searchModel.value.statisticDate?.join('-'),
  }
  const response = await exportClockInStatistics(params)
  if (response instanceof Blob) {
    downloadFile(response, `打卡统计列表_${new Date().getTime()}.xlsx`)
    window.$message.success('导出成功')
  }
}
const handleViewDetail = (row: Statistics) => {
const handleViewDetail = (row: any) => {
  router.push(`/operations/attendance/statistics/detail/${row.id}`)
}
@@ -81,30 +81,29 @@
  },
  {
    label: '角色',
    path: 'role',
    render: () => <n-select v-model:value={searchModel.value.role} options={roleOptions} placeholder="请选择" clearable />
    path: 'searchModel.value.roleId',
    render: () => <n-select v-model:value={searchModel.value.roleId} options={roleOptions} placeholder="请选择" clearable />
  },
  {
    label: '打卡日期',
    path: 'dateRange',
    render: () => <n-date-picker v-model:value={searchModel.value.dateRange} type="daterange" clearable start-placeholder="开始时间" end-placeholder="结束时间" />
    path: 'statisticDate',
    render: () => <n-date-picker v-model:value={searchModel.value.statisticDate} type="daterange" clearable start-placeholder="开始时间" end-placeholder="结束时间" />
  }
])
const columns = computed<DataTableColumns<Statistics>>(() => [
  { title: '序号', key: 'id', width: 80, align: 'center' },
  { title: '人员', key: 'name', width: 120 },
  { title: '角色', key: 'role', width: 120 },
  { title: '所属队伍', key: 'fleet', width: 150 },
  { title: '应打卡天数', key: 'shouldAttendDays', width: 120, align: 'center' },
  { title: '正常', key: 'actualAttendDays', width: 100, align: 'center' },
  { title: '缺卡', key: 'absentDays', width: 100, align: 'center' },
  { title: '迟到', key: 'lateDays', width: 100, align: 'center' },
const columns = computed<DataTableColumns<any>>(() => [
  { title: '序号', key: 'serialNumber', width: 91, resizable: true, fixed: 'left' },
  { title: '人员', key: 'name', width: 143, resizable: true, fixed: 'left'  },
  { title: '角色', key: 'roleName', width: 134, resizable: true, },
  { title: '所属队伍', key: 'teamName', width: 142, resizable: true, },
  { title: '应打卡天数', key: 'shouldClockInDays', minWidth: 100, resizable: true, },
  { title: '正常', key: 'clockInDays', minWidth: 100, resizable: true, },
  { title: '缺卡', key: 'notClockInDays', minWidth: 100, resizable: true, },
  { title: '迟到', key: 'lateEarly', minWidth: 100, resizable: true, },
  {
    title: '操作',
    key: 'actions',
    width: 120,
    align: 'center',
    width: 134,
    fixed: 'right',
    render: (row) => (
      <NButton text type="primary" onClick={() => handleViewDetail(row)}>
@@ -113,10 +112,39 @@
    )
  }
])
async function getList() {
  startLoading()
  const params = {
    ...pageParams.value,
    name: searchModel.value.name,
    roleId: searchModel.value.roleId,
    statisticDate: searchModel.value.statisticDate?.join('-'),
  }
  getClockInStatisticsList(params).then((res) => {
    // statisticsList.value = res.data.records || []
    statisticsList.value.forEach((item: any, index: number) => {
      item.serialNumber = index + 1
    })
    total.value = res.data.total || res.data.records?.length
  }).finally(() => {
    endLoading()
  })
}
onMounted(() => {
  getList()
})
function changePage(page: number, size: number) {
  pageParams.value.pageNum = page
  pageParams.value.pageSize = size
  getList()
}
</script>
<template>
  <div class="h-full flex flex-col">
  <div class="roboTaxi">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form :model="searchModel" label-placement="left" inline :show-feedback="false">
        <SearchPanel>
@@ -127,30 +155,25 @@
          </n-gi>
          <template #action>
            <n-button style="width: 84px; height: 32px" @click="handleReset">重置</n-button>
            <n-button type="primary" style="width: 84px; height: 32px" @click="handleSearch">查询</n-button>
            <n-button type="primary" style="width: 84px; height: 32px" @click="getList">查询</n-button>
          </template>
        </SearchPanel>
      </n-form>
    </n-card>
    <n-card style="border-radius: 6px; margin-top: 12px" content-style="padding: 12px">
      <div class="mb-4 flex justify-end">
        <n-button style="height: 32px;background-color: #E6F4FF;color: #0068FF;border: 1px solid #7ABDFF;" @click="handleExport">导出</n-button>
      </div>
      <n-data-table
        :columns="columns"
        :data="statisticsList"
        :pagination="{
          page: currentPage,
          pageSize: pageSize,
          itemCount: total,
          showSizePicker: true,
          pageSizes: [10, 20, 50, 100],
          prefix: ({ itemCount }) => `第 ${(currentPage - 1) * pageSize + 1}-${Math.min(currentPage * pageSize, itemCount || 0)} 条 / 总共 ${itemCount || 0} 条`
        }"
        :scroll-x="1200"
      />
       <template #header>
          <n-flex justify="flex-end">
            <NButton type="primary" secondary @click="handleExport">
              导出
            </NButton>
          </n-flex>
        </template>
      <NSpace vertical :size="12">
          <n-data-table :columns="columns" :data="statisticsList" :loading="loading" :bordered="false" :header-height="18"
            :row-height="18" />
          <Pagination :count="total" @change="changePage" />
        </NSpace>
    </n-card>
  </div>
</template>
src/views/operations/message/index.vue
@@ -1,53 +1,68 @@
<script setup lang="ts">
import { ref, h } from 'vue'
import { useBoolean } from '@/hooks'
import type { DataTableColumns } from 'naive-ui'
import { NCheckbox } from 'naive-ui'
import { getNoticeList, updateNoticeConfig } from '@/service/api/attendance'
interface MessageItem {
  id: number
  type: string
  pushMethod: boolean
  smsMethod: boolean
  updateTime: string
}
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const tableData = ref<MessageItem[]>([
  { id: 1, type: '新任务通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 2, type: '任务状态通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 3, type: '排班变动通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 4, type: '公告通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 5, type: '新事故通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' },
  { id: 6, type: '事故状态通知', pushMethod: true, smsMethod: false, updateTime: '2025-05-07 14:53' }
])
const tableData = ref<any[]>([])
const columns: DataTableColumns<MessageItem> = [
const columns: DataTableColumns<any> = [
  {
    title: '序号',
    key: 'id',
    key: 'serialNumber',
    width: 80,
    align: 'center'
  },
  {
    title: '通知类型',
    key: 'type',
    align: 'center'
    key: 'notificationType',
    render: (row) => {
      switch (row.notificationType) {
        //通知类型 1=新增事故,2=事故即将超时提示,3=事故超时提示,4=排班信息变动,5=事故状态变更,6=新增任务,7=任务过期提示,8=版本更新提示,9=变更协议政策内容,10=变更协议内容并需用户授权,11=打卡成功通知
        case 1:
          return '新增事故'
        case 2:
          return '事故即将超时提示'
        case 3:
          return '事故超时提示'
        case 4:
          return '排班信息变动'
        case 5:
          return '事故状态变更'
        case 6:
          return '新增任务'
        case 7:
          return '任务过期提示'
        case 8:
          return '版本更新提示'
        case 9:
          return '变更协议政策内容'
        case 10:
          return '变更协议内容并需用户授权'
        case 11:
          return '打卡成功通知'
        default:
          return row.notificationType
      }
    }
  },
  {
    title: '通知方式',
    key: 'method',
    align: 'center',
    render: (row) => {
      return h('div', { class: 'flex items-center justify-center gap-4' }, [
      return h('div', { class: 'flex items-center gap-4' }, [
        h(NCheckbox, {
          checked: row.pushMethod,
          checked: row.isPush,
          onUpdateChecked: (value: boolean) => {
            row.pushMethod = value
            row.isPush = value
          }
        }, { default: () => 'PUSH推送' }),
        h(NCheckbox, {
          checked: row.smsMethod,
          checked: row.isMessage,
          onUpdateChecked: (value: boolean) => {
            row.smsMethod = value
            row.isMessage = value
          }
        }, { default: () => '短信通知' })
      ])
@@ -56,29 +71,51 @@
  {
    title: '更新时间',
    key: 'updateTime',
    align: 'center'
  }
]
const handleSave = () => {
  console.log('保存', tableData.value)
  startLoading()
  updateNoticeConfig({ notifications: tableData.value }).then((res) => {
    if (res.code === 200) {
      window.$message.success('保存成功')
    }
  }).finally(() => {
    getList()
    endLoading()
  })
}
async function getList() {
  startLoading()
  getNoticeList().then((res) => {
    tableData.value = res.data || []
    tableData.value.forEach((item: any, index: number) => {
      item.serialNumber = index + 1
    })
  }).finally(() => {
    endLoading()
  })
}
onMounted(() => {
  getList()
})
</script>
<template>
  <div class="h-full flex-col">
    <n-card style="border-radius: 6px; overflow: hidden;" content-style="padding: 12px; height: 100%; display: flex; flex-direction: column;">
    <n-card style="border-radius: 6px; overflow: hidden;"
      content-style="padding: 12px; height: 100%; display: flex; flex-direction: column;">
      <div style="flex: 1; overflow: auto;">
        <n-data-table
          :columns="columns"
          :data="tableData"
          :bordered="true"
          :single-line="false"
        />
        <n-data-table :columns="columns" :data="tableData" :loading="loading" :bordered="false" :header-height="18"
          :row-height="18" />
      </div>
    </n-card>
    <div class="flex justify-end absolute bottom-0 left-0 right-0" style="background-color: white; padding: 16px 24px; border-radius: 6px;">
    <div class="flex justify-end absolute bottom-0 left-0 right-0"
      style="background-color: white; padding: 16px 24px; border-radius: 6px;">
      <n-button type="primary" style="width: 84px; height: 32px" @click="handleSave">保存</n-button>
    </div>
  </div>
src/views/operations/personnel/personnelManagement/components/PersonnelFormModal.vue
@@ -3,18 +3,19 @@
import type { FormInst, FormRules, UploadFileInfo } from 'naive-ui'
interface FormModel {
  name: string
  phone: string
  nickName: string
  phoneNumber: string
  uniqueNumber: string
  avatar: string
  contactPhone: string
  password: string
  role: string | null
  remark: string
  roleIds: string[] | null
  remarks: string
}
const props = defineProps<{
  show: boolean
  mode?: 'add' | 'edit'
  roleOptions: any[]
}>()
const emit = defineEmits<{
@@ -29,39 +30,35 @@
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  name: '',
  phone: '',
  nickName: '',
  phoneNumber: '',
  uniqueNumber: '',
  avatar: '',
  contactPhone: '',
  password: '',
  role: null,
  remark: ''
  roleIds: null,
  remarks: ''
})
const roleOptions = [
  { label: '运维管理', value: '运维管理' },
  { label: '角色1', value: '角色1' },
  { label: '角色2', value: '角色2' }
]
const roleOptions = computed(() => props.roleOptions)
const fileList = ref<UploadFileInfo[]>([])
const rules: FormRules = {
  name: [
  nickName: [
    { required: true, message: '请输入用户名称', trigger: 'blur' }
  ],
  phone: [
  uniqueNumber: [
    { required: true, message: '请输入编号', trigger: 'blur' }
  ],
  password: [
    { required: true, message: '请输入初始密码', trigger: 'blur' }
  ],
  contactPhone: [
  phoneNumber: [
    { required: true, message: '请输入联系电话', trigger: 'blur' },
    { pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }
  ],
  role: [
    { required: true, message: '请选择角色', trigger: 'change' }
  roleIds: [
    { type: 'array', required: true, message: '请选择角色', trigger: 'change' }
  ]
}
@@ -81,15 +78,25 @@
  }
}
async function openModal(row: any) {
  formModel.value = {...row}
  formModel.value.roleIds = row.sysRoles.map((item: any) => item.id)
}
defineExpose({
  openModal,
})
const resetForm = () => {
  formModel.value = {
    name: '',
    phone: '',
    nickName: '',
    phoneNumber: '',
    uniqueNumber: '',
    avatar: '',
    contactPhone: '',
    password: '',
    role: null,
    remark: ''
    roleIds: null,
    remarks: ''
  }
  fileList.value = []
}
@@ -103,11 +110,11 @@
</script>
<template>
  <n-modal
    v-model:show="show"
    :mask-closable="false"
    preset="card"
    :title="mode === 'edit' ? '编辑运维人员' : '新增运维人员'"
  <n-modal
    v-model:show="show"
    :mask-closable="false"
    preset="card"
    :title="mode === 'edit' ? '编辑账号' : '新增账号'"
    class="w-600px"
    :segmented="{
      content: true,
@@ -121,12 +128,12 @@
      require-mark-placement="left"
    >
      <div class="grid grid-cols-2 gap-4">
        <n-form-item label="用户名称" path="name">
          <n-input v-model:value="formModel.name" placeholder="请输入" clearable />
        <n-form-item label="用户名称" path="nickName">
          <n-input v-model:value="formModel.nickName" placeholder="请输入" clearable />
        </n-form-item>
        <n-form-item label="编号" path="phone">
          <n-input v-model:value="formModel.phone" placeholder="请输入" clearable />
        <n-form-item label="编号" path="uniqueNumber">
          <n-input v-model:value="formModel.uniqueNumber" placeholder="请输入" clearable />
        </n-form-item>
      </div>
@@ -144,24 +151,24 @@
          </n-upload>
        </n-form-item>
        <n-form-item label="初始密码" path="password">
        <n-form-item label="初始密码" path="password" v-if="mode === 'add'">
          <n-input v-model:value="formModel.password" type="password" placeholder="请输入" clearable show-password-on="click" />
        </n-form-item>
      </div>
      <div class="grid grid-cols-2 gap-4">
        <n-form-item label="联系电话" path="contactPhone">
          <n-input v-model:value="formModel.contactPhone" placeholder="请输入" clearable />
        <n-form-item label="联系电话" path="phoneNumber">
          <n-input v-model:value="formModel.phoneNumber" placeholder="请输入" clearable />
        </n-form-item>
        <n-form-item label="角色" path="role">
          <n-select v-model:value="formModel.role" :options="roleOptions" placeholder="请选择" clearable />
        <n-form-item label="角色" path="roleIds">
          <n-select v-model:value="formModel.roleIds" :options="roleOptions" placeholder="请选择" clearable multiple/>
        </n-form-item>
      </div>
      <n-form-item label="备注" path="remark">
      <n-form-item label="备注" path="remarks">
        <n-input
          v-model:value="formModel.remark"
          v-model:value="formModel.remarks"
          type="textarea"
          placeholder="请输入"
          :rows="3"
src/views/operations/personnel/personnelManagement/detail.vue
@@ -1,30 +1,30 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useTabStore } from '@/store'
import { useRouter, useRoute } from 'vue-router'
import { getOperationsUserDetail } from '@/service/api/attendance'
const router = useRouter()
const route = useRoute()
const tabStore = useTabStore()
const personnelInfo = ref({
  name: '王丽丽',
  phone: '10001',
  contactPhone: '14725836905',
  role: '调度员',
  avatar: 'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
  remark: '-',
  idCardFront: '',
  idCardBack: '',
  driverLicense: '',
  noCriminalRecord: ''
})
const personnelInfo = ref()
const handleBack = () => {
  tabStore.closeTab(route.fullPath)
  router.back()
}
onMounted(async () => {
  const res = await getOperationsUserDetail(route.params.id)
  if (res.code === 200) {
    personnelInfo.value = res.data
  }
})
</script>
<template>
  <div class="h-full flex flex-col" style="gap: 12px">
  <div class="h-full flex flex-col" style="gap: 12px" v-if="personnelInfo?.nickName">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <div class="flex items-center justify-between mb-4">
        <div class="text-lg font-bold">员工详情</div>
@@ -39,25 +39,26 @@
        />
        <div class="flex-1">
          <div class="flex items-center gap-3 mb-2">
            <span class="text-lg font-medium">{{ personnelInfo.name }}</span>
            <n-tag type="info" size="small">运维管理员</n-tag>
            <span class="text-lg font-medium">{{ personnelInfo.nickName }}</span>
          </div>
          <div class="text-sm mb-2">
            <span class="text-gray-600">编号:</span>
            <span>{{ personnelInfo.phone }}</span>
            <span>{{ personnelInfo.uniqueNumber }}</span>
          </div>
          <div class="grid grid-cols-3 gap-4 text-sm">
            <div>
              <span class="text-gray-600">电话:</span>
              <span>{{ personnelInfo.contactPhone }}</span>
              <span>{{ personnelInfo.phoneNumber }}</span>
            </div>
            <div>
              <span class="text-gray-600">角色:</span>
              <span>{{ personnelInfo.role }}</span>
              <span class="gap-2 inline-flex">
                <n-tag type="info" size="small" v-for="item in personnelInfo.sysRoles">{{ item.roleName }}</n-tag>
              </span>
            </div>
            <div>
              <span class="text-gray-600">备注:</span>
              <span>{{ personnelInfo.remark }}</span>
              <span>{{ personnelInfo.remarks || '-' }}</span>
            </div>
          </div>
        </div>
@@ -71,14 +72,16 @@
          <n-image
            width="128"
            height="80"
            src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
            v-if="personnelInfo.idCardFront"
            :src="personnelInfo.idCardFront"
            object-fit="cover"
            style="border-radius: 4px"
          />
          <n-image
            width="128"
            height="80"
            src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
            v-if="personnelInfo.idCardBack"
            :src="personnelInfo.idCardBack"
            object-fit="cover"
            style="border-radius: 4px"
          />
@@ -90,7 +93,8 @@
        <n-image
          width="128"
          height="80"
          src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
          v-if="personnelInfo.driverLicense"
          :src="personnelInfo.driverLicense"
          object-fit="cover"
          style="border-radius: 4px"
        />
@@ -101,7 +105,8 @@
        <n-image
          width="128"
          height="80"
          src="https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg"
          v-if="personnelInfo.certificateOfNoCriminalRecord"
          :src="personnelInfo.certificateOfNoCriminalRecord"
          object-fit="cover"
          style="border-radius: 4px"
        />
src/views/operations/personnel/personnelManagement/index.vue
@@ -1,61 +1,45 @@
<script setup lang="tsx">
import { ref, h, computed } from 'vue'
import { useBoolean } from '@/hooks'
import { useRouter } from 'vue-router'
import type { DataTableColumns } from 'naive-ui'
import { NButton, NTag, NDropdown } from 'naive-ui'
import { getOperationsUserList, exportOperationsUserTemplate, exportOperationsUser, importOperationsUser,
  addOperationsUser, editOperationsUser, updateOperationsUserStatus, resetOperationsUserPassword, deleteOperationsUser } from '@/service/api/attendance'
import { fetchRoleList } from '@/service/api/fleet'
import { downloadFile } from '@/utils'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import PersonnelFormModal from './components/PersonnelFormModal.vue'
const router = useRouter()
interface Personnel {
  id: number
  avatar: string
  name: string
  phone: string
  contactPhone: string
  role: string
  status: string
  createTime: string
  remark: string
}
const dialog = useDialog()
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const searchModel = ref({
  name: '',
  phone: '',
  role: null as string | null,
  nickName: '',
  phoneNumber: '',
  roleId: null as string | null,
  status: null as string | null
})
const showFormModal = ref(false)
const formMode = ref<'add' | 'edit'>('add')
const roleOptions = [
  { label: '运维管理', value: '运维管理' },
  { label: '角色1', value: '角色1' }
]
const roleOptions = ref<any[]>([])
const modalRef = ref()
const statusOptions = [
  { label: '启用', value: '启用' },
  { label: '禁用', value: '禁用' }
  { label: '启用', value: true },
  { label: '禁用', value: false }
]
const personnelList = ref<Personnel[]>([
  { id: 1, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '运维管理', status: '启用', createTime: '2025-06-08 03:15', remark: '前端开发' },
  { id: 2, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '角色1', status: '禁用', createTime: '2025-06-08 03:15', remark: '' },
  { id: 3, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '角色1', status: '启用', createTime: '2025-06-08 03:15', remark: '' },
  { id: 4, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '角色1', status: '启用', createTime: '2025-06-08 03:15', remark: '' },
  { id: 5, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '角色1', status: '启用', createTime: '2025-06-08 03:15', remark: '' },
  { id: 6, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '角色1', status: '启用', createTime: '2025-06-08 03:15', remark: '' },
  { id: 7, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '角色1', status: '启用', createTime: '2025-06-08 03:15', remark: '' },
  { id: 8, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '角色1', status: '启用', createTime: '2025-06-08 03:15', remark: '' },
  { id: 9, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '角色1', status: '启用', createTime: '2025-06-08 03:15', remark: '' },
  { id: 10, avatar: '', name: '王美丽', phone: '3948716', contactPhone: '14725836905', role: '角色1', status: '启用', createTime: '2025-06-08 03:15', remark: '' }
])
const personnelList = ref<any[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const total = ref(800)
const total = ref(0)
const pageParams = ref({
  pageNum: 1,
  pageSize: 10,
})
const handleSearch = () => {
  console.log('搜索', searchModel.value)
@@ -63,19 +47,58 @@
const handleReset = () => {
  searchModel.value = {
    name: '',
    phone: '',
    role: null,
    nickName: '',
    phoneNumber: '',
    roleId: null,
    status: null
  }
}
const handleExport = () => {
  console.log('导出')
  const params = {
    ...pageParams.value,
    nickName: searchModel.value.nickName,
    phoneNumber: searchModel.value.phoneNumber,
    roleId: searchModel.value.roleId,
    status: searchModel.value.status,
  }
  exportOperationsUser(params).then(res => {
    if (res instanceof Blob) {
      downloadFile(res, `运维人员列表_${new Date().getTime()}.xlsx`)
      window.$message.success('导出成功')
    }
  })
}
const handleImport = () => {
  console.log('导入')
// 文件导入相关
const fileInputRef = ref<HTMLInputElement | null>(null)
// 导入
function handleImport() {
  fileInputRef.value?.click()
}
// 处理文件选择
async function handleFileChange(event: Event) {
  const target = event.target as HTMLInputElement
  const file = target.files?.[0]
  if (!file)
    return
  try {
    const { code } = await importOperationsUser(file)
    if (code === 200) {
      window.$message.success('导入成功')
      getList()
    }
  }
  catch (error) {
    console.error('导入失败:', error)
  }
  finally {
    // 清空 input 方便下次选择同一个文件
    target.value = ''
  }
}
const handleAdd = () => {
@@ -83,20 +106,34 @@
  showFormModal.value = true
}
const handleEdit = (row: Personnel) => {
  router.push(`/operations/personnel/personnelManagement/detail/${row.id}`)
const handleEdit = (row: any) => {
  // router.push(`/operations/personnel/personnelManagement/detail/${row.id}`)
  formMode.value = 'edit'
  showFormModal.value = true
  modalRef.value?.openModal(row)
}
const handleConfirmForm = (data: any) => {
  console.log('保存数据', data)
  // 这里可以调用API保存数据
  if (data.id) {
    editOperationsUser(data).then(res => {
      if (res.code === 200) {
        showFormModal.value = false
        window.$message.success('编辑成功')
        getList()
      }
    })
  } else {
    addOperationsUser(data).then(res => {
      if (res.code === 200) {
        showFormModal.value = false
        window.$message.success('新增成功')
        getList()
      }
    })
  }
}
const handleMore = (row: Personnel) => {
  console.log('更多', row)
}
const moreOptions = (row: Personnel) => [
const moreOptions = (row: any) => [
  {
    label: '编辑',
    key: 'edit',
@@ -105,24 +142,68 @@
    }
  },
  {
    label: row.status === '启用' ? '禁用' : '启用',
    label: row.status ? '禁用' : '启用',
    key: 'toggle',
    props: {
      onClick: () => console.log('切换状态', row)
      onClick: () => {
        updateOperationsUserStatus({
          id: row.id,
          status: !row.status
        }).then(res => {
          if (res.code === 200) {
            window.$message.success('操作成功')
            getList()
          }
        })
      }
    }
  },
  {
    label: '重置密码',
    key: 'password',
    props: {
      onClick: () => console.log('重置密码', row)
      onClick: () => {
        dialog.create({
          title: '确认重置密码?',
          content: '',
          positiveText: '重置密码',
          negativeText: '取消',
          draggable: true,
          onPositiveClick: () => {
            resetOperationsUserPassword({
              id: row.id
            }).then(res => {
              if (res.code === 200) {
                window.$message.success('重置密码成功')
                getList()
              }
            })
          }
        })
      }
    }
  },
  {
    label: '删除',
    key: 'delete',
    props: {
      onClick: () => console.log('删除', row)
      onClick: () => {
        dialog.create({
          title: '确认删除吗?',
          content: '',
          positiveText: '删除',
          negativeText: '取消',
          draggable: true,
          onPositiveClick: () => {
            deleteOperationsUser(row.id).then(res => {
              if (res.code === 200) {
                window.$message.success('删除成功')
                getList()
              }
            })
          }
        })
      }
    }
  }
]
@@ -130,80 +211,77 @@
const filterItems = computed(() => [
  {
    label: '员工姓名',
    path: 'name',
    render: () => <n-input v-model:value={searchModel.value.name} placeholder="请输入" clearable />
    path: 'nickName',
    render: () => <n-input v-model:value={searchModel.value.nickName} placeholder="请输入" clearable />
  },
  {
    label: '联系电话',
    path: 'phone',
    render: () => <n-input v-model:value={searchModel.value.phone} placeholder="请输入" clearable />
    path: 'phoneNumber',
    render: () => <n-input v-model:value={searchModel.value.phoneNumber} placeholder="请输入" clearable />
  },
  {
    label: '角色',
    path: 'role',
    render: () => <n-select v-model:value={searchModel.value.role} options={roleOptions} placeholder="请选择" clearable />
    path: 'roleId',
    render: () => <n-select v-model:value={searchModel.value.roleId} options={roleOptions.value} placeholder="请选择" clearable />
  },
  {
    label: '账号状态',
    path: 'status',
    render: () => <n-select v-model:value={searchModel.value.status} options={statusOptions} placeholder="请选择" clearable />
  }
  },
])
const columns = computed<DataTableColumns<Personnel>>(() => [
  { title: '序号', key: 'id', width: 80, align: 'center' },
const columns = computed<DataTableColumns<any>>(() => [
  { title: '序号', key: 'serialNumber', width: 80, resizable: true },
  {
    title: '姓名',
    key: 'name',
    key: 'nickName',
    width: 150,
    resizable: true,
    render: (row) => (
      <div class="flex items-center gap-2">
        <div class="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-sm">头</div>
        <span>{row.name}</span>
        {row.avatar && <img src={row.avatar} alt="头像" class="w-8 h-8 rounded-full" />}
        {!row.avatar && <div class="w-8 h-8 rounded-full bg-gray-300 flex items-center justify-center text-sm">头</div>}
        <span>{row.nickName}</span>
      </div>
    )
  },
  { title: '编号', key: 'phone', width: 120 },
  { title: '联系电话', key: 'contactPhone', width: 150 },
  { title: '编号', key: 'uniqueNumber', width: 120, resizable: true  },
  { title: '联系电话', key: 'phoneNumber', width: 150, resizable: true  },
  {
    title: '角色',
    key: 'role',
    key: 'sysRoles',
    width: 150,
    resizable: true,
    render: (row) => (
      <NTag type="info" size="small" style={{ borderRadius: '4px' }}>{row.role}</NTag>
      <div>
        {
          row.sysRoles?.map((role: any) => (
            <NTag type="primary" size="small" style={{ borderRadius: '4px' }} >{role.roleName}</NTag>
          ))
        }
      </div>
    )
  },
  {
    title: '状态',
    key: 'status',
    width: 100,
    render: (row) => {
      const isActive = row.status === '启用'
      return h('span', {
        style: {
          display: 'inline-block',
          width: '52px',
          height: '20px',
          lineHeight: '20px',
          textAlign: 'center',
          fontSize: '12px',
          borderRadius: '4px',
          color: isActive ? '#0068FF' : 'rgba(23, 31, 38, 0.8)',
          backgroundColor: isActive ? '#E6F4FF' : 'rgba(23, 31, 38, 0.05)'
        }
      }, row.status)
    }
    resizable: true,
    render: (row) => (
      row.status  ? <NTag type="primary" size="small" style={{ borderRadius: '4px' }} >启用</NTag> : <NTag type="error" size="small" style={{ borderRadius: '4px' }} >禁用</NTag>
    )
  },
  { title: '创建时间', key: 'createTime', width: 180 },
  { title: '备注', key: 'remark', minWidth: 150 },
  { title: '创建时间', key: 'createTime', width: 180 , resizable: true },
  { title: '备注', key: 'remarks', width: 150, resizable: true },
  {
    title: '操作',
    key: 'actions',
    width: 150,
    width: 120,
    fixed: 'right',
    render: (row) => (
      <div class="flex gap-3">
        <a style={{ color: '#0068FF', cursor: 'pointer' }} onClick={() => handleEdit(row)}>详情</a>
        <a style={{ color: '#0068FF', cursor: 'pointer' }} onClick={() => router.push(`/operations/personnel/personnelManagement/detail/${row.id}`)}>详情</a>
        <NDropdown options={moreOptions(row)} trigger="click">
          <a style={{ color: '#0068FF', cursor: 'pointer' }}>更多</a>
        </NDropdown>
@@ -211,10 +289,59 @@
    )
  }
])
async function getList() {
  startLoading()
  const params = {
    ...pageParams.value,
    nickName: searchModel.value.nickName,
    phoneNumber: searchModel.value.phoneNumber,
    roleId: searchModel.value.roleId,
    status: searchModel.value.status,
  }
  getOperationsUserList(params).then((res) => {
    personnelList.value = res.data.records || []
    personnelList.value.forEach((item: any, index: number)  => {
      item.serialNumber = index + 1
    })
    total.value = res.data.total || res.data.records?.length
  }).finally(() => {
    endLoading()
  })
}
async function getRoleList() {
  const res = await fetchRoleList()
  if (res.code === 200) {
    roleOptions.value = res.data.map((item: any) => ({
      label: item.roleName,
      value: item.id,
    }))
  }
}
onMounted(() => {
  getList()
  getRoleList()
})
function changePage(page: number, size: number) {
  pageParams.value.pageNum = page
  pageParams.value.pageSize = size
  getList()
}
const handleDownloadTemplate = async () => {
  const response = await exportOperationsUserTemplate()
  if (response instanceof Blob) {
    downloadFile(response, `运维人员导入模板.xlsx`)
    window.$message.success('导出成功')
  }
}
</script>
<template>
  <div class="h-full flex flex-col">
  <div class="roboTaxi">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form :model="searchModel" label-placement="left" inline :show-feedback="false">
        <SearchPanel>
@@ -225,35 +352,38 @@
          </n-gi>
          <template #action>
            <n-button style="width: 84px; height: 32px" @click="handleReset">重置</n-button>
          <n-button type="primary" style="width: 84px; height: 32px" @click="handleSearch">查询</n-button>
            <n-button type="primary" style="width: 84px; height: 32px" @click="getList">查询</n-button>
          </template>
        </SearchPanel>
      </n-form>
    </n-card>
    <n-card style="border-radius: 6px;margin-top: 12px;" content-style="padding: 12px">
      <div class="mb-4 flex justify-end gap-2">
        <n-button @click="handleExport" style="width: 84px; height: 32px">导出</n-button>
        <n-button @click="handleImport" style="width: 84px; height: 32px">导入</n-button>
        <n-button style="width: 84px;height: 32px;background-color: #E6F4FF;color: #0068FF;border: 1px solid #7ABDFF;" @click="handleAdd">新增</n-button>
      </div>
      <n-data-table
        :columns="columns"
        :data="personnelList"
        :pagination="{
          page: currentPage,
          pageSize: pageSize,
          itemCount: total,
          showSizePicker: true,
          pageSizes: [10, 20, 50, 100],
          prefix: ({ itemCount }) => `共 ${itemCount || 0} 条`
        }"
        :scroll-x="1400"
      />
    <n-card class="flex-1 mt-12px">
      <template #header>
        <n-flex justify="flex-end">
          <n-button @click="handleExport" style="width: 84px; height: 32px">导出</n-button>
          <n-button @click="handleImport" style="width: 84px; height: 32px">导入</n-button>
          <n-button @click="handleDownloadTemplate" style="width: 84px; height: 32px">下载模板</n-button>
          <n-button style="width: 84px;height: 32px;background-color: #E6F4FF;color: #0068FF;border: 1px solid #7ABDFF;"
            @click="handleAdd">新增</n-button>
        </n-flex>
      </template>
      <NSpace vertical>
        <n-data-table :columns="columns" :data="personnelList" :loading="loading" :bordered="false" :header-height="18"
          :row-height="18" />
        <Pagination :count="total" @change="changePage" />
      </NSpace>
    </n-card>
    <PersonnelFormModal v-model:show="showFormModal" :mode="formMode" @confirm="handleConfirmForm" />
    <PersonnelFormModal v-model:show="showFormModal" ref="modalRef" :mode="formMode" :roleOptions="roleOptions"
      @confirm="handleConfirmForm" />
    <input
      ref="fileInputRef"
      type="file"
      style="display: none"
      accept=".xlsx, .xls"
      @change="handleFileChange"
    />
  </div>
</template>
src/views/operations/taskOrder/taskOrderCenter/components/AddFormModal.vue
New file
@@ -0,0 +1,440 @@
<script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue'
import type { FormInst, FormRules, UploadFileInfo } from 'naive-ui'
import { useScriptTag } from '@vueuse/core'
import { getVehicleListNoPage } from '@/service/api/attendance'
import { customUploadRequest } from '@/utils/upload'
// main.ts 或 index.html 里(高德脚本加载前)
(window as any)._AMapSecurityConfig = {
  securityJsCode: import.meta.env.VITE_MAP_SECURITY
}
const { load } = useScriptTag(
  `https://webapi.amap.com/maps?v=2.0&key=${import.meta.env.VITE_MAP_KEY}&plugin=AMap.PlaceSearch,AMap.Geocoder`
)
interface FormModel {
  taskDescription: string
  taskImage: UploadFileInfo[]
  taskTypeId: number | null
  taskUrgencyLevel: number | null
  vehicleIds: number[]
  vehicleLat: number | null
  vehicleLon: number | null
  vehicleLocation: string
}
const props = defineProps<{
  show: boolean
  mode?: 'add' | 'edit'
  taskErrorIdOptions: any[]
  taskUrgencyLevelOptions: any[]
}>()
const emit = defineEmits<{
  'update:show': [value: boolean]
  confirm: [data: FormModel]
}>()
const show = computed({
  get: () => props.show,
  set: (value) => emit('update:show', value)
})
const formRef = ref<FormInst | null>(null)
const formModel = ref<FormModel>({
  taskDescription: '',
  taskImage: [],
  taskTypeId: null,
  taskUrgencyLevel: null,
  vehicleIds: [],
  vehicleLat: null,
  vehicleLon: null,
  vehicleLocation: ''
})
const searchKeyword = ref('')
const mapContainer = ref<HTMLDivElement | null>(null)
let map: any = null
let marker: any = null
let placeSearch: any = null
const rules: FormRules = {
  taskTypeId: [
    {
      required: true,
      type: 'array',
      message: '请选择任务类型',
      trigger: ['blur', 'change']
    }
  ],
  taskUrgencyLevel: [
    {
      required: true,
      type: 'number',
      message: '请选择任务等级',
      trigger: ['blur', 'change']
    }
  ],
  vehicleIds: [
    {
      required: true,
      type: 'array',
      message: '请选择车辆',
      trigger: ['blur', 'change']
    }
  ],
  taskDescription: [
    {
      required: true,
      message: '请输入任务描述',
      trigger: ['blur', 'change']
    }
  ],
  vehicleLocation: [
    {
      required: true,
      validator: (_rule: any, _value: any) => {
        if (formModel.value.vehicleLon === null || formModel.value.vehicleLon === undefined ||
          formModel.value.vehicleLat === null || formModel.value.vehicleLat === undefined) {
          return new Error('请在地图上选择位置')
        }
        return true
      },
      trigger: 'change'
    }
  ]
}
const taskErrorIdOptions = computed(() => props.taskErrorIdOptions)
const taskUrgencyLevelOptions = computed(() => props.taskUrgencyLevelOptions)
const vehicleOptions = ref([])
const initMap = async () => {
  if (!mapContainer.value) return
  await load()
  if (typeof (window as any).AMap === 'undefined') {
    return
  }
  map = new AMap.Map(mapContainer.value, {
    zoom: 15,
    center: [116.397428, 39.90923],
    mapStyle: 'amap://styles/whitesmoke'
  })
  if (props.mode === 'edit') {
    // 编辑模式下,根据经纬度初始化地图
    map.setCenter([formModel.value.vehicleLon, formModel.value.vehicleLat])
    marker = new AMap.Marker({
      position: [formModel.value.vehicleLon, formModel.value.vehicleLat],
      draggable: true
    })
    map.add(marker)
    // 监听标记拖拽
    marker.on('dragend', (e: any) => {
      const position = e.target.getPosition()
      formModel.value.vehicleLon = position.lng
      formModel.value.vehicleLat = position.lat
      // 触发验证
      formRef.value?.validate(
        (errors) => {
          // 验证完成
        },
        (rule) => rule?.key === 'location'
      )
      // 逆地理编码获取地址
      const geocoder = new AMap.Geocoder()
      geocoder.getAddress([position.lng, position.lat], (status: string, result: any) => {
        if (status === 'complete' && result.info === 'OK') {
          formModel.value.vehicleLocation = result.regeocode.formattedAddress
        }
      })
    })
  }
  // 点击地图添加标记
  map.on('click', (e: any) => {
    const lng = e.lnglat.lng
    const lat = e.lnglat.lat
    formModel.value.vehicleLon = lng
    formModel.value.vehicleLat = lat
    // 触发验证
    formRef.value?.validate(
      (errors) => {
        // 验证完成
      },
      (rule) => rule?.key === 'vehicleLocation'
    )
    // 如果标记已存在,更新位置;否则创建新标记
    if (marker) {
      marker.setPosition([lng, lat])
    } else {
      marker = new AMap.Marker({
        position: [lng, lat],
        draggable: true
      })
      map.add(marker)
      // 监听标记拖拽
      marker.on('dragend', (e: any) => {
        const position = e.target.getPosition()
        formModel.value.vehicleLon = position.lng
        formModel.value.vehicleLat = position.lat
        // 触发验证
        formRef.value?.validate(
          (errors) => {
            // 验证完成
          },
          (rule) => rule?.key === 'location'
        )
        // 逆地理编码获取地址
        const geocoder = new AMap.Geocoder()
        geocoder.getAddress([position.lng, position.lat], (status: string, result: any) => {
          if (status === 'complete' && result.info === 'OK') {
            formModel.value.vehicleLocation = result.regeocode.formattedAddress
          }
        })
      })
    }
    // 逆地理编码获取地址
    const geocoder = new AMap.Geocoder()
    geocoder.getAddress([lng, lat], (status: string, result: any) => {
      console.log(result, '逆地理编码结果')
      if (status === 'complete' && result.info === 'OK') {
        formModel.value.vehicleLocation = result.regeocode.formattedAddress
      }
    })
  })
  // 添加地图搜索
  placeSearch = new AMap.PlaceSearch({
    city: '全国'
  })
}
const handleSearch = () => {
  if (!searchKeyword.value || !placeSearch) return
  placeSearch.search(searchKeyword.value, (status: string, result: any) => {
    console.log(result, '搜索结果')
    if (status === 'complete' && result.poiList && result.poiList.pois.length > 0) {
      const poi = result.poiList.pois[0]
      formModel.value.vehicleLon = poi.location.lng
      formModel.value.vehicleLat = poi.location.lat
      formModel.value.vehicleLocation = poi.name
      // 触发验证
      formRef.value?.validate(
        (errors) => {
          // 验证完成
        },
        (rule) => rule?.key === 'location'
      )
      if (marker) {
        marker.setPosition([poi.location.lng, poi.location.lat])
      } else {
        marker = new AMap.Marker({
          position: [poi.location.lng, poi.location.lat],
          draggable: true
        })
        map.add(marker)
        // 监听标记拖拽
        marker.on('dragend', (e: any) => {
          const position = e.target.getPosition()
          formModel.value.vehicleLon = position.lng
          formModel.value.vehicleLat = position.lat
          // 触发验证
          formRef.value?.validate(
            (errors) => {
              // 验证完成
            },
            (rule) => rule?.key === 'location'
          )
          // 逆地理编码获取地址
          const geocoder = new AMap.Geocoder()
          geocoder.getAddress([position.lng, position.lat], (status: string, result: any) => {
            if (status === 'complete' && result.info === 'OK') {
              formModel.value.vehicleLocation = result.regeocode.formattedAddress
            }
          })
        })
      }
      map.setCenter([poi.location.lng, poi.location.lat])
    }
  })
}
const handleCancel = () => {
  show.value = false
  resetForm()
}
const handleConfirm = async () => {
  try {
    await formRef.value?.validate()
    emit('confirm', formModel.value)
    show.value = false
    resetForm()
  } catch (error) {
    console.log('验证失败', error)
  }
}
const resetForm = () => {
  formModel.value = {
    taskDescription: '',
    taskImage: [],
    taskTypeId: null,
    taskUrgencyLevel: null,
    vehicleIds: [],
    vehicleLat: null,
    vehicleLon: null,
    vehicleLocation: ''
  }
  if (marker) {
    map.remove(marker)
    marker = null
  }
  searchKeyword.value = ''
}
const openEdit = (data: any) => {
  formModel.value = { ...data }
}
defineExpose({
  openEdit,
})
const fetchVehicleList = async () => {
  try {
    const res = await getVehicleListNoPage()
    if (res.code === 200) {
      vehicleOptions.value = res.data.map((item: any) => ({
        label: item.plateNumber,
        value: item.id
      }))
    }
  } catch (error) {
    console.log('查询车辆不分页列表失败', error)
  }
}
// 监听弹窗显示状态
watch(show, (newVal) => {
  if (newVal) {
    setTimeout(() => {
      initMap()
      fetchVehicleList()
    }, 100)
  } else {
    // 关闭弹窗时清理标记
    if (marker) {
      map.remove(marker)
      marker = null
    }
  }
})
async function handleUploadImages(options: any) {
  const res = await customUploadRequest(options)
  // 上传成功后,更新文件的 id 为后端返回的 id
  if (res?.id) {
    const fileItem = formModel.value.taskImage.find(f => f.id === options.file.id)
    if (fileItem) {
      fileItem.id = res.id
    }
  }
}
onMounted(() => {
  // 加载高德地图
  // initMap()
})
</script>
<template>
  <n-modal v-model:show="show" :mask-closable="false" preset="card" title="创建事故任务" class="w-1000px" :segmented="{
    content: true,
    action: true,
  }">
    <n-form ref="formRef" :model="formModel" :rules="rules" label-placement="top" require-mark-placement="left"
      show-require-mark>
      <div class="grid grid-cols-3 gap-4">
        <n-form-item label="任务类型" path="taskTypeId">
          <n-cascader v-model:value="formModel.taskTypeId" :options="taskErrorIdOptions" value-field="id"
            label-field="errorName" children-field="child" check-strategy="all" placeholder="请选择" clearable multiple
            :max-tag-count="1" />
        </n-form-item>
        <n-form-item label="任务等级" path="taskTypeId">
          <n-select v-model:value="formModel.taskUrgencyLevel" :options="taskUrgencyLevelOptions" placeholder="请选择"
            clearable />
        </n-form-item>
        <n-form-item label="车辆" path="vehicleIds">
          <n-select v-model:value="formModel.vehicleIds" :options="vehicleOptions" placeholder="请选择" clearable multiple
            :max-tag-count="1" />
        </n-form-item>
      </div>
      <div class="grid grid-cols-1 gap-12px">
        <n-form-item label="任务描述" path="taskDescription">
          <n-input v-model:value="formModel.taskDescription" type="textarea" placeholder="请输入" :rows="3" clearable />
        </n-form-item>
        <n-upload v-model:file-list="formModel.taskImage" :max="10" list-type="image-card"
          :custom-request="handleUploadImages">
          <div
            style="width: 100px; height: 100px; display: flex; align-items: center; justify-content: center; font-size: 32px; color: #999;">
            +
          </div>
        </n-upload>
      </div>
      <div class="grid grid-cols-3 gap-4 mt-4">
        <n-form-item label="车辆位置" path="vehicleLocation">
          <n-input :value="formModel.vehicleLocation" placeholder="请在下方地图选择位置" disabled />
        </n-form-item>
      </div>
      <n-form-item label="" path="map">
        <div style="width: 100%; height: 400px; position: relative">
          <div ref="mapContainer" style="width: 100%; height: 100%; border-radius: 4px; border: 1px solid #e5e7eb">
            <div class="flex items-center justify-center h-full text-gray-400">
              加载地图中...
            </div>
          </div>
          <div style="position: absolute; top: 10px; left: 10px; z-index: 1000; width: 300px">
            <n-input v-model:value="searchKeyword" placeholder="搜索地址" clearable @keyup.enter="handleSearch">
            </n-input>
          </div>
        </div>
      </n-form-item>
    </n-form>
    <template #action>
      <n-space justify="end">
        <n-button @click="handleCancel">取消</n-button>
        <n-button type="primary" @click="handleConfirm">保存</n-button>
      </n-space>
    </template>
  </n-modal>
</template>
<style scoped>
:deep(.amap-logo),
:deep(.amap-copyright) {
  display: none !important;
}
</style>
src/views/operations/taskOrder/taskOrderCenter/components/TableModal.vue
New file
@@ -0,0 +1,95 @@
<script setup lang="ts">
import { useBoolean } from '@/hooks'
import { getOperationsUserListNoPage, assignTaskOrder } from '@/service/api/attendance'
import type { FormItemRule } from 'naive-ui'
const emit = defineEmits<{
  open: []
  close: []
  refresh: []
}>()
const { bool: modalVisible, setTrue: showModal, setFalse: hiddenModal } = useBoolean(false)
const { bool: submitLoading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const formDefault = {
  handlerUserId: null,
  id: '',
}
const formModel = ref({ ...formDefault })
async function openModal(id: string) {
  emit('open')
  await getUserList()
  formModel.value.id = id
  showModal()
}
function closeModal() {
  formModel.value = { ...formDefault }
  hiddenModal()
  endLoading()
  emit('close')
}
defineExpose({
  openModal,
})
const formRef = ref()
async function submitModal() {
  await formRef.value?.validate()
  startLoading()
  // 分配任务
  const res = await assignTaskOrder(formModel.value)
  if (res.code === 200) {
    window.$message.success('分配成功')
    emit('refresh')
    closeModal()
  }
  endLoading()
}
const rules = {
  handlerUserId: {
    required: true,
    message: '请选择处理人',
    trigger: 'change',
  } as FormItemRule,
}
const userList = ref<any[]>([])
const getUserList = async () => {
  const res = await getOperationsUserListNoPage()
  if (res.code === 200) {
    userList.value = res.data || []
  }
}
</script>
<template>
  <n-modal v-model:show="modalVisible" :mask-closable="false" preset="card" title="任务分配" class="w-400px" :segmented="{
    content: true,
    action: true,
  }">
    <n-form ref="formRef" :rules="rules" :model="formModel" require-mark-placement="left">
      <n-form-item label="选择处理人" path="handlerUserId">
        <n-select v-model:value="formModel.handlerUserId" :options="userList" value-field="id" label-field="nickName" />
      </n-form-item>
    </n-form>
    <template #action>
      <n-space justify="end">
        <n-button @click="closeModal">
          取消
        </n-button>
        <n-button type="primary" :loading="submitLoading" @click="submitModal">
          保存
        </n-button>
      </n-space>
    </template>
  </n-modal>
</template>
src/views/operations/taskOrder/taskOrderCenter/index.vue
New file
@@ -0,0 +1,365 @@
<script setup lang="tsx">
import type { DataTableColumns, FormInst } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { NButton, NPopconfirm, NSpace, NTag } from 'naive-ui'
import { getTaskAccidentTypeList, getTaskOrderCenterList, deleteTaskOrder, exportTaskOrderCenter, createTaskAccident } from '@/service/api/attendance'
import { downloadFile } from '@/utils'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import TableModal from './components/TableModal.vue'
import AddFormModal from './components/AddFormModal.vue'
import dayjs from 'dayjs'
const router = useRouter()
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const { t } = useI18n()
const initialModel = {
  plateNumber: '',
  taskCategory: null,
  taskErrorId: null,
  taskGeneralId: null,
  taskUrgencyLevel: null,
  time: null,
  status: null,
}
const model = ref({ ...initialModel })
function handleResetSearch() {
  model.value = { ...initialModel }
}
const formRef = ref<FormInst | null>()
const modalRef = ref()
const showFormModal = ref(false)
const formMode = ref<'add' | 'edit'>('add')
const pageParams = ref({
  pageNum: 1,
  pageSize: 10,
})
function handleDeltete(id: number) {
  deleteTaskOrder(id).then((res) => {
    if (res.code === 200) {
      window.$message.success('删除成功')
    }
  }).finally(() => {
    getList()
  })
}
const options =  [
  { label: '常规', value: 1 },
  { label: '事故', value: 2 },
]
const taskGeneralOptions = [
  { 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 },
]
const taskUrgencyLevelOptions = [
  { label: '一级', value: 0, type: 'error' },
  { label: '二级', value: 1, type: 'error' },
  { label: '三级', value: 2, type: 'error' },
  { label: '四级', value: 3, type: 'warning' },
]
const statusOptions = [
  { label: '待到达', value: 0, type: 'default' },
  { label: '待处理', value: 1, type: 'warning' },
  { label: '已完成', value: 2, type: 'primary' },
  { label: '已取消', value: 3, type: 'default' },
]
const taskErrorIdOptions = ref<any[]>([])
const filterItems = computed(() => [
  {
    label: '车辆',
    path: 'plateNumber',
    render: () => <n-input v-model:value={model.value.plateNumber} />,
  },
  {
    label: '任务类别',
    path: 'taskCategory',
    render: () => <n-select v-model:value={model.value.taskCategory} options={options} placeholder="请选择" clearable />
  },
  {
    label: '常规任务类型',
    path: 'taskGeneralId',
    render: () => <n-select v-model:value={model.value.taskGeneralId} options={taskGeneralOptions} placeholder="请选择" clearable />
  },
  {
    label: '事故任务类型',
    path: 'taskErrorId',
    render: () => <n-cascader v-model:value={model.value.taskErrorId} options={taskErrorIdOptions.value} value-field="id" label-field="errorName" children-field="child" check-strategy="all" clearable />
  },
  {
    label: '级别',
    path: 'taskUrgencyLevel',
    render: () => <n-select v-model:value={model.value.taskUrgencyLevel} options={taskUrgencyLevelOptions} placeholder="请选择" clearable />
  },
  {
    label: '任务状态',
    path: 'status',
    render: () => <n-select v-model:value={model.value.status} options={statusOptions} placeholder="请选择" clearable />
  },
  {
    label: '上报时间',
    path: 'time',
    render: () => <n-date-picker v-model:value={model.value.time} type="daterange" clearable />
  },
])
const total = ref(0)
const listData = ref<any[]>([])
async function getList() {
  startLoading()
  const params = {
    ...pageParams.value,
    status: model.value.status,
    plateNumber: model.value.plateNumber,
    taskCategory: model.value.taskCategory,
    taskGeneralId: model.value.taskGeneralId,
    taskErrorId: model.value.taskErrorId,
    taskUrgencyLevel: model.value.taskUrgencyLevel,
    startTime: model.value.time ? dayjs(model.value.time?.[0]).format('YYYY-MM-DD HH:mm:ss') : null,
    endTime: model.value.time ? dayjs(model.value.time?.[1]).format('YYYY-MM-DD HH:mm:ss') : null,
  }
  getTaskOrderCenterList(params).then((res) => {
    listData.value = res.data.records || []
    listData.value.forEach((item: any, index: number)  => {
      item.serialNumber = index + 1
    })
    total.value = res.data.total || res.data.records?.length
  }).finally(() => {
    endLoading()
  })
}
const getAccidentTypeList = () => {
  getTaskAccidentTypeList().then((res) => {
    if (res.code === 200) {
      taskErrorIdOptions.value = res.data || []
    }
  })
}
onMounted(() => {
  getList()
  getAccidentTypeList()
})
function changePage(page: number, size: number) {
  pageParams.value.pageNum = page
  pageParams.value.pageSize = size
  getList()
}
const handleExport = () => {
  const params = {
    ...pageParams.value,
    status: model.value.status,
    plateNumber: model.value.plateNumber,
    taskCategory: model.value.taskCategory,
    taskGeneralId: model.value.taskGeneralId,
    taskErrorId: model.value.taskErrorId,
    taskUrgencyLevel: model.value.taskUrgencyLevel,
    startTime: model.value.time ? dayjs(model.value.time?.[0]).format('YYYY-MM-DD HH:mm:ss') : null,
    endTime: model.value.time ? dayjs(model.value.time?.[1]).format('YYYY-MM-DD HH:mm:ss') : null,
  }
  exportTaskOrderCenter(params).then(res => {
    if (res instanceof Blob) {
      downloadFile(res, `工单中心列表_${new Date().getTime()}.xlsx`)
      window.$message.success('导出成功')
    }
  })
}
const handleAdd = () => {
  showFormModal.value = true
  formMode.value = 'add'
}
const handleConfirmForm = (data: any) => {
  const params = {
    ...data,
    taskTypeId: data.taskTypeId.join(','),
  }
    createTaskAccident(params).then((res) => {
      if (res.code === 200) {
        window.$message.success('新增成功')
      }
    }).finally(() => {
      getList()
    })
}
const columns = computed<DataTableColumns<any>>(() =>[
  {
    title: '序号',
    key: 'serialNumber',
    width: 80,
    resizable: true,
    fixed: 'left'
  },
  {
    title: '车辆',
    key: 'plateNumber',
    width: 116,
    resizable: true,
    fixed: 'left'
  },
  {
    title: '任务类别',
    key: 'taskCategory',
    width: 80,
    resizable: true,
    render: (row) => {
      return options.find((item) => item.value === row.taskCategory)?.label || '-'
    }
  },
  {
    title: '类型',
    key: 'taskTypeName',
    width: 200,
    resizable: true,
    ellipsis: {
      tooltip: true,
    },
  },
  {
    title: '紧急程度',
    key: 'taskUrgencyLevel',
    width: 100,
    resizable: true,
    render(row) {
      return h(
        NTag,
        {
          type: taskUrgencyLevelOptions.find(item => item.value === row.taskUrgencyLevel)?.type || 'default',
          bordered: false
        },
        {
          default: () => taskUrgencyLevelOptions.find(item => item.value === row.taskUrgencyLevel)?.label || '-'
        }
      )
    }
  },
  {
    title: '任务描述',
    key: 'taskDescription',
    width: 180,
    resizable: true,
    ellipsis: {
      tooltip: true,
    },
  },
  {
    title: '上报时间',
    key: 'createTime',
    width: 160,
    resizable: true,
  },
  {
    title: '处理状态',
    key: 'status',
    width: 100,
    resizable: true,
    render(row) {
      return h(
        NTag,
        {
          type: statusOptions.find(item => item.value === row.status)?.type || 'default',
          bordered: false
        },
        {
          default: () => statusOptions.find(item => item.value === row.status)?.label || '-'
        }
      )
    }
  },
  {
    title: '处理人',
    key: 'handleUserName',
    width: 110,
    resizable: true,
  },
  {
    title: '操作',
    key: 'actions',
    width: 140,
    fixed: 'right',
    render: (row) => {
      return (
        <NSpace>
          {(row.status === 0 || row.status === 1) && <NButton
            text
            type='primary'
            onClick={() => modalRef.value.openModal(row.id)}
          >
            分配
          </NButton>}
          <NButton
            text
            type='primary'
            onClick={() => modalRef.value.openModal('edit', row.id)}
          >
            详情
          </NButton>
          <NPopconfirm onPositiveClick={() => handleDeltete(row.id!)}>
            {{
              default: () => '确认删除',
              trigger: () => <NButton text type="error">删除</NButton>,
            }}
          </NPopconfirm>
        </NSpace>
      )
    },
  },
])
</script>
<template>
  <div class="roboTaxi">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form :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>
            <n-button style="width: 84px; height: 32px" @click="handleResetSearch">重置</n-button>
            <n-button type="primary" style="width: 84px; height: 32px" @click="getList">查询</n-button>
          </template>
        </SearchPanel>
      </n-form>
    </n-card>
    <n-card style="border-radius: 6px; margin-top: 12px" content-style="padding: 12px">
      <template #header>
        <n-flex justify="flex-end">
          <n-button @click="handleExport" style="width: 84px; height: 32px">导出</n-button>
          <NButton type="primary" secondary @click="handleAdd">
            新增任务
          </NButton>
        </n-flex>
      </template>
      <NSpace vertical :size="12">
        <n-data-table :columns="columns" :data="listData" :loading="loading" :bordered="false" :header-height="18"
          :row-height="18" :scroll-x="1300" />
        <Pagination :count="total" @change="changePage" />
      </NSpace>
    </n-card>
    <TableModal ref="modalRef" @refresh="getList" />
    <AddFormModal v-model:show="showFormModal" :mode="formMode" @confirm="handleConfirmForm" :taskErrorIdOptions="taskErrorIdOptions" :taskUrgencyLevelOptions="taskUrgencyLevelOptions"/>
  </div>
</template>
src/views/service/ticket/feedback/index.vue
New file
@@ -0,0 +1,286 @@
<script setup lang="ts">
import { ref, h, onMounted } from "vue";
import type { DataTableColumns } from "naive-ui";
import {
  NButton,
  NImage,
  NImageGroup,
  NModal,
  NSpace,
  NAvatar,
} from "naive-ui";
import { useBoolean } from "@/hooks";
import { fetchFeedbackPageList } from "@/service/api/serviceCenter";
const {
  bool: loading,
  setTrue: startLoading,
  setFalse: endLoading,
} = useBoolean(false);
const initialSearchForm = {
  feedbackContent: "",
};
const searchForm = ref({ ...initialSearchForm });
const tableData = ref<any[]>([]);
const pagination = ref({
  page: 1,
  pageSize: 10,
  itemCount: 0,
  showSizePicker: true,
  pageSizes: [10, 20, 50, 100],
});
const previewVisible = ref(false);
const previewImages = ref<string[]>([]);
function normalizeImages(value: any) {
  if (!value) return [];
  if (Array.isArray(value)) {
    return value.filter((item) => typeof item === "string" && item);
  }
  if (typeof value === "string") {
    const trimmed = value.trim();
    if (!trimmed) return [];
    if (trimmed.startsWith("[")) {
      try {
        const parsed = JSON.parse(trimmed);
        if (Array.isArray(parsed)) {
          return parsed.filter((item) => typeof item === "string" && item);
        }
      } catch {
        return [trimmed];
      }
    }
    return trimmed.includes(",")
      ? trimmed
          .split(",")
          .map((item) => item.trim())
          .filter(Boolean)
      : [trimmed];
  }
  return [];
}
function getImages(row: any) {
  return normalizeImages(row.feedbackPictures);
}
const columns: DataTableColumns<FeedbackItem> = [
  {
    title: "序号",
    key: "index",
    width: 80,
    align: "center",
    render: (_row, index) =>
      h(
        "span",
        (pagination.value.page - 1) * pagination.value.pageSize + index + 1
      ),
  },
  {
    title: "反馈人",
    key: "nickName",
    width: 120,
    align: "center",
    render: (row) => {
      const displayName = row.nickName || "-";
      return h("div", { class: "flex items-center justify-center gap-2" }, [
        h(
          NAvatar,
          {
            round: true,
            size: 32,
            style: { backgroundColor: "#e0e0e0" },
            src: row.avatar || undefined,
          },
          { default: () => displayName.charAt(0) }
        ),
        h("span", displayName),
      ]);
    },
  },
  {
    title: "反馈类型",
    key: "feedbackTypeName",
    width: 120,
    align: "center",
  },
  {
    title: "反馈内容",
    key: "feedbackContent",
    minWidth: 200,
    align: "center",
  },
  {
    title: "图片",
    key: "feedbackPictures",
    width: 120,
    align: "center",
    render: (row) => {
      const images = getImages(row);
      if (!images.length) return h("span", "-");
      return h(
        NButton,
        {
          text: true,
          type: "primary",
          onClick: () => handleViewImages(images),
        },
        { default: () => "查看" }
      );
    },
  },
  {
    title: "联系方式",
    key: "phoneNumber",
    width: 140,
    align: "center",
  },
  {
    title: "反馈时间",
    key: "createTime",
    width: 160,
    align: "center",
  },
];
const handleSearch = () => {
  pagination.value.page = 1;
  getFeedbackList();
};
const handleReset = () => {
  searchForm.value = { ...initialSearchForm };
  pagination.value.page = 1;
  getFeedbackList();
};
function handleViewImages(images: string[]) {
  previewImages.value = images;
  previewVisible.value = true;
}
function handlePageChange(page: number) {
  pagination.value.page = page;
  getFeedbackList();
}
function handlePageSizeChange(pageSize: number) {
  pagination.value.pageSize = pageSize;
  pagination.value.page = 1;
  getFeedbackList();
}
async function getFeedbackList() {
  startLoading();
  try {
    const res = await fetchFeedbackPageList({
      content: searchForm.value.feedbackContent || undefined,
      pageNum: pagination.value.page,
      pageSize: pagination.value.pageSize,
    });
    if (res.code === 200 && res.data) {
      tableData.value = res.data.records || [];
      pagination.value.itemCount = res.data.total || 0;
    } else {
      tableData.value = [];
      pagination.value.itemCount = 0;
    }
  } finally {
    endLoading();
  }
}
onMounted(() => {
  getFeedbackList();
});
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form
        :model="searchForm"
        label-placement="left"
        inline
        :show-feedback="false"
      >
        <div class="flex justify-between items-center w-full">
          <div class="flex gap-4">
            <n-form-item label="反馈内容">
              <n-input
                v-model:value="searchForm.feedbackContent"
                placeholder="请输入"
                clearable
                style="width: 200px"
              />
            </n-form-item>
          </div>
          <div class="flex gap-2">
            <n-button style="width: 84px; height: 32px" @click="handleReset"
              >重置</n-button
            >
            <n-button
              type="primary"
              style="width: 84px; height: 32px"
              @click="handleSearch"
              >查询</n-button
            >
          </div>
        </div>
      </n-form>
    </n-card>
    <n-card
      style="border-radius: 6px; margin-top: 12px"
      content-style="padding: 12px"
    >
      <n-data-table
        :columns="columns"
        :data="tableData"
        :loading="loading"
        :pagination="{
          page: pagination.page,
          pageSize: pagination.pageSize,
          itemCount: pagination.itemCount,
          showSizePicker: true,
          pageSizes: pagination.pageSizes,
          prefix: ({ itemCount }) => `共 ${itemCount || 0} 条`,
        }"
        :scroll-x="1200"
        :row-key="(row: FeedbackItem) => row.id"
        @update:page="handlePageChange"
        @update:page-size="handlePageSizeChange"
      />
    </n-card>
    <n-modal
      v-model:show="previewVisible"
      preset="card"
      title="图片预览"
      :style="{ width: '720px' }"
      :bordered="false"
      :segmented="{ content: true, action: true }"
    >
      <n-image-group v-if="previewImages.length">
        <n-space wrap>
          <n-image
            v-for="(img, index) in previewImages"
            :key="index"
            :src="img"
            :width="120"
            :height="90"
            object-fit="cover"
          />
        </n-space>
      </n-image-group>
      <span v-else>-</span>
    </n-modal>
  </div>
</template>
<style scoped>
</style>
src/views/service/ticket/feedbackType/index.vue
New file
@@ -0,0 +1,234 @@
<script setup lang="ts">
import { ref, h, computed, onMounted } from "vue";
import type { DataTableColumns, FormInst, FormRules } from "naive-ui";
import { NButton } from "naive-ui";
import { useBoolean } from "@/hooks";
import {
  fetchFeedbackTypeList,
  saveFeedbackType,
  updateFeedbackType,
  deleteFeedbackType,
} from "@/service/api/serviceCenter";
const { bool: loading, setTrue: startLoading, setFalse: endLoading } =
  useBoolean(false);
const tableData = ref<any[]>([]);
const modalVisible = ref(false);
const modalMode = ref<"add" | "edit">("add");
const formRef = ref<FormInst | null>(null);
const formModel = ref({
  id: null as number | null,
  feedbackType: "",
});
const rules: FormRules = {
  feedbackType: [{ required: true, message: "请输入反馈类型", trigger: "blur" }],
};
const modalTitle = computed(() =>
  modalMode.value === "add" ? "新增反馈类型" : "编辑反馈类型"
);
const columns: DataTableColumns<any> = [
  {
    title: "序号",
    key: "index",
    width: 80,
    align: "center",
    render: (_row, index) => h("span", index + 1),
  },
  {
    title: "反馈类型",
    key: "feedbackType",
    minWidth: 200,
    align: "center",
  },
  {
    title: "创建时间",
    key: "createTime",
    width: 180,
    align: "center",
    render: (row) => row.createTime || "-",
  },
  {
    title: "操作",
    key: "actions",
    width: 160,
    align: "center",
    render: (row) =>
      h("div", { class: "flex items-center justify-center gap-2" }, [
        h(
          NButton,
          {
            text: true,
            type: "primary",
            onClick: () => handleEdit(row),
          },
          { default: () => "编辑" }
        ),
        h(
          NButton,
          {
            text: true,
            type: "error",
            onClick: () => handleDelete(row),
          },
          { default: () => "删除" }
        ),
      ]),
  },
];
function handleAdd() {
  modalMode.value = "add";
  formModel.value = { id: null, feedbackType: "" };
  modalVisible.value = true;
  formRef.value?.restoreValidation();
}
function handleEdit(row: FeedbackTypeItem) {
  modalMode.value = "edit";
  formModel.value = { id: row.id, feedbackType: row.feedbackType || "" };
  modalVisible.value = true;
  formRef.value?.restoreValidation();
}
function handleClose() {
  modalVisible.value = false;
}
async function handleSave() {
  await formRef.value?.validate();
  const payload = { feedbackType: formModel.value.feedbackType };
  if (modalMode.value === "add") {
    const { error } = await saveFeedbackType(payload);
    if (!error) {
      window.$message?.success("新增成功");
      modalVisible.value = false;
      getFeedbackTypeList();
    } else {
      window.$message?.error("新增失败");
    }
    return;
  }
  const { error } = await updateFeedbackType({
    id: formModel.value.id as number,
    ...payload,
  });
  if (!error) {
    window.$message?.success("编辑成功");
    modalVisible.value = false;
    getFeedbackTypeList();
  } else {
    window.$message?.error("编辑失败");
  }
}
function handleDelete(row: FeedbackTypeItem) {
  window.$dialog?.warning({
    title: "确认删除",
    content: "确定要删除该反馈类型吗?",
    positiveText: "确定",
    negativeText: "取消",
    onPositiveClick: async () => {
      const { error } = await deleteFeedbackType(row.id);
      if (!error) {
        window.$message?.success("删除成功");
        getFeedbackTypeList();
      } else {
        window.$message?.error("删除失败");
      }
    },
  });
}
async function getFeedbackTypeList() {
  startLoading();
  try {
    const res = await fetchFeedbackTypeList();
    if (res.code === 200 && res.data) {
      const list = Array.isArray(res.data)
        ? res.data
        : res.data.records || [];
      tableData.value = list;
    } else {
      tableData.value = [];
    }
  } finally {
    endLoading();
  }
}
onMounted(() => {
  getFeedbackTypeList();
});
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card
      style="border-radius: 6px; margin-top: 12px"
      content-style="padding: 12px"
    >
      <div class="mb-4 flex justify-end gap-2">
        <n-button
          style="
            width: 84px;
            height: 32px;
            color: #0068ff;
            background-color: #e6f4ff;
            border: 1px solid #7abdff;
          "
          @click="handleAdd"
          >新增</n-button
        >
      </div>
      <n-data-table
        :columns="columns"
        :data="tableData"
        :loading="loading"
        :pagination="false"
        :scroll-x="800"
        :row-key="(row: FeedbackTypeItem) => row.id"
      />
    </n-card>
    <n-modal
      v-model:show="modalVisible"
      preset="card"
      :title="modalTitle"
      :mask-closable="false"
      :style="{ width: '420px' }"
      :segmented="{ content: true, action: true }"
    >
      <n-form
        ref="formRef"
        :model="formModel"
        :rules="rules"
        label-placement="left"
        label-width="80"
        require-mark-placement="left"
      >
        <n-form-item label="反馈类型" path="feedbackType">
          <n-input
            v-model:value="formModel.feedbackType"
            placeholder="请输入"
            clearable
          />
        </n-form-item>
      </n-form>
      <template #action>
        <n-space justify="end">
          <n-button @click="handleClose">取消</n-button>
          <n-button type="primary" @click="handleSave">保存</n-button>
        </n-space>
      </template>
    </n-modal>
  </div>
</template>
<style scoped></style>
src/views/service/ticket/workOrder/components/WorkOrderHandleModal.vue
New file
@@ -0,0 +1,254 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { FormInst, FormRules, UploadFileInfo } from "naive-ui";
import { customUploadRequest } from "@/utils/upload";
interface HandleFileItem {
  id: string;
  name: string;
  url: string;
}
interface Props {
  visible: boolean;
  rowData?: any;
  typeOptions: { label: string; value: number }[];
  problemSourceOptions: { label: string; value: number }[];
  priorityOptions: { label: string; value: number }[];
  handleUserOptions: { label: string; value: number | string }[];
}
const props = withDefaults(defineProps<Props>(), {
  visible: false,
  rowData: null,
  typeOptions: () => [],
  problemSourceOptions: () => [],
  priorityOptions: () => [],
  handleUserOptions: () => [],
});
const emit = defineEmits(["update:visible", "submit"]);
const showModal = computed({
  get: () => props.visible,
  set: (val) => emit("update:visible", val),
});
const formRef = ref<FormInst | null>(null);
const formModel = ref({
  handleLog: "",
  handleUserId: null as number | string | null,
  handleFile: [] as HandleFileItem[],
});
const fileList = ref<UploadFileInfo[]>([]);
const rules: FormRules = {
  handleLog: [{ required: true, message: "请输入处理情况", trigger: "blur" }],
};
const disableSave = computed(() => !formModel.value.handleUserId);
const disableClose = computed(() => !!formModel.value.handleUserId);
const displayType = computed(() => {
  const id = props.rowData?.feedbackTypeId;
  const matched = props.typeOptions.find((item) => item.value === id);
  return matched?.label || props.rowData?.feedbackTypeName || "-";
});
const displayProblemSource = computed(() => {
  const id = props.rowData?.problemSource;
  const matched = props.problemSourceOptions.find((item) => item.value === id);
  return matched?.label || "-";
});
const displayPriority = computed(() => {
  const id = props.rowData?.priority;
  const matched = props.priorityOptions.find((item) => item.value === id);
  return matched?.label || "-";
});
const displayImages = computed<HandleFileItem[]>(() => {
  const images = props.rowData?.problemImage;
  return images ? JSON.parse(images) : [];
});
function resetForm() {
  formModel.value = {
    handleLog: "",
    handleUserId: null,
    handleFile: [],
  };
  fileList.value = [];
  formRef.value?.restoreValidation();
}
async function handleUploadFile({ file, onFinish, onError, onProgress }: any) {
  const res = await customUploadRequest({
    file,
    onFinish,
    onError,
    onProgress,
  });
  if (res) {
    file.id = res.id;
    file.url = res.url;
    file.status = "finished";
    const nextItem = {
      id: String(res.id || file.id || Date.now()),
      name: file.name || "附件",
      url: res.url,
    };
    formModel.value.handleFile = [
      ...formModel.value.handleFile.filter((item) => item.id !== nextItem.id),
      nextItem,
    ];
    onFinish();
  }
}
function handleRemoveFile(
  _file: UploadFileInfo,
  fileListValue: UploadFileInfo[]
) {
  const ids = new Set(
    fileListValue
      .map((item) => String(item.id))
      .filter((id) => id && id !== "undefined")
  );
  formModel.value.handleFile = formModel.value.handleFile.filter((item) =>
    ids.has(String(item.id))
  );
}
async function handleSave() {
  await formRef.value?.validate();
  emit("submit", {
    handleType: 1,
    handleLog: formModel.value.handleLog,
    handleUserId: formModel.value.handleUserId,
    handleFile: JSON.stringify(formModel.value.handleFile),
  });
}
async function handleClose() {
  await formRef.value?.validate();
  emit("submit", {
    handleType: 2,
    handleLog: formModel.value.handleLog,
    handleUserId: null,
    handleFile: JSON.stringify(formModel.value.handleFile),
  });
}
watch(
  () => props.visible,
  (visible) => {
    if (visible) {
      resetForm();
    }
  }
);
</script>
<template>
  <n-modal
    v-model:show="showModal"
    preset="card"
    title="处理工单"
    :mask-closable="false"
    :style="{ width: '860px' }"
    :segmented="{ content: true, action: true }"
  >
    <div class="space-y-4">
      <div class="rounded border border-[#E6E6E6] p-3">
        <div class="grid grid-cols-2 gap-4 text-sm text-[#1D2129]">
          <div>问题:{{ props.rowData?.problem || "-" }}</div>
          <div>反馈用户:{{ props.rowData?.feedbackUserPhone || "-" }}</div>
          <div>来源:{{ displayProblemSource }}</div>
          <div>类型:{{ displayType }}</div>
          <div>优先级:{{ displayPriority }}</div>
        </div>
        <div class="mt-3 flex text-sm text-[#1D2129]">
          <div>问题描述:</div>
          <div>
            <div class="whitespace-pre-wrap text-[#4E5969]">
              {{ props.rowData?.problemDescription || "-" }}
            </div>
            <div v-if="displayImages.length" class="mt-3">
              <n-image-group>
                <div class="flex flex-wrap gap-2">
                  <n-image
                    v-for="item in displayImages"
                    :key="item.id || item.url"
                    :src="item.url"
                    width="80"
                    height="80"
                  />
                </div>
              </n-image-group>
            </div>
          </div>
        </div>
      </div>
      <n-form
        ref="formRef"
        :model="formModel"
        :rules="rules"
        label-placement="left"
        label-width="90"
        require-mark-placement="left"
      >
        <div class="text-sm font-medium text-[#1D2129] mb-2">处理日志</div>
        <n-form-item label="处理情况" path="handleLog">
          <n-input
            v-model:value="formModel.handleLog"
            type="textarea"
            placeholder="请输入"
            :rows="4"
            clearable
          />
        </n-form-item>
        <n-form-item label="相关资料" path="handleFile" :show-feedback="false">
          <div class="w-full">
            <n-upload
              v-model:file-list="fileList"
              list-type="text"
              :max="10"
              accept="image/*,audio/*,video/*"
              :custom-request="handleUploadFile"
              @remove="handleRemoveFile"
            >
              <n-button>上传文件</n-button>
            </n-upload>
            <div class="mt-2 text-xs text-[#86909C]">
              上传通话录音、处理结果等资料。上传文件类型可选图片、音频、视频
            </div>
          </div>
        </n-form-item>
        <n-form-item label="移交" path="handleUserId">
          <n-select
            v-model:value="formModel.handleUserId"
            :options="handleUserOptions"
            placeholder="请选择"
            clearable
          />
        </n-form-item>
      </n-form>
    </div>
    <template #action>
      <n-space justify="end">
        <n-button @click="showModal = false">取消</n-button>
        <n-button v-if="!disableClose" type="primary" @click="handleClose">
          结办
        </n-button>
        <n-button v-if="!disableSave" type="primary" @click="handleSave">
          保存
        </n-button>
      </n-space>
    </template>
  </n-modal>
</template>
<style scoped></style>
src/views/service/ticket/workOrder/components/WorkOrderModal.vue
New file
@@ -0,0 +1,323 @@
<script setup lang="ts">
import { computed, ref, watch } from "vue";
import type { FormInst, FormRules, UploadFileInfo } from "naive-ui";
import { customUploadRequest } from "@/utils/upload";
interface WorkOrderFormModel {
  id?: number | null;
  problem: string;
  problemDescription: string;
  problemImage: { id: string; name: string; url: string }[];
  feedbackUserPhone: number | string;
  problemSource: number | null;
  feedbackTypeId: number | null;
  priority: number | null;
  handleUserId: number | string | null;
}
interface Props {
  visible: boolean;
  mode: "add" | "edit";
  rowData?: any;
  typeOptions: { label: string; value: number }[];
  problemSourceOptions: { label: string; value: number }[];
  priorityOptions: { label: string; value: number }[];
  handleUserOptions: { label: string; value: number | string }[];
}
const props = withDefaults(defineProps<Props>(), {
  visible: false,
  mode: "add",
  rowData: null,
  typeOptions: () => [],
  problemSourceOptions: () => [],
  priorityOptions: () => [],
  handleUserOptions: () => [],
});
const emit = defineEmits(["update:visible", "submit"]);
const showModal = computed({
  get: () => props.visible,
  set: (val) => emit("update:visible", val),
});
const formRef = ref<FormInst | null>(null);
const formModel = ref<WorkOrderFormModel>({
  id: null,
  problem: "",
  problemDescription: "",
  problemImage: [],
  feedbackUserPhone: "",
  problemSource: null,
  feedbackTypeId: null,
  priority: null,
  handleUserId: null,
});
const fileList = ref<UploadFileInfo[]>([]);
const rules: FormRules = {
  problem: [{ required: true, message: "请输入问题", trigger: "blur" }],
  problemDescription: [
    { required: true, message: "请输入问题描述", trigger: "blur" },
  ],
  feedbackUserPhone: [
    { required: true, message: "请输入反馈用户", trigger: "blur" },
  ],
  problemSource: [
    {
      validator: (_rule, value) =>
        value === null || value === undefined || value === ""
          ? new Error("请选择来源")
          : true,
      trigger: ["change", "blur"],
    },
  ],
  feedbackTypeId: [
    {
      validator: (_rule, value) =>
        value === null || value === undefined || value === ""
          ? new Error("请选择类型")
          : true,
      trigger: ["change", "blur"],
    },
  ],
  priority: [
    {
      validator: (_rule, value) =>
        value === null || value === undefined || value === ""
          ? new Error("请选择优先级")
          : true,
      trigger: ["change", "blur"],
    },
  ],
  handleUserId: [
    {
      validator: (_rule, value) =>
        value === null || value === undefined || value === ""
          ? new Error("请选择处理人")
          : true,
      trigger: ["change", "blur"],
    },
  ],
};
const modalTitle = computed(() =>
  props.mode === "add" ? "新增工单" : "编辑工单"
);
function resetForm() {
  formModel.value = {
    id: null,
    problem: "",
    problemDescription: "",
    problemImage: [],
    feedbackUserPhone: "",
    problemSource: null,
    feedbackTypeId: null,
    priority: null,
    handleUserId: null,
  };
  fileList.value = [];
  formRef.value?.restoreValidation();
}
function fillForm() {
  const row = props.rowData || {};
  formModel.value = {
    problem: row.problem || "",
    problemDescription: row.problemDescription || "",
    problemImage: row.problemImage ? JSON.parse(row.problemImage) : [],
    feedbackUserPhone: row.feedbackUserPhone || "",
    problemSource: row.problemSource ?? null,
    feedbackTypeId: row.feedbackTypeId ?? null,
    priority: row.priority ?? null,
    handleUserId: row.handleUserId || null,
  };
  if (row.id) {
    formModel.value.id = row.id;
  }
  fileList.value = formModel.value.problemImage.map((item) => ({
    id: item.id || `image_${row.id || Date.now()}`,
    name: item.name || "图片",
    status: "finished",
    url: item.url,
  }));
  formRef.value?.restoreValidation();
}
async function handleUploadImage({ file, onFinish, onError, onProgress }: any) {
  const res = await customUploadRequest({
    file,
    onFinish,
    onError,
    onProgress,
  });
  if (res) {
    file.id = res.id;
    file.url = res.url;
    file.status = "finished";
    const nextItem = {
      id: String(res.id || file.id || Date.now()),
      name: file.name || "附件",
      url: res.url,
    };
    formModel.value.problemImage = [
      ...formModel.value.problemImage.filter((item) => item.id !== nextItem.id),
      nextItem,
    ];
    onFinish();
  }
}
function handleRemoveImage(
  _file: UploadFileInfo,
  fileListValue: UploadFileInfo[]
) {
  const ids = new Set(
    fileListValue
      .map((item) => String(item.id))
      .filter((id) => id && id !== "undefined")
  );
  formModel.value.problemImage = formModel.value.problemImage.filter((item) =>
    ids.has(String(item.id))
  );
}
async function handleSave() {
  await formRef.value?.validate();
  emit("submit", {
    ...formModel.value,
    problemImage: JSON.stringify(formModel.value.problemImage),
  });
}
watch(
  () => props.visible,
  (visible) => {
    if (!visible) return;
    if (props.mode === "edit") {
      fillForm();
    } else {
      resetForm();
    }
  }
);
</script>
<template>
  <n-modal
    v-model:show="showModal"
    preset="card"
    :title="modalTitle"
    :mask-closable="false"
    :style="{ width: '720px' }"
    :segmented="{ content: true, action: true }"
  >
    <n-form
      ref="formRef"
      :model="formModel"
      :rules="rules"
      label-placement="left"
      label-width="90"
      require-mark-placement="left"
    >
      <n-form-item label="问题" path="problem">
        <n-input
          v-model:value="formModel.problem"
          placeholder="请输入"
          clearable
        />
      </n-form-item>
      <n-form-item label="问题描述" path="problemDescription">
        <n-input
          v-model:value="formModel.problemDescription"
          type="textarea"
          placeholder="请输入"
          :rows="4"
          clearable
        />
      </n-form-item>
      <n-form-item label="图片" path="problemImage">
        <div class="flex flex-col">
          <n-upload
            v-model:file-list="fileList"
            list-type="image-card"
            :max="10"
            :custom-request="handleUploadImage"
            @remove="handleRemoveImage"
            accept="image/*"
          >
            <div
              style="
                width: 120px;
                height: 120px;
                display: flex;
                flex-direction: column;
                align-items: center;
                justify-content: center;
                color: #999;
              "
            >
              <icon-park-outline-plus style="font-size: 20px" />
              <div style="margin-top: 8px; font-size: 12px">点击上传图片</div>
            </div>
          </n-upload>
          <div style="margin-top: 8px; color: #999; font-size: 12px">
            支持jpg、png 格式 建议尺寸:X px Xpx<br />
          </div>
        </div>
      </n-form-item>
      <n-form-item label="反馈用户" path="feedbackUserPhone">
        <n-input
          v-model:value="formModel.feedbackUserPhone"
          placeholder="请输入"
          clearable
        />
      </n-form-item>
      <n-form-item label="来源" path="problemSource" required>
        <n-select
          v-model:value="formModel.problemSource"
          :options="problemSourceOptions"
          placeholder="请选择"
          clearable
        />
      </n-form-item>
      <div class="grid grid-cols-2 gap-4">
        <n-form-item label="类型" path="feedbackTypeId" required>
          <n-select
            v-model:value="formModel.feedbackTypeId"
            :options="typeOptions"
            placeholder="请选择"
            clearable
          />
        </n-form-item>
        <n-form-item label="优先级" path="priority" required>
          <n-select
            v-model:value="formModel.priority"
            :options="priorityOptions"
            placeholder="请选择"
            clearable
          />
        </n-form-item>
      </div>
      <n-form-item label="处理人" path="handleUserId" required>
        <n-select
          v-model:value="formModel.handleUserId"
          :options="handleUserOptions"
          placeholder="请选择"
          clearable
        />
      </n-form-item>
    </n-form>
    <template #action>
      <n-space justify="end">
        <n-button @click="showModal = false">取消</n-button>
        <n-button type="primary" @click="handleSave">保存</n-button>
      </n-space>
    </template>
  </n-modal>
</template>
<style scoped></style>
src/views/service/ticket/workOrder/detail.vue
New file
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { computed, ref, onMounted } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useBoolean } from "@/hooks";
import { fetchWorkOrderDetail } from "@/service/api/serviceCenter";
const route = useRoute();
const router = useRouter();
const {
  bool: loading,
  setTrue: startLoading,
  setFalse: endLoading,
} = useBoolean(false);
const detailData = ref<any>({});
const workOrderId = computed(() => route.query.id);
const problemSourceLabelMap: Record<number, string> = {
  1: "电话客服",
};
const priorityLabelMap: Record<number, string> = {
  1: "低",
  2: "中",
  3: "高",
};
const displayProblemSource = computed(
  () => problemSourceLabelMap[detailData.value.problemSource] || "-"
);
const displayPriority = computed(
  () => priorityLabelMap[detailData.value.priority] || "-"
);
const displayType = computed(() => detailData.value.feedbackTypeName || "-");
const displayImages = computed(() =>
  parseFileList(detailData.value.problemImage)
);
const timelineItems = computed(() => {
  const items: any[] = [];
  if (detailData.value.createTime) {
    items.push({
      title: detailData.value.createTime,
      handleUserName: detailData.value.createBy,
      typeTxt: "创建工单",
    });
  }
  const logs = Array.isArray(detailData.value.workOrderLogs)
    ? detailData.value.workOrderLogs
    : [];
  logs.forEach((log: any) => {
    items.push({
      title: log.handleTime || log.createTime || "-",
      handleUserName: log.handleUserName || "-",
      typeTxt: "处理",
      log,
    });
  });
  return items;
});
function parseFileList(value: any) {
  if (!value) return [];
  if (Array.isArray(value)) return value;
  if (typeof value === "string") {
    try {
      const parsed = JSON.parse(value);
      return Array.isArray(parsed) ? parsed : [];
    } catch {
      return [];
    }
  }
  return [];
}
function handleTypeLabel(value: number) {
  if (value === 1) return "移交";
  if (value === 2) return "办结";
  return "-";
}
function getFileExt(fileName: string) {
  const normalized = fileName || "";
  const dotIndex = normalized.lastIndexOf(".");
  if (dotIndex === -1) return "";
  return normalized.slice(dotIndex + 1).toLowerCase();
}
function isImageFile(fileName: string) {
  return ["png", "jpg", "jpeg", "gif", "bmp", "webp"].includes(
    getFileExt(fileName)
  );
}
function isVideoFile(fileName: string) {
  return ["mp4", "mov", "avi", "wmv", "mkv", "webm"].includes(
    getFileExt(fileName)
  );
}
function isAudioFile(fileName: string) {
  return ["mp3", "wav", "aac", "flac", "ogg", "m4a"].includes(
    getFileExt(fileName)
  );
}
function isOfficeFile(fileName: string) {
  return ["doc", "docx", "xls", "xlsx", "ppt", "pptx", "pdf"].includes(
    getFileExt(fileName)
  );
}
function openNewTab(url: string) {
  if (!url) {
    window.$message?.warning("文件地址不存在");
    return;
  }
  window.open(url, "_blank", "noopener");
}
function handlePreview(file: any) {
  const url = file?.url;
  const name = file?.name || "";
  if (isOfficeFile(name)) {
    const previewUrl = `https://view.officeapps.live.com/op/view.aspx?src=${encodeURIComponent(
      url || ""
    )}`;
    openNewTab(previewUrl);
    return;
  }
  if (isImageFile(name) || isVideoFile(name) || isAudioFile(name)) {
    openNewTab(url);
    return;
  }
  openNewTab(url);
}
async function handleDownload(file: any) {
  const url = file?.url;
  if (!url) {
    window.$message?.warning("文件地址不存在");
    return;
  }
  try {
    const response = await fetch(url);
    if (!response.ok) {
      throw new Error("download_failed");
    }
    const blob = await response.blob();
    const objectUrl = URL.createObjectURL(blob);
    const link = document.createElement("a");
    link.href = objectUrl;
    link.download = file?.name || "file";
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
    URL.revokeObjectURL(objectUrl);
  } catch {
    const link = document.createElement("a");
    link.href = url;
    link.download = file?.name || "file";
    document.body.appendChild(link);
    link.click();
    document.body.removeChild(link);
  }
}
function handleBack() {
  router.back();
}
async function getDetail() {
  if (!workOrderId.value) return;
  startLoading();
  try {
    const res = await fetchWorkOrderDetail(workOrderId.value);
    if (res.code === 200 && res.data) {
      detailData.value = res.data;
    } else {
      detailData.value = {};
    }
  } finally {
    endLoading();
  }
}
onMounted(() => {
  getDetail();
});
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <div class="flex items-center gap-2 mb-3">
        <div
          class="bg-[#0068FF]"
          style="width: 3px; height: 14px; border-radius: 3px"
        ></div>
        <div
          class="text-[#171F26]"
          style="line-height: 24px; font-size: 16px; font-weight: bold"
        >
          工单详情
        </div>
      </div>
      <n-spin :show="loading">
        <div class="grid grid-cols-2 gap-4 text-sm text-[#1D2129]">
          <div>问题:{{ detailData.problem || "-" }}</div>
          <div>反馈用户:{{ detailData.feedbackUserPhone || "-" }}</div>
          <div>来源:{{ displayProblemSource }}</div>
          <div>类型:{{ displayType }}</div>
          <div>优先级:{{ displayPriority }}</div>
          <div>创建人:{{ detailData.createBy }}</div>
          <div>创建时间:{{ detailData.createTime || "-" }}</div>
        </div>
        <div class="mt-3 flex text-sm text-[#1D2129]">
          <div class="shrink-0">问题描述:</div>
          <div class="whitespace-pre-wrap text-[#4E5969]">
            {{ detailData.problemDescription || "-" }}
          </div>
        </div>
        <div v-if="displayImages.length" class="mt-3">
          <n-image-group>
            <div class="flex flex-wrap gap-2">
              <n-image
                v-for="item in displayImages"
                :key="item.id || item.url"
                :src="item.url"
                width="80"
                height="80"
              />
            </div>
          </n-image-group>
        </div>
      </n-spin>
    </n-card>
    <n-card
      style="border-radius: 6px; margin-top: 12px"
      content-style="padding: 12px"
    >
      <div class="flex items-center gap-2 mb-3">
        <div
          class="bg-[#0068FF]"
          style="width: 3px; height: 14px; border-radius: 3px"
        ></div>
        <div
          class="text-[#171F26]"
          style="line-height: 24px; font-size: 16px; font-weight: bold"
        >
          处理流程
        </div>
      </div>
      <n-spin :show="loading">
        <n-timeline>
          <n-timeline-item
            v-for="(item, index) in timelineItems"
            :key="index"
            :title="item.title"
            type="success"
          >
            <div>
              <div class="flex items-center gap-10">
                <!-- 增加一个头像 -->
                <div class="flex items-center gap-3">
                  <n-avatar :src="item.handleUserAvatar" size="small" />
                  <div>{{ item.handleUserName || "-" }}</div>
                </div>
                <div>{{ item.typeTxt || "-" }}</div>
              </div>
              <div
                v-if="index != 0"
                class="mt-2 rounded border border-[#E5E6EB] bg-[#F7F8FA] p-3 text-sm text-[#1D2129]"
              >
                <div class="flex">
                  <div class="shrink-0">处理内容:</div>
                  <div>
                    <div>{{ item.log.handleLog || "-" }}</div>
                    <div class="mt-2">
                      <div
                        v-if="parseFileList(item.log.handleFile).length"
                        class="mt-2 space-y-2"
                      >
                        <div
                          v-for="file in parseFileList(item.log.handleFile)"
                          :key="file.id || file.url"
                          class="flex items-center gap-3"
                        >
                          <div class="max-w-[320px] truncate">
                            {{ file.name || "-" }}
                          </div>
                          <n-button
                            type="primary"
                            :text="true"
                            @click="handlePreview(file)"
                          >
                            预览
                          </n-button>
                          <n-button
                            type="primary"
                            :text="true"
                            @click="handleDownload(file)"
                          >
                            下载
                          </n-button>
                        </div>
                      </div>
                      <div v-else class="text-[#4E5969]">-</div>
                    </div>
                  </div>
                </div>
                <div class="mt-2">
                  操作类型:
                  <n-tag type="primary">{{
                    handleTypeLabel(item.log.handleType)
                  }}</n-tag>
                </div>
              </div>
            </div>
          </n-timeline-item>
        </n-timeline>
      </n-spin>
      <div class="mt-5 flex justify-center">
        <n-button @click="handleBack">返回</n-button>
      </div>
    </n-card>
  </div>
</template>
<style scoped></style>
src/views/service/ticket/workOrder/index.vue
New file
@@ -0,0 +1,564 @@
<script setup lang="ts">
import { ref, h, onMounted } from "vue";
import type { DataTableColumns } from "naive-ui";
import { NButton } from "naive-ui";
import { useRouter } from "vue-router";
import { useBoolean } from "@/hooks";
import {
  fetchWorkOrderPageList,
  fetchFeedbackTypeList,
  fetchOperationsUserListAll,
  saveWorkOrder,
  updateWorkOrder,
  deleteWorkOrder,
  handleWorkOrder,
} from "@/service/api/serviceCenter";
import WorkOrderModal from "./components/WorkOrderModal.vue";
import WorkOrderHandleModal from "./components/WorkOrderHandleModal.vue";
interface WorkOrderItem {
  id: number;
  feedbackUserPhone?: string;
  problemSource?: number;
  feedbackTypeId?: number;
  feedbackTypeName?: string;
  problem?: string;
  problemDescription?: string;
  problemImage?: string;
  priority?: number;
  handleStatus?: number;
  handleUserId?: number | string;
  handleUserName?: string;
  createTime?: string;
}
const {
  bool: loading,
  setTrue: startLoading,
  setFalse: endLoading,
} = useBoolean(false);
const router = useRouter();
const initialSearchForm = {
  feedbackUserPhone: "",
  problemSource: null as number | null,
  feedbackTypeId: null as number | null,
  priority: null as number | null,
  handleStatus: null as number | null,
};
const searchForm = ref({ ...initialSearchForm });
const tableData = ref<WorkOrderItem[]>([]);
const currentRow = ref<WorkOrderItem | null>(null);
const modalVisible = ref(false);
const modalMode = ref<"add" | "edit">("add");
const handleModalVisible = ref(false);
const handleRow = ref<WorkOrderItem | null>(null);
const pagination = ref({
  page: 1,
  pageSize: 10,
  itemCount: 0,
  showSizePicker: true,
  pageSizes: [10, 20, 50, 100],
});
const typeOptions = ref<{ label: string; value: number }[]>([]);
const handleUserOptions = ref<{ label: string; value: number | string }[]>([]);
const problemSourceOptions = [{ label: "电话客服", value: 1 }];
const priorityOptions = [
  { label: "低", value: 1 },
  { label: "中", value: 2 },
  { label: "高", value: 3 },
];
const statusOptions = [
  { label: "待处理", value: 1 },
  { label: "处理中", value: 2 },
  { label: "已处理", value: 3 },
];
const priorityLabelMap: Record<number, string> = {
  1: "低",
  2: "中",
  3: "高",
};
const statusLabelMap: Record<number, string> = {
  1: "待处理",
  2: "处理中",
  3: "已处理",
};
const problemSourceLabelMap: Record<number, string> = {
  1: "电话客服",
};
const columns: DataTableColumns<WorkOrderItem> = [
  {
    title: "序号",
    key: "index",
    width: 80,
    align: "center",
    render: (_row, index) =>
      h(
        "span",
        (pagination.value.page - 1) * pagination.value.pageSize + index + 1
      ),
  },
  {
    title: "提交用户",
    key: "feedbackUserPhone",
    width: 140,
    align: "center",
    render: (row) => row.feedbackUserPhone || "-",
  },
  {
    title: "来源",
    key: "problemSource",
    width: 120,
    align: "center",
    render: (row) => problemSourceLabelMap[row.problemSource || 0] || "-",
  },
  {
    title: "类型",
    key: "feedbackTypeName",
    width: 140,
    align: "center",
    render: (row) => row.feedbackTypeName || "-",
  },
  {
    title: "问题",
    key: "problem",
    minWidth: 200,
    align: "center",
    render: (row) => row.problem || "-",
  },
  {
    title: "优先级",
    key: "priority",
    width: 120,
    align: "center",
    render: (row) => priorityLabelMap[row.priority || 0] || "-",
  },
  {
    title: "处理状态",
    key: "handleStatus",
    width: 120,
    align: "center",
    render: (row) => statusLabelMap[row.handleStatus || 0] || "-",
  },
  {
    title: "处理人",
    key: "handleUserName",
    width: 120,
    align: "center",
    render: (row) => row.handleUserName || "-",
  },
  {
    title: "添加时间",
    key: "createTime",
    width: 180,
    align: "center",
    render: (row) => row.createTime || "-",
  },
  {
    title: "操作",
    key: "actions",
    width: 260,
    align: "center",
    render: (row) => {
      const buttons = [
        h(
          NButton,
          { text: true, type: "primary", onClick: () => handleView(row) },
          { default: () => "详情" }
        ),
      ];
      if (row.handleStatus === 1) {
        buttons.push(
          h(
            NButton,
            { text: true, type: "primary", onClick: () => handleStart(row) },
            { default: () => "开始处理" }
          )
        );
      }
      if (row.handleStatus === 2) {
        buttons.push(
          h(
            NButton,
            { text: true, type: "primary", onClick: () => handleProcess(row) },
            { default: () => "处理" }
          )
        );
      }
      buttons.push(
        h(
          NButton,
          { text: true, type: "primary", onClick: () => handleEdit(row) },
          { default: () => "编辑" }
        ),
        h(
          NButton,
          { text: true, type: "error", onClick: () => handleDelete(row) },
          { default: () => "删除" }
        )
      );
      return h(
        "div",
        { class: "flex items-center justify-center gap-2" },
        buttons
      );
    },
  },
];
function handleSearch() {
  pagination.value.page = 1;
  getWorkOrderList();
}
function handleReset() {
  searchForm.value = { ...initialSearchForm };
  pagination.value.page = 1;
  getWorkOrderList();
}
function handlePageChange(page: number) {
  pagination.value.page = page;
  getWorkOrderList();
}
function handlePageSizeChange(pageSize: number) {
  pagination.value.pageSize = pageSize;
  pagination.value.page = 1;
  getWorkOrderList();
}
function handleCreate() {
  modalMode.value = "add";
  currentRow.value = null;
  modalVisible.value = true;
}
function handleEdit(row: WorkOrderItem) {
  modalMode.value = "edit";
  currentRow.value = row;
  modalVisible.value = true;
}
function handleView(row: WorkOrderItem) {
  router.push({
    path: "/service/ticket/workOrder/detail",
    query: { id: row.id },
  });
}
function handleStart(row: WorkOrderItem) {
  handleRow.value = row;
  handleModalVisible.value = true;
}
function handleProcess(row: WorkOrderItem) {
  handleRow.value = row;
  handleModalVisible.value = true;
}
async function handleSave(formData: any) {
  const handleUser = handleUserOptions.value.find(
    (item) => item.value === formData.handleUserId
  );
  const savePayload = {
    feedbackTypeId: formData.feedbackTypeId || undefined,
    feedbackUserPhone: formData.feedbackUserPhone || undefined,
    priority: formData.priority ?? undefined,
    problemSource: formData.problemSource ?? undefined,
    problem: formData.problem || undefined,
    problemDescription: formData.problemDescription || undefined,
    problemImage: Array.isArray(formData.problemImage)
      ? formData.problemImage
      : undefined,
    handleUserId: formData.handleUserId ?? undefined,
    handleUserName: handleUser?.label || undefined,
  };
  if (modalMode.value === "add") {
    const { code } = await saveWorkOrder(savePayload);
    if (code === 200) {
      window.$message?.success("新增成功");
      modalVisible.value = false;
      getWorkOrderList();
    } else {
      window.$message?.error("新增失败");
    }
    return;
  }
  const { code } = await updateWorkOrder({
    id: formData.id as number,
    ...savePayload,
  });
  if (code === 200) {
    window.$message?.success("编辑成功");
    modalVisible.value = false;
    getWorkOrderList();
  } else {
    window.$message?.error("编辑失败");
  }
}
async function handleSubmitHandle(payload: {
  handleType: number;
  handleLog: string;
  handleUserId: number | string | null;
  handleFile: { id: string; name: string; url: string }[];
}) {
  if (!handleRow.value?.id) return;
  const selectedUser = handleUserOptions.value.find(
    (item) => item.value === payload.handleUserId
  );
  const handleUserId =
    payload.handleUserId ?? handleRow.value.handleUserId ?? undefined;
  const handleUserName =
    selectedUser?.label || handleRow.value.handleUserName || undefined;
  const submitData = {
    handleFile: payload.handleFile || undefined,
    handleLog: payload.handleLog,
    handleType: payload.handleType,
    handleUserId,
    handleUserName,
    workOrderId: handleRow.value.id,
  };
  const execSubmit = async () => {
    const { code } = await handleWorkOrder(submitData);
    if (code === 200) {
      window.$message?.success("操作成功");
      handleModalVisible.value = false;
      getWorkOrderList();
    } else {
      window.$message?.error("操作失败");
    }
  };
  if (payload.handleType === 2) {
    window.$dialog?.warning({
      title: "确认结办",
      content: "是否确认结办?",
      positiveText: "确定",
      negativeText: "取消",
      onPositiveClick: execSubmit,
    });
    return;
  }
  execSubmit();
}
function handleDelete(row: WorkOrderItem) {
  window.$dialog?.warning({
    title: "确认删除",
    content: "确定要删除该工单吗?",
    positiveText: "确定",
    negativeText: "取消",
    onPositiveClick: async () => {
      const { error } = await deleteWorkOrder(row.id);
      if (!error) {
        window.$message?.success("删除成功");
        getWorkOrderList();
      } else {
        window.$message?.error("删除失败");
      }
    },
  });
}
async function getTypeOptions() {
  const res = await fetchFeedbackTypeList({ pageNum: 1, pageSize: 9999 });
  if (res.code === 200 && res.data) {
    const list = Array.isArray(res.data) ? res.data : res.data.records || [];
    typeOptions.value = list.map((item: any) => ({
      label: item.feedbackType,
      value: item.id,
    }));
  } else {
    typeOptions.value = [];
  }
}
async function getHandleUserOptions() {
  const res = await fetchOperationsUserListAll();
  if (res.code === 200 && res.data) {
    const list = Array.isArray(res.data) ? res.data : res.data.records || [];
    handleUserOptions.value = list
      .filter((item: any) => item.nickName)
      .map((item: any) => ({
        label: item.nickName,
        value: item.id,
      }));
  } else {
    handleUserOptions.value = [];
  }
}
async function getWorkOrderList() {
  startLoading();
  try {
    const res = await fetchWorkOrderPageList({
      feedbackTypeId: searchForm.value.feedbackTypeId || undefined,
      feedbackUserPhone: searchForm.value.feedbackUserPhone || undefined,
      handleStatus: searchForm.value.handleStatus ?? undefined,
      pageNum: pagination.value.page,
      pageSize: pagination.value.pageSize,
      priority: searchForm.value.priority ?? undefined,
      problemSource: searchForm.value.problemSource ?? undefined,
    });
    if (res.code === 200 && res.data) {
      tableData.value = res.data.records || [];
      pagination.value.itemCount = res.data.total || 0;
    } else {
      tableData.value = [];
      pagination.value.itemCount = 0;
    }
  } finally {
    endLoading();
  }
}
onMounted(() => {
  getTypeOptions();
  getHandleUserOptions();
  getWorkOrderList();
});
</script>
<template>
  <div class="h-full flex flex-col">
    <n-card style="border-radius: 6px" content-style="padding: 12px">
      <n-form
        :model="searchForm"
        label-placement="left"
        inline
        :show-feedback="false"
      >
        <div class="flex justify-between items-center w-full">
          <div class="flex flex-wrap gap-4">
            <n-form-item label="用户">
              <n-input
                v-model:value="searchForm.feedbackUserPhone"
                placeholder="请输入"
                clearable
                style="width: 200px"
              />
            </n-form-item>
            <n-form-item label="问题来源">
              <n-select
                v-model:value="searchForm.problemSource"
                :options="problemSourceOptions"
                placeholder="请选择"
                clearable
                style="width: 200px"
              />
            </n-form-item>
            <n-form-item label="类型">
              <n-select
                v-model:value="searchForm.feedbackTypeId"
                :options="typeOptions"
                placeholder="请选择"
                clearable
                style="width: 200px"
              />
            </n-form-item>
            <n-form-item label="优先级">
              <n-select
                v-model:value="searchForm.priority"
                :options="priorityOptions"
                placeholder="请选择"
                clearable
                style="width: 200px"
              />
            </n-form-item>
            <n-form-item label="状态">
              <n-select
                v-model:value="searchForm.handleStatus"
                :options="statusOptions"
                placeholder="请选择"
                clearable
                style="width: 200px"
              />
            </n-form-item>
          </div>
          <div class="flex gap-2">
            <n-button style="width: 84px; height: 32px" @click="handleReset"
              >重置</n-button
            >
            <n-button
              type="primary"
              style="width: 84px; height: 32px"
              @click="handleSearch"
              >查询</n-button
            >
          </div>
        </div>
      </n-form>
    </n-card>
    <n-card
      style="border-radius: 6px; margin-top: 12px"
      content-style="padding: 12px"
    >
      <div class="mb-4 flex justify-end gap-2">
        <n-button
          style="
            width: 96px;
            height: 32px;
            color: #0068ff;
            background-color: #e6f4ff;
            border: 1px solid #7abdff;
          "
          @click="handleCreate"
          >创建工单</n-button
        >
      </div>
      <n-data-table
        :columns="columns"
        :data="tableData"
        :loading="loading"
        :pagination="{
          page: pagination.page,
          pageSize: pagination.pageSize,
          itemCount: pagination.itemCount,
          showSizePicker: true,
          pageSizes: pagination.pageSizes,
          prefix: ({ itemCount }) => `共 ${itemCount || 0} 条`,
        }"
        :scroll-x="1600"
        :row-key="(row: WorkOrderItem) => row.id"
        @update:page="handlePageChange"
        @update:page-size="handlePageSizeChange"
      />
    </n-card>
    <WorkOrderModal
      v-model:visible="modalVisible"
      :mode="modalMode"
      :row-data="currentRow"
      :type-options="typeOptions"
      :problem-source-options="problemSourceOptions"
      :priority-options="priorityOptions"
      :handle-user-options="handleUserOptions"
      @submit="handleSave"
    />
    <WorkOrderHandleModal
      v-model:visible="handleModalVisible"
      :row-data="handleRow"
      :type-options="typeOptions"
      :problem-source-options="problemSourceOptions"
      :priority-options="priorityOptions"
      :handle-user-options="handleUserOptions"
      @submit="handleSubmitHandle"
    />
  </div>
</template>
<style scoped></style>
src/views/transaction/order/orderManagement/index.vue
@@ -135,21 +135,21 @@
    width: 120,
    resizable: true,
    render(row) {
        return h(
          NTag,
          {
            type: statusOptions.find(item => item.value === row.status)?.type || 'default',
            style: row.status === 7 ? {
              color: '#764DFF',
              backgroundColor: '#F6F0FF',
            } : {},
            bordered: false
          },
          {
            default: () => statusOptions.find(item => item.value === row.status)?.label || '-'
          }
        )
      }
      return h(
        NTag,
        {
          type: statusOptions.find(item => item.value === row.status)?.type || 'default',
          style: row.status === 7 ? {
            color: '#764DFF',
            backgroundColor: '#F6F0FF',
          } : {},
          bordered: false
        },
        {
          default: () => statusOptions.find(item => item.value === row.status)?.label || '-'
        }
      )
    }
  },
  {
    title: '操作',
src/views/transaction/pricingManagement/detail.vue
@@ -5,6 +5,7 @@
import { useRoute, useRouter } from 'vue-router'
import { getPricingDetail, setPricing } from '@/service/api/transaction'
// 检查对象中是否存在值为null的键
const hasNullValue = (obj: Record<string, any>) => {
  for (const key in obj) {
@@ -53,6 +54,7 @@
    num12: null,
    num13: null,
    num14: null,
    num141: null, // 夜间行车时间
    num15: null,
    num16: null,
    num17: null,
@@ -60,7 +62,9 @@
    num19: null,
    num20: null,
    num21: null,
    num211: null,
    num22: null,
    num221: null,
    num23: null,
    num24: null,
    num25: null,
@@ -116,10 +120,18 @@
      window.$message.error('请配置基础定价')
      return
    }
    let basicPricingJson1= ''
    if (category.value === '1') {
      if (hasNullValue(model.value.basicPricingJson1)) {
        window.$message.error('请配置基础定价')
        return
      } else {
        model.value.basicPricingJson1.num14 = model.value.basicPricingJson1.num14 + ' - ' + model.value.basicPricingJson1.num141
        model.value.basicPricingJson1.num21 = model.value.basicPricingJson1.num21 + ' - ' + model.value.basicPricingJson1.num211
        model.value.basicPricingJson1.num22 = model.value.basicPricingJson1.num22 + ' - ' + model.value.basicPricingJson1.num221
        delete model.value.basicPricingJson1.num141
        delete model.value.basicPricingJson1.num211
        delete model.value.basicPricingJson1.num221
      }
      if (model.value.startingFareJson.length === 0) {
        window.$message.error('请配置起步价')
@@ -137,9 +149,6 @@
      return
    }
    startLoading()
    if (category.value === '2') {
    } else {
    }
    const params = {
      basicPricingJson: category.value === '1' ? JSON.stringify(model.value.basicPricingJson1) : JSON.stringify(model.value.basicPricingJson2),
      dynamicPricingJson: JSON.stringify(model.value.dynamicPricingJson),
@@ -171,6 +180,15 @@
    detail.value = res.data
    if (category.value === '1') {
      const parsedBasicPricing = JSON.parse(detail.value.basicPricingJson)
      const num14 = JSON.parse(JSON.stringify(parsedBasicPricing.num14))
      const num21 = JSON.parse(JSON.stringify(parsedBasicPricing.num21))
      const num22 = JSON.parse(JSON.stringify(parsedBasicPricing.num22))
      parsedBasicPricing.num14 = num14.split(' - ')[0]
      parsedBasicPricing.num141 = num14.split(' - ')[1]
      parsedBasicPricing.num21 = num21.split(' - ')[0]
      parsedBasicPricing.num211 = num21.split(' - ')[1]
      parsedBasicPricing.num22 = num22.split(' - ')[0]
      parsedBasicPricing.num221 = num22.split(' - ')[1]
      model.value.basicPricingJson1 = parsedBasicPricing || { ...initialModel.basicPricingJson1 }
      const parsedStartingFare = JSON.parse(detail.value.startingFareJson)
      model.value.startingFareJson = parsedStartingFare || []
@@ -346,7 +364,9 @@
                  <NSpace vertical :size="16">
                    <NSpace align="center">
                      <n-time-picker v-model:formatted-value="model.basicPricingJson1.num14" format="HH:mm"
                        value-format="HH:mm" clearable />
                        value-format="HH:mm" clearable class="w-80px"/>-
                      <n-time-picker v-model:formatted-value="model.basicPricingJson1.num141" format="HH:mm"
                        value-format="HH:mm" clearable class="w-80px" />
                      点为夜间行车时间,起步价变为
                      <n-input-number v-model:value="model.basicPricingJson1.num15" min="0" max="999" :precision="0"
                        :show-button="false" placeholder="最多3位数" class="mr-3" />
@@ -379,10 +399,14 @@
                  <NSpace vertical :size="16">
                    <NSpace align="center">
                      <n-time-picker v-model:formatted-value="model.basicPricingJson1.num21" format="HH:mm"
                        value-format="HH:mm" clearable />
                        value-format="HH:mm" clearable class="w-80px" />-
                      <n-time-picker v-model:formatted-value="model.basicPricingJson1.num211" format="HH:mm"
                        value-format="HH:mm" clearable class="w-80px" />
                      和
                      <n-time-picker v-model:formatted-value="model.basicPricingJson1.num22" format="HH:mm"
                        value-format="HH:mm" clearable />
                        value-format="HH:mm" clearable class="w-80px" />-
                      <n-time-picker v-model:formatted-value="model.basicPricingJson1.num221" format="HH:mm"
                        value-format="HH:mm" clearable class="w-80px" />
                      为高峰期行车时间,起步价变为
                      <n-input-number v-model:value="model.basicPricingJson1.num23" min="0" max="999" :precision="0"
                        :show-button="false" placeholder="最多3位数" class="mr-3" />