zjk
2026-04-09 e2f91cec56e0779fe5fe1ab67c19e45b509ecd49
feat: 新增定向服务管理模块
8个文件已添加
3个文件已修改
2708 ■■■■■ 已修改文件
src/router/routes.static.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/service/api/directService.ts 31 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/directServiceManagement/add.vue 572 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/directServiceManagement/components/CruisePlanSelectModal.vue 327 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/directServiceManagement/components/PriorityModal.vue 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/directServiceManagement/components/UserSelectModal.vue 404 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/directServiceManagement/components/VehicleSelectModal.vue 370 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/directServiceManagement/detail.vue 300 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/directServiceManagement/index.vue 406 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/order/orderManagement/index.vue 128 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/transaction/pricingManagement/components/TableModal.vue 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/router/routes.static.ts
@@ -850,6 +850,63 @@
    pid: 6,
  },
  {
    name: 'directServiceManagement',
    path: '/transaction/directServiceManagement',
    title: '定向服务管理',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/transaction/directServiceManagement/index.vue',
    id: 607,
    keepAlive: true,
    pid: 6,
  },
  {
    name: 'directServiceManagementAdd',
    path: '/transaction/directServiceManagement/add',
    title: '新增定向服务',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/transaction/directServiceManagement/add.vue',
    id: 6071,
    keepAlive: false,
    pid: 6,
    hide: true,
    activeMenu: '/transaction/directServiceManagement',
    fullScreen: true,
  },
  {
    name: 'directServiceManagementEdit',
    path: '/transaction/directServiceManagement/edit',
    title: '编辑定向服务',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/transaction/directServiceManagement/add.vue',
    id: 6072,
    keepAlive: false,
    pid: 6,
    hide: true,
    activeMenu: '/transaction/directServiceManagement',
    fullScreen: true,
  },
  {
    name: 'directServiceManagementDetail',
    path: '/transaction/directServiceManagement/detail',
    title: '定向服务详情',
    requiresAuth: true,
    icon: 'icon-park-outline:list-view',
    menuType: 'page',
    componentPath: '/transaction/directServiceManagement/detail.vue',
    id: 6073,
    keepAlive: false,
    pid: 6,
    hide: true,
    activeMenu: '/transaction/directServiceManagement',
    fullScreen: true,
  },
  {
    name: 'operations',
    path: '/operations',
    title: '运维执行',
src/service/api/directService.ts
New file
@@ -0,0 +1,31 @@
import { request } from '@/service/http'
/** 获取定向服务列表 */
export function getDirectServiceList(params: any) {
  return request.Get<Service.ResponseResult<any>>('/api/direct-service/page', params)
}
/** 添加定向服务 */
export function addDirectService(params: any) {
  return request.Post<Service.ResponseResult<any>>('/api/direct-service', params)
}
/** 获取定向服务详情 */
export function getDirectServiceDetail(id: any) {
  return request.Get<Service.ResponseResult<any>>(`/api/direct-service/${id}`)
}
/** 编辑定向服务 */
export function editDirectService(id: any, params: any) {
  return request.Put<Service.ResponseResult<any>>(`/api/direct-service/${id}`, params)
}
/** 删除定向服务 */
export function deleteDirectService(id: any) {
  return request.Delete<Service.ResponseResult<any>>(`/api/direct-service/${id}`)
}
/** 启用/禁用定向服务 */
export function toggleDirectServiceStatus(id: any, status: any) {
  return request.Put<Service.ResponseResult<any>>(`/api/direct-service/${id}/status`, { status })
}
src/views/transaction/directServiceManagement/add.vue
New file
@@ -0,0 +1,572 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NCascader, NCheckbox, NDataTable, NDatePicker, NForm, NGrid, NInput, NSpace, NTag, useDialog, useMessage } from 'naive-ui'
import { getRegionCascaderNoArea } from '@/service/api/operations'
import { useTabStore } from '@/store'
import { useBoolean } from '@/hooks'
import PriorityModal from './components/PriorityModal.vue'
import VehicleSelectModal from './components/VehicleSelectModal.vue'
import CruisePlanSelectModal from './components/CruisePlanSelectModal.vue'
import UserSelectModal from './components/UserSelectModal.vue'
const tabStore = useTabStore()
const router = useRouter()
const route = useRoute()
const dialog = useDialog()
const message = useMessage()
const isEdit = ref(false)
const id = ref<string | null>(null)
const loading = ref(false)
const currentStep = ref(2)
const formModel = ref({
  cityCode: null,
  isPermanent: true,
  startTime: null as number | null,
  endTime: null as number | null,
  remark: '',
})
// 表单校验规则
const rules = {
  cityCode: {
    required: true,
    message: '请选择城市',
    trigger: ['blur', 'change'],
  },
}
const formRef = ref<any>(null)
const pcaOptions = ref<any[]>([])
function formatCascaderData(list: any[]) {
  return list.map((item) => {
    const newItem = { ...item }
    if (newItem.children && newItem.children.length > 0) {
      newItem.children = formatCascaderData(newItem.children)
    }
    else {
      delete newItem.children
    }
    return newItem
  })
}
async function fetchCascaderData() {
  const res = await getRegionCascaderNoArea()
  if (res.code === 200 && res.data) {
    pcaOptions.value = formatCascaderData(res.data)
  }
}
async function fetchDetail() {
  if (!id.value)
    return
  loading.value = true
  try {
    // const res = await getDirectServiceDetail(id.value)
    // if (res.code === 200 && res.data) {
    //   const data = res.data
    //   formModel.value.cityCode = data.cityCode
    //   formModel.value.isPermanent = !!data.isPermanent
    //   if (data.startTime && data.endTime) {
    //     formModel.value.timeRange = [dayjs(data.startTime).valueOf(), dayjs(data.endTime).valueOf()]
    //   }
    //   formModel.value.boundVehicleCount = data.boundVehicleCount || 0
    //   formModel.value.boundUserCount = data.boundUserCount || 0
    //   formModel.value.status = data.status
    //   formModel.value.remark = data.remark || ''
    // }
  }
  finally {
    loading.value = false
  }
}
// 关联车辆 Mock 数据和表格配置
const selectedVehicles = ref<any[]>([
  {
    id: 1,
    vehicleCode: 'S156165161651',
    plateNo: '粤PXV7029',
    model: 'Robotaxi',
    fleet: '白云区1号',
    cruisePlan: '广州巡游计划1111',
  },
  {
    id: 2,
    vehicleCode: 'S156165161651',
    plateNo: '粤PXV7029',
    model: 'Robotaxi',
    fleet: '白云区1号',
    cruisePlan: '-',
  },
])
const vehicleColumns = computed<DataTableColumns<any>>(() => [
  { title: '序号', key: 'index', width: 60, render: (_, index) => index + 1 },
  { title: '车辆编码', key: 'vehicleCode' },
  { title: '车牌号', key: 'plateNo' },
  { title: '型号', key: 'model' },
  { title: '所属车队', key: 'fleet' },
  { title: '关联巡游计划', key: 'cruisePlan' },
  {
    title: '操作',
    key: 'actions',
    width: 140,
    render: (row) => {
      return (
        <div class="flex items-center gap-2">
          <NButton text type="primary" onClick={() => handleRemoveVehicle(row)}>移除</NButton>
          <NButton text type="primary" onClick={() => handleOpenCruisePlanModal(row)}>设置巡游计划</NButton>
        </div>
      )
    },
  },
])
// 服务用户 Mock 数据和表格配置
const selectedUsers = ref<any[]>([
  { id: 1, userName: '方一强', account: '13034179070', status: 1, priority: 100, desc: '优先级说明内容说明内容说明内容' },
  { id: 2, userName: '李雅惠', account: '13619910302', status: 0, priority: 80, desc: '优先级说明内容说明内容说明内容' },
  { id: 3, userName: '倪怡芳', account: '15392830508', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 4, userName: '-', account: '13818629999', status: 0, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 5, userName: '-', account: '13654373011', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 6, userName: '谢彦文', account: '13396827528', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 7, userName: '-', account: '18878751881', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 8, userName: '-', account: '18878751881', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 9, userName: '-', account: '18878751881', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 10, userName: '-', account: '18878751881', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 11, userName: '-', account: '18878751881', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
])
const userColumns = computed<DataTableColumns<any>>(() => [
  { title: '序号', key: 'index', width: 60, render: (_, index) => index + 1 },
  { title: '用户名称', key: 'userName' },
  { title: '白名单账号', key: 'account' },
  {
    title: '状态',
    key: 'status',
    render: (row) => {
      const isNormal = row.status === 1
      return (
        <NTag
          round
          bordered={false}
          style={{
            borderRadius: '4px',
            color: isNormal ? '#0068FF' : '#FF8C00',
            backgroundColor: isNormal ? 'rgba(0, 104, 255, 0.1)' : 'rgba(255, 140, 0, 0.1)',
            padding: '0 8px',
          }}
        >
          {isNormal ? '正常' : '冻结'}
        </NTag>
      )
    },
  },
  { title: '用户优先级', key: 'priority' },
  { title: '优先级说明', key: 'desc' },
  {
    title: '操作',
    key: 'actions',
    width: 130,
    render: (row) => {
      return (
        <div class="flex items-center gap-2">
          <NButton text type="primary" onClick={() => handleRemoveUser(row)}>移除</NButton>
          <NButton text type="primary" onClick={() => handleOpenPriorityModal(row)}>设置优先级</NButton>
        </div>
      )
    },
  },
])
// 弹窗相关逻辑已抽离到单独组件
const { bool: priorityModalVisible, setTrue: showPriorityModal } = useBoolean(false)
const { bool: vehicleSelectModalVisible, setTrue: showVehicleModal } = useBoolean(false)
const { bool: cruisePlanModalVisible, setTrue: showCruisePlanModal } = useBoolean(false)
const { bool: userSelectModalVisible, setTrue: showUserModal } = useBoolean(false)
const currentEditVehicle = ref<any>(null) // null 表示批量设置,否则为单条设置
const currentEditUser = ref<any>(null)
function handleOpenVehicleSelectModal() {
  showVehicleModal()
}
function handleOpenUserSelectModal() {
  showUserModal()
}
function handleUserSelectConfirm(selectedUsersData: any[]) {
  const newUsers = selectedUsersData.filter(
    (u: any) => !selectedUsers.value?.some((existing: any) => existing.id === u.id),
  )
  if (newUsers.length > 0) {
    selectedUsers.value = [...(selectedUsers.value || []), ...newUsers.map(u => ({
      ...u,
      priority: 50, // 默认赋予优先级 50
      desc: '默认优先级', // 默认说明
    }))]
  }
}
function handleOpenCruisePlanModal(row?: any) {
  currentEditVehicle.value = row || null
  showCruisePlanModal()
}
function handleCruisePlanConfirm(selectedPlan: any) {
  if (currentEditVehicle.value) {
    // 单条设置
    currentEditVehicle.value.cruisePlan = selectedPlan.planName
  }
  else {
    // 批量设置
    selectedVehicles.value.forEach((v) => {
      v.cruisePlan = selectedPlan.planName
    })
  }
}
function handleVehicleSelectConfirm(selectedVehiclesData: any[]) {
  // Mock adding selected vehicles to the list
  const newVehicles = selectedVehiclesData.filter(
    (v: any) => !selectedVehicles.value?.some((existing: any) => existing.id === v.id),
  )
  if (newVehicles.length > 0) {
    selectedVehicles.value = [...(selectedVehicles.value || []), ...newVehicles]
  }
}
function handleOpenPriorityModal(row: any) {
  currentEditUser.value = row
  showPriorityModal()
}
function submitPriority(priority: number) {
  if (currentEditUser.value) {
    currentEditUser.value.priority = priority
  }
}
function handleRemoveVehicle(row: any) {
  dialog.warning({
    title: '提示',
    content: '确认要移除该车辆吗?',
    positiveText: '确认',
    negativeText: '取消',
    onPositiveClick: () => {
      selectedVehicles.value = selectedVehicles.value.filter(v => v.id !== row.id)
      message.success('移除成功')
    },
  })
}
function handleRemoveUser(row: any) {
  dialog.warning({
    title: '提示',
    content: '确认要移除该用户吗?',
    positiveText: '确认',
    negativeText: '取消',
    onPositiveClick: () => {
      selectedUsers.value = selectedUsers.value.filter(u => u.id !== row.id)
      message.success('移除成功')
    },
  })
}
function handleResetVehicles() {
  if (!selectedVehicles.value.length) {
    message.warning('暂无可重置的数据')
    return
  }
  dialog.warning({
    title: '提示',
    content: '确认要清空已关联的车辆吗?',
    positiveText: '确认',
    negativeText: '取消',
    onPositiveClick: () => {
      selectedVehicles.value = []
      message.success('重置成功')
    },
  })
}
function handleResetUsers() {
  if (!selectedUsers.value.length) {
    message.warning('暂无可重置的数据')
    return
  }
  dialog.warning({
    title: '提示',
    content: '确认要清空已选择的用户吗?',
    positiveText: '确认',
    negativeText: '取消',
    onPositiveClick: () => {
      selectedUsers.value = []
      message.success('重置成功')
    },
  })
}
function handlePrev() {
  currentStep.value--
}
function handleNext() {
  if (currentStep.value === 1) {
    formRef.value?.validate((errors: any) => {
      if (!errors) {
        currentStep.value = 2
      }
    })
  }
  else if (currentStep.value === 2) {
    currentStep.value = 3
  }
}
function handleCancel() {
  tabStore.closeTab(route.fullPath)
  router.back()
}
onMounted(() => {
  fetchCascaderData()
  if (route.query.id) {
    isEdit.value = true
    id.value = route.query.id as string
    fetchDetail()
  }
})
</script>
<template>
  <div class=" w-full h-full relative content flex flex-col">
    <div class="p-12px main flex flex-col flex-1 overflow-hidden">
      <div class="box mb-12px shrink-0">
        <div class="text-xl">
          定向服务配置
        </div>
      </div>
      <!-- 顶部步骤条 -->
      <div class="box mb-12px shrink-0">
        <n-steps
          :current="currentStep" status="process" class="justify-center mx-auto"
          style="max-width: 600px; padding-top: 12px;"
        >
          <n-step title="基础信息" />
          <n-step title="关联车辆" />
          <n-step title="服务用户" />
        </n-steps>
      </div>
      <!-- 基础信息卡片 -->
      <div v-if="currentStep === 1" class="box flex-1 flex flex-col overflow-y-auto">
        <div class="head shrink-0">
          基础信息
        </div>
        <n-spin :show="loading">
          <NForm ref="formRef" :model="formModel" :rules="rules" label-placement="top" class="custom-form">
            <!-- 第一行:城市和生效时间段 -->
            <NGrid :cols="24" :x-gap="24" :y-gap="8">
              <n-form-item-gi :span="6" label="城市" path="cityCode" required>
                <NCascader
                  v-model:value="formModel.cityCode" :options="pcaOptions" value-field="code"
                  label-field="name" check-strategy="child" clearable placeholder="请选择"
                >
                  <template #arrow>
                    <img
                      src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
                      class="arrow-icon" style="width: 16px; height: 16px" alt="arrow"
                    >
                  </template>
                </NCascader>
              </n-form-item-gi>
              <n-form-item-gi :span="12" label="生效时间段" path="startTime" required>
                <div class="flex items-center gap-2 w-full">
                  <NDatePicker
                    v-model:value="formModel.startTime" type="datetime" clearable
                    :disabled="formModel.isPermanent" placeholder="请选择" class="flex-1"
                  />
                  <span class="text-gray-400 shrink-0">至</span>
                  <NDatePicker
                    v-model:value="formModel.endTime" type="datetime" clearable
                    :disabled="formModel.isPermanent" placeholder="请选择" class="flex-1"
                  />
                  <NCheckbox v-model:checked="formModel.isPermanent" class="shrink-0 ml-2">
                    长期有效
                  </NCheckbox>
                </div>
              </n-form-item-gi>
              <n-form-item-gi :span="18" label="备注" path="remark">
                <NInput
                  v-model:value="formModel.remark" type="textarea" placeholder="请输入" show-count :maxlength="200"
                  class="bg-gray-50/50"
                />
              </n-form-item-gi>
            </NGrid>
          </NForm>
        </n-spin>
      </div>
      <!-- 关联车辆卡片 -->
      <div v-if="currentStep === 2" class="box flex-1 flex flex-col overflow-hidden">
        <div class="head shrink-0">
          关联车辆
        </div>
        <div class="mb-4 shrink-0 flex gap-3">
          <NButton type="primary" secondary @click="handleOpenVehicleSelectModal">
            选择
          </NButton>
          <NButton @click="handleResetVehicles">
            重置
          </NButton>
          <NButton @click="handleOpenCruisePlanModal()">
            批量设置巡游计划
          </NButton>
        </div>
        <NDataTable
          :columns="vehicleColumns"
          :data="selectedVehicles"
          :bordered="false"
          :single-line="false"
          flex-height
          class="flex-1"
        />
      </div>
      <!-- 服务用户卡片 -->
      <div v-if="currentStep === 3" class="box flex-1 flex flex-col overflow-hidden">
        <div class="head shrink-0">
          服务用户
        </div>
        <div class="mb-4 shrink-0 flex gap-3">
          <NButton type="primary" secondary @click="handleOpenUserSelectModal">
            选择
          </NButton>
          <NButton @click="handleResetUsers">
            重置
          </NButton>
        </div>
        <NDataTable
          :columns="userColumns"
          :data="selectedUsers"
          :bordered="false"
          :single-line="false"
          flex-height
          class="flex-1"
        />
      </div>
    </div>
    <!-- 底部操作栏 -->
    <div class="footer flex justify-end items-center shrink-0">
      <NSpace>
        <NButton v-if="currentStep === 1" class="w-84px" @click="handleCancel">
          取消
        </NButton>
        <NButton v-if="currentStep > 1" class="w-84px" @click="handlePrev">
          上一步
        </NButton>
        <NButton type="primary" class="w-84px" @click="handleNext">
          {{ currentStep === 3 ? '完成' : '下一步' }}
        </NButton>
      </NSpace>
    </div>
    <PriorityModal
      v-model:visible="priorityModalVisible"
      :priority="currentEditUser?.priority ?? null"
      @confirm="submitPriority"
    />
    <VehicleSelectModal
      v-model:visible="vehicleSelectModalVisible"
      :existing-vehicles="selectedVehicles"
      @confirm="handleVehicleSelectConfirm"
    />
    <CruisePlanSelectModal
      v-model:visible="cruisePlanModalVisible"
      @confirm="handleCruisePlanConfirm"
    />
    <UserSelectModal
      v-model:visible="userSelectModalVisible"
      :existing-users="selectedUsers"
      @confirm="handleUserSelectConfirm"
    />
  </div>
</template>
<style scoped lang="scss">
.roboTaxi-card {
  height: calc(100vh - 76px);
  width: 100%;
  box-sizing: border-box;
}
.n-button--secondary {
  box-sizing: border-box;
  color: #0068FF;
  border: 1px solid #7ABDFF;
  background-color: #E6F4FF;
}
.box {
  padding: 24px;
  background-color: #fff;
  border-radius: 6px;
  width: 100%;
  box-sizing: border-box;
  --n-label-text-color: rgba(23, 31, 38, 0.8);
}
.head {
  font-family: PingFang SC;
  font-size: 16px;
  font-weight: 600;
  line-height: 24px;
  letter-spacing: normal;
  color: #171f26;
  position: relative;
  text-indent: 11px;
  margin-bottom: 16px;
  &::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 0;
    transform: translateY(-50%);
    width: 3px;
    height: 14px;
    z-index: 0;
    border-radius: 3px;
    background: #0068ff;
  }
}
.footer {
  position: sticky;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 64px;
  background-color: #fff;
  box-shadow: 0px 0px 5px 0px rgba(23, 31, 38, 0.05);
  padding: 16px 24px;
}
</style>
src/views/transaction/directServiceManagement/components/CruisePlanSelectModal.vue
New file
@@ -0,0 +1,327 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { computed, ref, watch } from 'vue'
import { NButton, NCard, NDataTable, NInput, NModal } from 'naive-ui'
import Pagination from '@/components/common/Pagination.vue'
interface CruisePlanItem {
  id: number
  planName: string
  relatedPoints: number
  relatedVehicles: number
  [key: string]: any
}
const emit = defineEmits<{
  confirm: [selectedPlan: CruisePlanItem]
}>()
const visible = defineModel<boolean>('visible', { default: false })
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
// 搜索条件
const searchModel = ref({
  planName: '',
})
// 选中的行(单选)
const checkedRowKeys = ref<number[]>([])
const listData = ref<CruisePlanItem[]>([])
// 缓存所有加载过的数据
const allLoadedData = ref<Map<number, CruisePlanItem>>(new Map())
const count = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
// 表格列定义
const columns = computed<DataTableColumns<CruisePlanItem>>(() => [
  {
    type: 'selection',
    multiple: false,
    width: 50,
  },
  {
    title: '序号',
    key: 'index',
    width: 80,
    render: (row, index) => <span>{(currentPage.value - 1) * pageSize.value + index + 1}</span>,
  },
  {
    title: '计划名称',
    key: 'planName',
    ellipsis: true,
  },
  {
    title: '关联点位',
    key: 'relatedPoints',
  },
  {
    title: '关联车辆数',
    key: 'relatedVehicles',
  },
])
// 获取巡游计划列表(纯Mock)
async function getPlanList() {
  startLoading()
  try {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 500))
    // 生成Mock数据,总共假设50条
    const totalItems = 50
    const startIdx = (currentPage.value - 1) * pageSize.value
    const currentList = []
    const basePlanName = searchModel.value.planName ? `${searchModel.value.planName}巡游计划` : '广州白云区巡游计划'
    for (let i = 0; i < pageSize.value; i++) {
      const idx = startIdx + i + 1
      if (idx > totalItems)
        break
      const randomPoints = Math.floor(Math.random() * 10) + 1
      currentList.push({
        id: idx,
        planName: basePlanName,
        relatedPoints: randomPoints,
        relatedVehicles: randomPoints, // 简化的Mock,数量与点位相同
      })
    }
    listData.value = currentList
    count.value = totalItems
    // 存入缓存
    listData.value.forEach((item) => {
      allLoadedData.value.set(item.id, item)
    })
  }
  catch (error) {
    console.error('获取巡游计划失败', error)
  }
  finally {
    endLoading()
  }
}
// 搜索
function handleSearch() {
  currentPage.value = 1
  getPlanList()
}
function resetSearchModel() {
  searchModel.value = {
    planName: '',
  }
}
// 重置
function handleReset() {
  resetSearchModel()
  currentPage.value = 1
  pageSize.value = 10
  getPlanList()
}
// 取消
function handleCancel() {
  visible.value = false
}
// 确认
function handleConfirm() {
  if (checkedRowKeys.value.length === 0) {
    window.$message?.warning('请选择一个巡游计划')
    return
  }
  // 单选
  const selectedId = checkedRowKeys.value[0]
  const selectedPlan = allLoadedData.value.get(selectedId)
  if (selectedPlan) {
    emit('confirm', selectedPlan)
    visible.value = false
  }
}
// 分页改变
function changePage(page: number, size: number) {
  const isPageSizeChanged = size !== pageSize.value
  pageSize.value = size
  currentPage.value = isPageSizeChanged ? 1 : page
  getPlanList()
}
function handleRowClick(row: CruisePlanItem) {
  checkedRowKeys.value = [row.id]
}
function rowProps(row: CruisePlanItem) {
  return {
    style: 'cursor: pointer;',
    onClick: () => handleRowClick(row),
  }
}
// 监听弹窗打开
watch(visible, (val) => {
  if (val) {
    resetSearchModel()
    currentPage.value = 1
    pageSize.value = 10
    checkedRowKeys.value = []
    allLoadedData.value.clear()
    getPlanList()
  }
})
</script>
<template>
  <NModal v-model:show="visible">
    <NCard
      style="
        width: 900px;
        height: 70vh;
        max-height: 700px;
        border-radius: 8px;
        display: flex;
        flex-direction: column;
      "
      :bordered="false"
      :segmented="{ content: true }"
      content-style="padding: 16px 24px; flex: 1; display: flex; flex-direction: column; overflow: hidden;"
      footer-style="padding: 16px 24px; border-top: 1px solid rgba(46, 61, 77, 0.1); flex-shrink: 0;"
    >
      <template #header>
        <span style="font-size: 16px; font-weight: bold; color: #1F1F1F;">设置巡游计划</span>
      </template>
      <template #header-extra>
        <div text class="cursor-pointer" @click="handleCancel">
          <span style="font-size: 18px; width: 10px; height: 10px; color: #999;">×</span>
        </div>
      </template>
      <div class="flex flex-col h-full overflow-hidden">
        <!-- 搜索区域 -->
        <div class="mb-3 flex items-end justify-between" style="flex-shrink: 0">
          <div class="w-300px">
            <div class="mb-2 text-sm text-[#1F1F1F]">
              计划名称
            </div>
            <NInput
              v-model:value="searchModel.planName"
              placeholder="请输入"
              clearable
            />
          </div>
          <div class="flex gap-8px">
            <NButton style="width: 84px; height: 32px" @click="handleReset">
              重置
            </NButton>
            <NButton
              type="primary"
              style="width: 84px; height: 32px"
              @click="handleSearch"
            >
              查询
            </NButton>
          </div>
        </div>
        <!-- 表格区域 -->
        <div class="flex-1 overflow-hidden" style="min-height: 0">
          <NDataTable
            v-model:checked-row-keys="checkedRowKeys"
            style="height: 100%"
            flex-height
            :columns="columns"
            :data="listData"
            :loading="loading"
            :bordered="false"
            :row-key="(row: CruisePlanItem) => row.id"
            :pagination="false"
            :row-props="rowProps"
          >
            <template #empty>
              <div class="flex-center">
                <img
                  src="/src/assets/element-img/no-data.png"
                  style="width: 123px; height: 125px"
                >
              </div>
            </template>
          </NDataTable>
        </div>
        <Pagination
          :count="count"
          :page="currentPage"
          :page-size="pageSize"
          style="margin-top: 12px; flex-shrink: 0"
          @change="changePage"
        />
      </div>
      <template #footer>
        <div class="flex justify-end gap-3">
          <NButton class="footer-btn" @click="handleCancel">
            取消
          </NButton>
          <NButton type="primary" class="footer-btn" @click="handleConfirm">
            保存
          </NButton>
        </div>
      </template>
    </NCard>
  </NModal>
</template>
<style scoped lang="scss">
.mb-4 {
  margin-bottom: 1rem;
}
.flex {
  display: flex;
}
.items-end {
  align-items: flex-end;
}
.gap-4 {
  gap: 1rem;
}
.gap-2 {
  gap: 0.5rem;
}
.gap-3 {
  gap: 0.75rem;
}
.flex-1 {
  flex: 1 1 0%;
}
.justify-between {
  justify-content: space-between;
}
.justify-end {
  justify-content: flex-end;
}
.mb-2 {
  margin-bottom: 0.5rem;
}
.text-sm {
  font-size: 0.875rem;
  line-height: 1.25rem;
}
.footer-btn {
  padding: 7px 29px;
  height: auto;
  font-size: 13px;
  line-height: 18px;
}
</style>
src/views/transaction/directServiceManagement/components/PriorityModal.vue
New file
@@ -0,0 +1,109 @@
<script setup lang="ts">
import type { FormInst, FormRules } from 'naive-ui'
import { NButton, NForm, NFormItem, NInputNumber, NModal, NSpace } from 'naive-ui'
const props = defineProps<{
  visible: boolean
  priority: number | null
}>()
const emit = defineEmits<{
  'update:visible': [value: boolean]
  confirm: [value: number]
}>()
const formRef = ref<FormInst>()
const formModel = ref({
  priority: null as number | null,
})
const rules: FormRules = {
  priority: [
    {
      required: true,
      type: 'number',
      message: '请输入优先权重值(1-100)',
      trigger: ['blur', 'change'],
    },
    {
      validator: (_, value: number | null) => {
        if (value === null || value === undefined)
          return new Error('请输入优先权重值(1-100)')
        if (!Number.isInteger(value) || value < 1 || value > 100)
          return new Error('请输入1-100的整数')
        return true
      },
      trigger: ['blur', 'change'],
    },
  ],
}
function closeModal() {
  emit('update:visible', false)
}
async function handleConfirm() {
  await formRef.value?.validate()
  if (formModel.value.priority !== null) {
    emit('confirm', formModel.value.priority)
    closeModal()
  }
}
watch(
  () => props.visible,
  (visible) => {
    if (visible) {
      formModel.value.priority = props.priority
      formRef.value?.restoreValidation()
    }
  },
)
</script>
<template>
  <NModal
    :show="visible"
    :mask-closable="false"
    preset="card"
    title="设置优先级"
    class="w-400px"
    :auto-focus="false"
    :segmented="{
      content: true,
      action: true,
    }"
    @close="closeModal"
  >
    <div class="pt-4 pb-2">
      <NForm ref="formRef" :model="formModel" :rules="rules" label-placement="top" label-width="100">
        <NFormItem label="优先权重值" path="priority">
          <div class="w-full">
            <NInputNumber
              v-model:value="formModel.priority"
              placeholder="请输入优先权重值(1-100)"
              :min="1"
              :max="100"
              :precision="0"
              :show-button="false"
              class="w-full"
            />
            <div class="text-[#0068FF] text-12px mt-2">
              权重值越大,优先级越高
            </div>
          </div>
        </NFormItem>
      </NForm>
    </div>
    <template #action>
      <NSpace justify="end">
        <NButton @click="closeModal">
          取消
        </NButton>
        <NButton type="primary" @click="handleConfirm">
          确认
        </NButton>
      </NSpace>
    </template>
  </NModal>
</template>
src/views/transaction/directServiceManagement/components/UserSelectModal.vue
New file
@@ -0,0 +1,404 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { computed, ref, watch } from 'vue'
import { NButton, NCard, NDataTable, NInput, NModal, NSelect, NTag } from 'naive-ui'
import Pagination from '@/components/common/Pagination.vue'
interface UserItem {
  id: number
  userName: string
  account: string
  status: number | string
  priorityDispatch: boolean
  creator: string
  createTime: string
  priority?: number
  desc?: string
  [key: string]: any
}
interface Props {
  existingUsers?: UserItem[]
}
const props = withDefaults(defineProps<Props>(), {
  existingUsers: () => [],
})
const emit = defineEmits<{
  confirm: [selectedUsers: UserItem[]]
}>()
const visible = defineModel<boolean>('visible', { default: false })
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
// 搜索条件
const searchModel = ref({
  userName: '',
  account: '',
  status: null as number | string | null,
})
const statusOptions = [
  { label: '全部', value: '' },
  { label: '正常', value: 1 },
  { label: '冻结', value: 0 },
]
// 选中的行
const checkedRowKeys = ref<number[]>([])
const listData = ref<UserItem[]>([])
// 缓存所有加载过的数据
const allLoadedData = ref<Map<number, UserItem>>(new Map())
const count = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
// 表格列定义
const columns = computed<DataTableColumns<UserItem>>(() => [
  {
    type: 'selection',
    width: 50,
  },
  {
    title: '序号',
    key: 'index',
    width: 80,
    render: (row, index) => <span>{(currentPage.value - 1) * pageSize.value + index + 1}</span>,
  },
  {
    title: '用户名称',
    key: 'userName',
    ellipsis: true,
  },
  {
    title: '白名单账号',
    key: 'account',
    ellipsis: true,
  },
  {
    title: '状态',
    key: 'status',
    render: (row) => {
      const isNormal = row.status === 1
      return (
        <NTag
          round
          bordered={false}
          style={{
            borderRadius: '4px',
            color: isNormal ? '#0068FF' : '#FF8C00',
            backgroundColor: isNormal ? 'rgba(0, 104, 255, 0.1)' : 'rgba(255, 140, 0, 0.1)',
            padding: '0 8px',
          }}
        >
          {isNormal ? '正常' : '冻结'}
        </NTag>
      )
    },
  },
  {
    title: '优先派单',
    key: 'priorityDispatch',
    render: row => <span>{row.priorityDispatch ? '是' : '否'}</span>,
  },
  {
    title: '创建人',
    key: 'creator',
    ellipsis: true,
  },
  {
    title: '创建时间',
    key: 'createTime',
    ellipsis: true,
  },
])
// 获取用户列表(纯Mock)
async function getUserList() {
  startLoading()
  try {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 500))
    // 生成Mock数据,总共假设1000条
    const totalItems = 1000
    const startIdx = (currentPage.value - 1) * pageSize.value
    const currentList = []
    const mockNames = ['杨佩芳', '曹敏侍', '郭贯妃', '丁汉臻', '朱政廷', '方一强', '李雅惠', '倪怡芳', '谢彦文']
    for (let i = 0; i < pageSize.value; i++) {
      const idx = startIdx + i + 1
      if (idx > totalItems)
        break
      const isNormal = idx % 2 !== 0 // 奇数正常,偶数冻结
      currentList.push({
        id: idx,
        userName: searchModel.value.userName || mockNames[i % mockNames.length],
        account: searchModel.value.account || `13${Math.floor(Math.random() * 1000000000).toString().padStart(9, '0')}`,
        status: Number(searchModel.value.status !== null && searchModel.value.status !== '' ? searchModel.value.status : (isNormal ? 1 : 0)),
        priorityDispatch: idx % 3 !== 0,
        creator: searchModel.value.userName || mockNames[i % mockNames.length],
        createTime: '2021/07/29 10:59',
      })
    }
    listData.value = currentList
    count.value = totalItems
    // 存入缓存
    listData.value.forEach((item) => {
      allLoadedData.value.set(item.id, item)
    })
  }
  catch (error) {
    console.error('获取用户列表失败', error)
  }
  finally {
    endLoading()
  }
}
// 搜索
function handleSearch() {
  currentPage.value = 1
  getUserList()
}
function resetSearchModel() {
  searchModel.value = {
    userName: '',
    account: '',
    status: null,
  }
}
// 重置
function handleReset() {
  resetSearchModel()
  currentPage.value = 1
  pageSize.value = 10
  getUserList()
}
// 取消
function handleCancel() {
  visible.value = false
}
// 确认
function handleConfirm() {
  if (checkedRowKeys.value.length === 0) {
    window.$message?.warning('请至少选择一个用户')
    return
  }
  // 从缓存中获取选中的完整对象
  const selectedUsers: UserItem[] = []
  checkedRowKeys.value.forEach((id) => {
    const item = allLoadedData.value.get(id)
    if (item) {
      selectedUsers.push(item)
    }
  })
  emit('confirm', selectedUsers)
  visible.value = false
}
// 分页改变
function changePage(page: number, size: number) {
  const isPageSizeChanged = size !== pageSize.value
  pageSize.value = size
  currentPage.value = isPageSizeChanged ? 1 : page
  getUserList()
}
// 监听弹窗打开
watch(visible, (val) => {
  if (val) {
    resetSearchModel()
    currentPage.value = 1
    pageSize.value = 10
    checkedRowKeys.value = props.existingUsers.map(v => v.id)
    // 清空并初始化 allLoadedData
    allLoadedData.value.clear()
    props.existingUsers.forEach((v) => {
      allLoadedData.value.set(v.id, v)
    })
    getUserList()
  }
  else {
    // 关闭时清空
    checkedRowKeys.value = []
    allLoadedData.value.clear()
  }
})
</script>
<template>
  <NModal v-model:show="visible">
    <NCard
      style="
        width: 1000px;
        height: 80vh;
        max-height: 800px;
        border-radius: 8px;
        display: flex;
        flex-direction: column;
      "
      :bordered="false"
      :segmented="{ content: true }"
      content-style="padding: 16px 24px; flex: 1; display: flex; flex-direction: column; overflow: hidden;"
      footer-style="padding: 16px 24px; border-top: 1px solid rgba(46, 61, 77, 0.1); flex-shrink: 0;"
    >
      <template #header>
        <span style="font-size: 16px; font-weight: bold; color: #1F1F1F;">选择用户</span>
      </template>
      <template #header-extra>
        <div text class="cursor-pointer" @click="handleCancel">
          <span style="font-size: 18px; width: 10px; height: 10px; color: #999;">×</span>
        </div>
      </template>
      <div class="flex flex-col h-full overflow-hidden">
        <!-- 搜索区域 -->
        <div class="mb-3 flex items-end gap-24px" style="flex-shrink: 0; flex-wrap: wrap;">
          <div class="w-300px">
            <div class="mb-2 text-sm text-[#1F1F1F]">
              用户名称
            </div>
            <NInput
              v-model:value="searchModel.userName"
              placeholder="请输入"
              clearable
            />
          </div>
          <div class="w-300px">
            <div class="mb-2 text-sm text-[#1F1F1F]">
              白名单账号
            </div>
            <NInput
              v-model:value="searchModel.account"
              placeholder="请输入"
              clearable
            />
          </div>
          <div class="w-300px">
            <div class="mb-2 text-sm text-[#1F1F1F]">
              状态
            </div>
            <NSelect
              v-model:value="searchModel.status"
              :options="statusOptions"
              placeholder="请搜索"
              clearable
            />
          </div>
          <div class="flex gap-8px ml-auto">
            <NButton style="width: 84px; height: 32px" @click="handleReset">
              重置
            </NButton>
            <NButton
              type="primary"
              style="width: 84px; height: 32px"
              @click="handleSearch"
            >
              查询
            </NButton>
          </div>
        </div>
        <!-- 表格区域 -->
        <div class="flex-1 overflow-hidden" style="min-height: 0">
          <NDataTable
            v-model:checked-row-keys="checkedRowKeys"
            style="height: 100%"
            flex-height
            :columns="columns"
            :data="listData"
            :loading="loading"
            :bordered="false"
            :row-key="(row: UserItem) => row.id"
            :pagination="false"
          >
            <template #empty>
              <div class="flex-center">
                <img
                  src="/src/assets/element-img/no-data.png"
                  style="width: 123px; height: 125px"
                >
              </div>
            </template>
          </NDataTable>
        </div>
        <Pagination
          :count="count"
          :page="currentPage"
          :page-size="pageSize"
          style="margin-top: 12px; flex-shrink: 0"
          @change="changePage"
        />
      </div>
      <template #footer>
        <div class="flex justify-end gap-3">
          <NButton class="footer-btn" @click="handleCancel">
            取消
          </NButton>
          <NButton type="primary" class="footer-btn" @click="handleConfirm">
            确认
          </NButton>
        </div>
      </template>
    </NCard>
  </NModal>
</template>
<style scoped lang="scss">
.mb-4 {
  margin-bottom: 1rem;
}
.flex {
  display: flex;
}
.items-end {
  align-items: flex-end;
}
.gap-4 {
  gap: 1rem;
}
.gap-2 {
  gap: 0.5rem;
}
.gap-3 {
  gap: 0.75rem;
}
.flex-1 {
  flex: 1 1 0%;
}
.justify-end {
  justify-content: flex-end;
}
.mb-2 {
  margin-bottom: 0.5rem;
}
.text-sm {
  font-size: 0.875rem;
  line-height: 1.25rem;
}
.footer-btn {
  padding: 7px 29px;
  height: auto;
  font-size: 13px;
  line-height: 18px;
}
</style>
src/views/transaction/directServiceManagement/components/VehicleSelectModal.vue
New file
@@ -0,0 +1,370 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { useBoolean } from '@/hooks'
import { computed, ref, watch } from 'vue'
import { NButton, NCard, NDataTable, NInput, NModal } from 'naive-ui'
import Pagination from '@/components/common/Pagination.vue'
interface VehicleItem {
  id: number
  vehicleCode: string
  plateNumber: string
  vehicleModel: string
  procurementSource?: string
  fleetName: string
  [key: string]: any
}
interface Props {
  existingVehicles?: VehicleItem[]
}
const props = withDefaults(defineProps<Props>(), {
  existingVehicles: () => [],
})
const emit = defineEmits<{
  confirm: [selectedVehicles: VehicleItem[]]
}>()
const visible = defineModel<boolean>('visible', { default: false })
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
// 搜索条件
const searchModel = ref({
  vehicleCode: '',
  plateNumber: '',
  fleetName: '',
})
// 选中的行
const checkedRowKeys = ref<number[]>([])
const listData = ref<VehicleItem[]>([])
// 缓存所有加载过的数据,以便在 handleConfirm 时能找到不在当前页但在 checkedRowKeys 中的项
const allLoadedData = ref<Map<number, VehicleItem>>(new Map())
const count = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
// 表格列定义
const columns = computed<DataTableColumns<VehicleItem>>(() => [
  {
    type: 'selection',
    width: 50,
  },
  {
    title: '序号',
    align: 'center',
    key: 'index',
    width: 80,
    render: (row, index) => <span>{(currentPage.value - 1) * pageSize.value + index + 1}</span>,
    resizable: true,
  },
  {
    title: '车辆编号',
    align: 'center',
    key: 'vehicleCode',
    ellipsis: true,
    resizable: true,
  },
  {
    title: '车牌号',
    align: 'center',
    key: 'plateNumber',
    ellipsis: true,
    resizable: true,
  },
  {
    title: '型号',
    align: 'center',
    key: 'vehicleModel',
    ellipsis: true,
    resizable: true,
  },
  {
    title: '采购来源',
    align: 'center',
    key: 'procurementSource',
    ellipsis: true,
    resizable: true,
  },
  {
    title: '当前车队',
    align: 'center',
    key: 'fleetName',
    ellipsis: true,
    render: row => <span>{row.fleetName || '-'}</span>,
  },
])
// 获取车辆列表(纯Mock)
async function getVehicleList() {
  startLoading()
  try {
    // 模拟网络延迟
    await new Promise(resolve => setTimeout(resolve, 500))
    // 生成Mock数据,总共假设50条
    const totalItems = 5
    const startIdx = (currentPage.value - 1) * pageSize.value
    const currentList = []
    for (let i = 0; i < pageSize.value; i++) {
      const idx = startIdx + i + 1
      if (idx > totalItems)
        break
      currentList.push({
        id: idx,
        vehicleCode: '073 D 204 77',
        plateNumber: '粤PXV7029',
        vehicleModel: 'Robotaxi',
        procurementSource: '自产',
        fleetName: '王美丽的车队',
      })
    }
    listData.value = currentList
    count.value = totalItems
    // 存入缓存
    listData.value.forEach((item) => {
      allLoadedData.value.set(item.id, item)
    })
  }
  catch (error) {
    console.error('获取车辆列表失败', error)
  }
  finally {
    endLoading()
  }
}
// 搜索
function handleSearch() {
  currentPage.value = 1
  getVehicleList()
}
function resetSearchModel() {
  searchModel.value = {
    vehicleCode: '',
    plateNumber: '',
    fleetName: '',
  }
}
// 重置
function handleReset() {
  resetSearchModel()
  currentPage.value = 1
  pageSize.value = 10
  getVehicleList()
}
// 取消
function handleCancel() {
  visible.value = false
}
// 确认
function handleConfirm() {
  if (checkedRowKeys.value.length === 0) {
    window.$message?.warning('请至少选择一辆车辆')
    return
  }
  // 从缓存中获取选中的完整对象
  const selectedVehicles: VehicleItem[] = []
  checkedRowKeys.value.forEach((id) => {
    const item = allLoadedData.value.get(id)
    if (item) {
      selectedVehicles.push(item)
    }
  })
  emit('confirm', selectedVehicles)
  visible.value = false
}
// 分页改变
function changePage(page: number, size: number) {
  const isPageSizeChanged = size !== pageSize.value
  pageSize.value = size
  currentPage.value = isPageSizeChanged ? 1 : page
  getVehicleList()
}
// 监听弹窗打开
watch(visible, (val) => {
  if (val) {
    resetSearchModel()
    currentPage.value = 1
    pageSize.value = 10
    checkedRowKeys.value = props.existingVehicles.map(v => v.id)
    // 清空并初始化 allLoadedData
    allLoadedData.value.clear()
    props.existingVehicles.forEach((v) => {
      allLoadedData.value.set(v.id, v)
    })
    getVehicleList()
  }
  else {
    // 关闭时清空
    checkedRowKeys.value = []
    allLoadedData.value.clear()
  }
})
</script>
<template>
  <NModal v-model:show="visible">
    <NCard
      style="
        width: 1000px;
        height: 80vh;
        max-height: 800px;
        border-radius: 8px;
        display: flex;
        flex-direction: column;
      "
      :bordered="false"
      :segmented="{ content: true }"
      content-style="padding: 16px 24px; flex: 1; display: flex; flex-direction: column; overflow: hidden;"
      footer-style="padding: 16px 24px; border-top: 1px solid rgba(46, 61, 77, 0.1); flex-shrink: 0;"
    >
      <template #header>
        <span style="font-size: 16px; font-weight: bold">添加车辆</span>
      </template>
      <template #header-extra>
        <div text class="cursor-pointer" @click="handleCancel">
          <span style="font-size: 18px; width: 10px; height: 10px">×</span>
        </div>
      </template>
      <div class="flex flex-col h-full overflow-hidden">
        <!-- 搜索区域 -->
        <div class="mb-3 flex items-end gap-24px" style="flex-shrink: 0">
          <div class="w-300px">
            <div class="mb-2 text-sm text-[#1F1F1F]">
              车牌号
            </div>
            <NInput
              v-model:value="searchModel.plateNumber"
              placeholder="请输入"
              clearable
            />
          </div>
          <div class="w-300px">
            <div class="mb-2 text-sm text-[#1F1F1F]">
              车队
            </div>
            <NInput
              v-model:value="searchModel.fleetName"
              placeholder="请输入"
              clearable
            />
          </div>
          <div class="flex ml-auto gap-8px">
            <NButton style="width: 84px; height: 32px" @click="handleReset">
              重置
            </NButton>
            <NButton
              type="primary"
              style="width: 84px; height: 32px"
              @click="handleSearch"
            >
              查询
            </NButton>
          </div>
        </div>
        <!-- 表格区域 -->
        <div class="flex-1 overflow-hidden" style="min-height: 0">
          <NDataTable
            v-model:checked-row-keys="checkedRowKeys"
            style="height: 100%"
            flex-height
            :columns="columns"
            :data="listData"
            :loading="loading"
            :bordered="false"
            :row-key="(row: VehicleItem) => row.id"
            :pagination="false"
          >
            <template #empty>
              <div class="flex-center">
                <img
                  src="/src/assets/element-img/no-data.png"
                  style="width: 123px; height: 125px"
                >
              </div>
            </template>
          </NDataTable>
        </div>
        <Pagination
          :count="count"
          :page="currentPage"
          :page-size="pageSize"
          style="margin-top: 12px; flex-shrink: 0"
          @change="changePage"
        />
      </div>
      <template #footer>
        <div class="flex justify-end gap-3">
          <NButton class="footer-btn" @click="handleCancel">
            取消
          </NButton>
          <NButton type="primary" class="footer-btn" @click="handleConfirm">
            保存
          </NButton>
        </div>
      </template>
    </NCard>
  </NModal>
</template>
<style scoped lang="scss">
.mb-4 {
  margin-bottom: 1rem;
}
.flex {
  display: flex;
}
.items-end {
  align-items: flex-end;
}
.gap-4 {
  gap: 1rem;
}
.gap-2 {
  gap: 0.5rem;
}
.gap-3 {
  gap: 0.75rem;
}
.flex-1 {
  flex: 1 1 0%;
}
.justify-end {
  justify-content: flex-end;
}
.mb-2 {
  margin-bottom: 0.5rem;
}
.text-sm {
  font-size: 0.875rem;
  line-height: 1.25rem;
}
.footer-btn {
  padding: 7px 29px;
  height: auto;
  font-size: 13px;
  line-height: 18px;
}
</style>
src/views/transaction/directServiceManagement/detail.vue
New file
@@ -0,0 +1,300 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { NButton, NCascader, NCheckbox, NDataTable, NDatePicker, NForm, NFormItem, NInput, NRadio, NRadioGroup, NSpace, NSpin, NTag } from 'naive-ui'
import { getRegionCascaderNoArea } from '@/service/api/operations'
const router = useRouter()
const route = useRoute()
const id = ref<string | null>(null)
const loading = ref(false)
const formModel = ref({
  cityCode: '110000', // Mock data
  isPermanent: true,
  startTime: 1770454122000,
  endTime: 1770454136000,
  status: 1, // 1 启用, 0 禁用
  remark: '备注内容备注内容',
})
const pcaOptions = ref<any[]>([])
function formatCascaderData(list: any[]) {
  return list.map((item) => {
    const newItem = { ...item }
    if (newItem.children && newItem.children.length > 0) {
      newItem.children = formatCascaderData(newItem.children)
    }
    else {
      delete newItem.children
    }
    return newItem
  })
}
async function fetchCascaderData() {
  const res = await getRegionCascaderNoArea()
  if (res.code === 200 && res.data) {
    pcaOptions.value = formatCascaderData(res.data)
  }
}
async function fetchDetail() {
  if (!id.value)
    return
  loading.value = true
  try {
    // Mock data fetch
    setTimeout(() => {
      loading.value = false
    }, 500)
  }
  finally {
    // loading.value = false
  }
}
// 关联车辆 Mock 数据和表格配置
const selectedVehicles = ref<any[]>([
  {
    id: 1,
    vehicleCode: 'S156165161651',
    plateNo: '粤PXV7029',
    model: 'Robotaxi',
    fleet: '白云区1号',
    cruisePlan: '广州巡游计划1111',
  },
  {
    id: 2,
    vehicleCode: 'S156165161651',
    plateNo: '粤PXV7029',
    model: 'Robotaxi',
    fleet: '白云区1号',
    cruisePlan: '-',
  },
])
const vehicleColumns = computed<DataTableColumns<any>>(() => [
  { title: '序号', key: 'index', width: 60, render: (_, index) => index + 1 },
  { title: '车辆编码', key: 'vehicleCode' },
  { title: '车牌号', key: 'plateNo' },
  { title: '型号', key: 'model' },
  { title: '所属车队', key: 'fleet' },
  { title: '关联巡游计划', key: 'cruisePlan' },
])
// 服务用户 Mock 数据和表格配置
const selectedUsers = ref<any[]>([
  { id: 1, userName: '方一强', account: '13034179070', status: 1, priority: 100, desc: '优先级说明内容说明内容说明内容' },
  { id: 2, userName: '李雅惠', account: '13619910302', status: 0, priority: 80, desc: '优先级说明内容说明内容说明内容' },
  { id: 3, userName: '倪怡芳', account: '15392830508', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 4, userName: '-', account: '13818629999', status: 0, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 5, userName: '-', account: '13654373011', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 6, userName: '谢彦文', account: '13396827528', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
  { id: 7, userName: '-', account: '18878751881', status: 1, priority: 50, desc: '优先级说明内容说明内容说明内容' },
])
const userColumns = computed<DataTableColumns<any>>(() => [
  { title: '序号', key: 'index', width: 60, render: (_, index) => index + 1 },
  { title: '用户名称', key: 'userName' },
  { title: '白名单账号', key: 'account' },
  {
    title: '状态',
    key: 'status',
    render: (row) => {
      const isNormal = row.status === 1
      return (
        <NTag
          round
          bordered={false}
          style={{
            borderRadius: '4px',
            color: isNormal ? '#0068FF' : '#FF8C00',
            backgroundColor: isNormal ? 'rgba(0, 104, 255, 0.1)' : 'rgba(255, 140, 0, 0.1)',
            padding: '0 8px',
          }}
        >
          {isNormal ? '正常' : '冻结'}
        </NTag>
      )
    },
  },
  { title: '用户优先级', key: 'priority' },
  { title: '优先级说明', key: 'desc' },
])
function handleEdit() {
  router.push({
    name: 'directServiceManagementEdit',
    query: { id: id.value },
  })
}
onMounted(() => {
  fetchCascaderData()
  if (route.query.id) {
    id.value = route.query.id as string
    fetchDetail()
  }
})
</script>
<template>
  <div class="w-full h-full relative content flex flex-col">
    <div class="p-12px main flex flex-col flex-1 overflow-y-auto">
      <div class="box mb-12px shrink-0">
        <div class="text-xl">
          定向服务详情
        </div>
      </div>
      <!-- 基础信息卡片 -->
      <div class="box shrink-0 mb-12px">
        <div class="head shrink-0">
          基础信息
        </div>
        <NSpin :show="loading">
          <NForm :model="formModel" inline label-placement="left" class="custom-form" disabled>
            <NGrid :cols="24" :x-gap="24" :y-gap="8">
              <n-form-item-gi :span="6" label="城市" path="cityCode" required>
                <NCascader
                  v-model:value="formModel.cityCode" :options="pcaOptions" value-field="code"
                  label-field="name" check-strategy="child" clearable placeholder="请选择"
                >
                  <template #arrow>
                    <img
                      src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
                      class="arrow-icon" style="width: 16px; height: 16px" alt="arrow"
                    >
                  </template>
                </NCascader>
              </n-form-item-gi>
              <n-form-item-gi :span="12" label="生效时间段" path="startTime" required>
                <div class="flex items-center gap-2">
                  <NDatePicker
                    v-model:value="formModel.startTime" type="datetime" clearable
                    placeholder="请选择" style="width: 200px"
                  />
                  <span class="text-gray-400">至</span>
                  <NDatePicker
                    v-model:value="formModel.endTime" type="datetime" clearable
                    placeholder="请选择" style="width: 200px"
                  />
                  <NCheckbox v-model:checked="formModel.isPermanent" class="shrink-0 ml-2">
                    长期有效
                  </NCheckbox>
                </div>
              </n-form-item-gi>
              <n-form-item-gi :span="4" label="状态" path="status" required>
                <NRadioGroup v-model:value="formModel.status">
                  <NSpace>
                    <NRadio :value="1">
                      启用
                    </NRadio>
                    <NRadio :value="0">
                      禁用
                    </NRadio>
                  </NSpace>
                </NRadioGroup>
              </n-form-item-gi>
              <n-form-item-gi :span="24" label="备注" path="remark" label-placement="top" class="w-full">
                <NInput
                  v-model:value="formModel.remark" type="textarea" placeholder="请输入备注内容" show-count :maxlength="200"
                  class="bg-gray-50/50 w-full"
                />
              </n-form-item-gi>
            </NGrid>
          </NForm>
        </NSpin>
      </div>
      <!-- 关联车辆卡片 -->
      <div class="box shrink-0 mb-12px">
        <div class="head shrink-0">
          关联车辆
        </div>
        <NDataTable
          :columns="vehicleColumns"
          :data="selectedVehicles"
          :bordered="false"
          :single-line="false"
        />
      </div>
      <!-- 服务用户卡片 -->
      <div class="box shrink-0">
        <div class="head shrink-0">
          服务用户
        </div>
        <NDataTable
          :columns="userColumns"
          :data="selectedUsers"
          :bordered="false"
          :single-line="false"
        />
      </div>
    </div>
    <!-- 底部操作栏 -->
    <div class="footer flex justify-end items-center shrink-0">
      <NSpace>
        <NButton type="primary" class="w-84px" @click="handleEdit">
          编辑
        </NButton>
      </NSpace>
    </div>
  </div>
</template>
<style scoped lang="scss">
.box {
  padding: 24px;
  background-color: #fff;
  border-radius: 6px;
  width: 100%;
  box-sizing: border-box;
  --n-label-text-color: rgba(23, 31, 38, 0.8);
}
.head {
  font-family: PingFang SC;
  font-size: 16px;
  font-weight: 600;
  line-height: 24px;
  letter-spacing: normal;
  color: #171f26;
  position: relative;
  text-indent: 11px;
  margin-bottom: 16px;
  &::before {
    content: "";
    position: absolute;
    top: 50%;
    left: 0;
    transform: translateY(-50%);
    width: 3px;
    height: 14px;
    z-index: 0;
    border-radius: 3px;
    background: #0068ff;
  }
}
.footer {
  position: sticky;
  bottom: 0;
  left: 0;
  width: 100%;
  height: 64px;
  background-color: #fff;
  box-shadow: 0px 0px 5px 0px rgba(23, 31, 38, 0.05);
  padding: 16px 24px;
}
</style>
src/views/transaction/directServiceManagement/index.vue
New file
@@ -0,0 +1,406 @@
<script setup lang="tsx">
import type { DataTableColumns } from 'naive-ui'
import { computed, onMounted, ref } from 'vue'
import { useBoolean } from '@/hooks'
import { deleteDirectService, getDirectServiceList, toggleDirectServiceStatus } from '@/service/api/directService'
import { getRegionCascaderNoArea } from '@/service/api/operations'
import { NButton, NSwitch, NTag, useDialog, useMessage } from 'naive-ui'
import SearchPanel from '@/components/custom/SearchPanel.vue'
import dayjs from 'dayjs'
import { useRouter } from 'vue-router'
import Pagination from '@/components/common/Pagination.vue'
const { bool: loading, setTrue: startLoading, setFalse: endLoading } = useBoolean(false)
const message = useMessage()
const router = useRouter()
const dialog = useDialog()
const initialModel = {
  cityCode: null,
}
const model = ref({ ...initialModel })
const pageParams = ref({
  page: 1,
  size: 10,
})
function handleResetSearch() {
  model.value = { ...initialModel }
  handleSearch()
}
function handleSearch() {
  pageParams.value.page = 1
  getList()
}
const count = ref(0)
const listData = ref<any[]>([])
const isCityCodeIdOpen = ref(false)
const pcaOptions = ref<any[]>([])
// 获取城市数据
function formatCascaderData(list: any[]) {
  return list.map((item) => {
    const newItem = { ...item }
    if (newItem.children && newItem.children.length > 0) {
      newItem.children = formatCascaderData(newItem.children)
    }
    else {
      delete newItem.children
    }
    return newItem
  })
}
async function fetchCascaderData() {
  const res = await getRegionCascaderNoArea()
  if (res.code === 200 && res.data) {
    pcaOptions.value = formatCascaderData(res.data)
  }
}
// 搜索项
const filterItems = computed(() => [
  {
    label: '城市',
    path: 'cityCode',
    render: () => (
      <n-cascader
        v-model:value={model.value.cityCode}
        options={pcaOptions.value}
        onUpdate:show={(val: any) => (isCityCodeIdOpen.value = val)}
        value-field="code"
        label-field="name"
        check-strategy="child"
        clearable
        placeholder="请选择"
      >
        {{
          arrow: () => (
            <img
              src="https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/e120f81c-9f15-4e1a-9938-22252898edf9.png"
              class={`arrow-icon ${isCityCodeIdOpen.value ? 'arrow-icon--open' : ''}`}
              style={{ width: '16px', height: '16px' }}
              alt="arrow"
            />
          ),
        }}
      </n-cascader>
    ),
  },
])
// 获取列表数据
async function getList() {
  startLoading()
  // Mock data simulation
  setTimeout(() => {
    const mockData = Array.from({ length: 10 }).map((_, index) => {
      const isPermanent = index % 3 === 0
      const isEnabled = index % 4 !== 0
      // City generation based on selection or random
      let cityName = '成都市金牛区'
      if (model.value.cityCode) {
        // Simple mock to just show the selected code if it exists
        cityName = `已选城市(${model.value.cityCode})`
      }
      else if (index > 7) {
        cityName = '广州市'
      }
      return {
        id: `mock-id-${pageParams.value.page}-${index}`,
        cityName,
        isPermanent,
        startTime: isPermanent ? null : '2026-03-20 09:00:00',
        endTime: isPermanent ? null : '2026-03-20 16:00:00',
        boundVehicleCount: 5,
        boundUserCount: 5,
        status: isEnabled ? 1 : 0,
        remark: '备注内容备注内容备注内容备注内容',
        addBy: 'admin',
        addTime: '2026-03-20 16:42:23',
      }
    })
    count.value = 1000 // Mock total count
    listData.value = mockData
    endLoading()
  }, 500)
}
// 分页变化
function changePage(page: number, size: number) {
  pageParams.value.page = page
  pageParams.value.size = size
  getList()
}
// 表格列定义
const columns = computed<DataTableColumns<any>>(() => [
  {
    title: '序号',
    key: 'index',
    width: 60,
    render: (_, index) => {
      return (pageParams.value.page - 1) * pageParams.value.size + index + 1
    },
  },
  {
    title: '城市',
    key: 'cityName',
    width: 120,
    resizable: true,
  },
  {
    title: '生效时间段',
    key: 'effectiveTime',
    width: 245,
    resizable: true,
    render: (row) => {
      if (row.isPermanent)
        return '永久有效'
      const start = row.startTime ? dayjs(row.startTime).format('YYYY/MM/DD HH:mm') : ''
      const end = row.endTime ? dayjs(row.endTime).format('YYYY/MM/DD HH:mm') : ''
      if (start && end)
        return `${start} ~ ${end}`
      return '-'
    },
  },
  {
    title: '绑定车辆数',
    key: 'boundVehicleCount',
    width: 100,
    resizable: true,
  },
  {
    title: '绑定用户数',
    key: 'boundUserCount',
    width: 100,
    resizable: true,
  },
  {
    title: '状态',
    key: 'status',
    width: 80,
    resizable: true,
    render: (row) => {
      const isEnabled = row.status === 1 || row.status === '1' || row.status === true
      const label = isEnabled ? '启用' : '禁用'
      return (
        <NTag
          round
          bordered={false}
          style={{
            borderRadius: '4px',
            border: 'none',
            color: isEnabled ? '#0BB52D' : '#666666',
            backgroundColor: isEnabled ? '#DCF5DD' : 'rgba(23, 31, 38, 0.05)',
          }}
        >
          {label}
        </NTag>
      )
    },
  },
  {
    title: '备注',
    key: 'remark',
    width: 150,
    ellipsis: {
      tooltip: true,
    },
    resizable: true,
  },
  {
    title: '添加时间',
    key: 'addTime',
    width: 160,
    resizable: true,
    render: row => row.addTime ? dayjs(row.addTime).format('YYYY/MM/DD HH:mm') : '',
  },
  {
    title: '操作',
    key: 'actions',
    width: 190,
    fixed: 'right',
    render: (row) => {
      const isEnabled = row.status === 1 || row.status === '1' || row.status === true
      return (
        <div class="flex items-center justify-start gap-2">
          <NButton
            text
            v-permission="60701"
            type="primary"
            onClick={() => handleDetail(row)}
          >
            详情
          </NButton>
          <NButton
            text
            v-permission="60702"
            type="primary"
            onClick={() => handleEdit(row)}
          >
            编辑
          </NButton>
          <NButton
            text
            v-permission="60703"
            type="primary"
            onClick={() => handleDelete(row)}
          >
            删除
          </NButton>
          <NSwitch
            v-permission="60704"
            value={isEnabled}
            onUpdateValue={val => handleToggleStatus(row, val)}
          >
            {{
              checked: () => '启用',
              unchecked: () => '禁用',
            }}
          </NSwitch>
        </div>
      )
    },
  },
])
// 详情
function handleDetail(row: any) {
  router.push({
    name: 'directServiceManagementDetail',
    query: { id: row.id },
  })
}
// 新增/编辑
function handleAdd() {
  router.push({ name: 'directServiceManagementAdd' })
}
function handleEdit(row: any) {
  router.push({
    name: 'directServiceManagementEdit',
    query: { id: row.id },
  })
}
// 删除
function handleDelete(row: any) {
  dialog.create({
    title: '删除确认',
    content: '确定要删除该定向服务吗?',
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: () => {
      deleteDirectService(row.id).then((res) => {
        if (res.code === 200) {
          message.success('删除成功')
          getList()
        }
      })
    },
  })
}
// 启用/禁用
function handleToggleStatus(row: any, targetStatus: boolean) {
  const statusValue = targetStatus ? 1 : 0
  const actionText = targetStatus ? '启用' : '禁用'
  dialog.create({
    title: `${actionText}确认`,
    content: `确定要${actionText}该定向服务吗?`,
    positiveText: '确定',
    negativeText: '取消',
    onPositiveClick: () => {
      toggleDirectServiceStatus(row.id, statusValue).then((res) => {
        if (res.code === 200) {
          message.success(`${actionText}成功`)
          getList()
        }
      })
    },
  })
}
onMounted(() => {
  fetchCascaderData()
  getList()
})
</script>
<template>
  <div class="roboTaxi w-full h-full">
    <div class="roboTaxi-form-card mb-12px">
      <n-form :model="model" label-placement="left" inline :show-feedback="false">
        <SearchPanel>
          <n-gi v-for="item in filterItems" :key="item.path" class="n-grid-item">
            <n-form-item :label="item.label" :path="item.path">
              <component :is="item.render" />
            </n-form-item>
          </n-gi>
          <template #action>
            <NButton @click="handleResetSearch">
              重置
            </NButton>
            <NButton type="primary" @click="handleSearch">
              查询
            </NButton>
          </template>
        </SearchPanel>
      </n-form>
    </div>
    <n-card class="flex-1 robotaxi-table-card">
      <template #header>
        <n-flex justify="flex-end">
          <NButton
            v-permission="60702"
            secondary
            type="primary"
            @click="handleAdd"
          >
            新增
          </NButton>
        </n-flex>
      </template>
      <div class="flex-1 flex flex-col h-full">
        <n-data-table
          class="flex-1"
          :scroll-x="1180"
          :columns="columns"
          :data="listData"
          :loading="loading"
          flex-height
          :bordered="false"
          :header-height="40"
          :row-height="50"
        >
          <template #empty>
            <div class="flex-center" style="justify-content: center;">
              <img
                src="/src/assets/element-img/no-data.png"
                style="width: 123px; height: 125px"
                @error="(e: any) => { e.target.src = 'https://huishou-1323682843.cos.ap-nanjing.myqcloud.com/images/9bed493b-a6aa-4ef6-ac10-7f67f5c6adc5.png' }"
              >
            </div>
          </template>
        </n-data-table>
        <Pagination
          :count="count"
          :page="pageParams.page"
          :page-size="pageParams.size"
          class="robotaxi-pagination mt-12px"
          @change="changePage"
        />
      </div>
    </n-card>
  </div>
</template>
src/views/transaction/order/orderManagement/index.vue
@@ -31,14 +31,16 @@
  model.value = { ...initialModel }
  handleSearch()
}
function handleSearch() {
  pageParams.value.pageNum = 1
  getList()
}
const pageParams = ref({
  pageNum: 1,
  pageSize: 10,
})
function handleSearch() {
  pageParams.value.pageNum = 1
  getList()
}
const isFleetIdOpen = ref(false)
const isCityCodeIdOpen = ref(false)
const isStatusOpen = ref(false)
@@ -78,6 +80,65 @@
    return 220
  return 260
})
// 订单状态
const statusOptions = [
  {
    label: '待接单',
    value: 1,
    type: 'primary',
  },
  {
    label: '待出发',
    value: 2,
    type: 'success',
  },
  {
    label: '待到达预约地点',
    value: 3,
    type: 'warning',
  },
  {
    label: '待乘客上车',
    value: 4,
    type: 'primary',
  },
  {
    label: '乘客已上车',
    value: 5,
    type: 'success',
  },
  {
    label: '服务中',
    value: 6,
    type: 'success',
  },
  {
    label: '待客下车',
    value: 7,
    type: 'warning',
  },
  {
    label: '待支付',
    value: 8,
    type: 'warning',
  },
  {
    label: '待评价',
    value: 9,
    type: 'default',
  },
  {
    label: '已完成',
    value: 10,
    type: 'success',
  },
  {
    label: '已取消',
    value: 0,
    type: 'default',
  },
]
const columns = computed<DataTableColumns<any>>(() => [
  {
@@ -332,64 +393,7 @@
    value: 1,
  },
]
// 订单状态
const statusOptions = [
  {
    label: '待接单',
    value: 1,
    type: 'primary',
  },
  {
    label: '待出发',
    value: 2,
    type: 'success',
  },
  {
    label: '待到达预约地点',
    value: 3,
    type: 'warning',
  },
  {
    label: '待乘客上车',
    value: 4,
    type: 'primary',
  },
  {
    label: '乘客已上车',
    value: 5,
    type: 'success',
  },
  {
    label: '服务中',
    value: 6,
    type: 'success',
  },
  {
    label: '待客下车',
    value: 7,
    type: 'warning',
  },
  {
    label: '待支付',
    value: 8,
    type: 'warning',
  },
  {
    label: '待评价',
    value: 9,
    type: 'default',
  },
  {
    label: '已完成',
    value: 10,
    type: 'success',
  },
  {
    label: '已取消',
    value: 0,
    type: 'default',
  },
]
const pcaOptions = ref<any[]>([])
// 搜索项
const filterItems = computed(() => [
src/views/transaction/pricingManagement/components/TableModal.vue
@@ -275,7 +275,7 @@
                src="/src/assets/element-img/no-data.png"
                style="width: 50%"
                alt="no-data"
              />
              >
            </div>
          </template>
        </n-select>
@@ -294,7 +294,7 @@
      </n-form-item>
    </n-form>
    <template #action>
      <n-space justify="center">
      <n-space justify="end">
        <n-button @click="closeModal">
          取消
        </n-button>