Quellcode durchsuchen

feat:修改创建订单页面样式

颜琼丽 vor 1 Woche
Ursprung
Commit
20ca6e6fd2

+ 1 - 0
jd-logistics-ui-v3/package.json

@@ -26,6 +26,7 @@
     "jsencrypt": "3.3.2",
     "nprogress": "0.2.0",
     "pinia": "3.0.2",
+    "province-city-china": "^8.5.8",
     "splitpanes": "4.0.4",
     "vue": "3.5.16",
     "vue-cropper": "1.1.1",

+ 8 - 0
jd-logistics-ui-v3/src/App.vue

@@ -13,3 +13,11 @@ onMounted(() => {
   })
 })
 </script>
+
+<style>
+:root {
+  --el-color-primary: #2D71FF;
+  --el-color-primary-light-3: #46a6ff; /* 可选,调整不同亮度 */
+  --el-color-primary-dark-2: #0e8afd; /* 可选 */
+}
+</style>

+ 199 - 0
jd-logistics-ui-v3/src/components/RegionCascader.vue

@@ -0,0 +1,199 @@
+<!-- @/components/RegionCascader/index.vue -->
+<template>
+  <el-cascader
+      v-model="selectedCodes"
+      :options="areaOptions"
+      :props="cascaderProps"
+      :placeholder="placeholder"
+      :clearable="clearable"
+      :disabled="disabled"
+      :filterable="filterable"
+      :show-all-levels="showAllLevels"
+      :separator="separator"
+      :size="size"
+      style="width: 100%"
+      @change="handleChange"
+  />
+</template>
+
+<script setup name="RegionCascader">
+import { ref, computed, defineProps, defineEmits, watch, onMounted } from 'vue'
+// 导入本地 JSON 数据
+import { areaData } from '@/utils/area-data.js'
+
+const props = defineProps({
+  modelValue: {
+    type: Array,
+    default: () => []
+  },
+  placeholder: {
+    type: String,
+    default: '请选择省市区'
+  },
+  clearable: {
+    type: Boolean,
+    default: true
+  },
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  filterable: {
+    type: Boolean,
+    default: false
+  },
+  showAllLevels: {
+    type: Boolean,
+    default: true
+  },
+  separator: {
+    type: String,
+    default: '/'
+  },
+  size: {
+    type: String,
+    default: 'default',
+    validator: (value) => ['large', 'default', 'small'].includes(value)
+  }
+})
+
+const emit = defineEmits(['update:modelValue', 'change'])
+
+// 选中的地区代码
+const selectedCodes = ref([])
+
+// 级联选择器配置
+const cascaderProps = {
+  expandTrigger: 'hover',
+  checkStrictly: false,
+  emitPath: true,
+  lazy: false,
+  value: 'code',
+  label: 'name',
+  children: 'children'
+}
+
+// 根据 code 获取地区信息
+const getAreaByCode = (code) => {
+  return areaData.find(item => item.code === code)
+}
+
+// 根据 parent_code 获取子级地区
+const getChildrenByParentCode = (parentCode) => {
+  return areaData.filter(item => item.parent_code === parentCode)
+}
+
+// 处理地区数据,构建树形结构
+const areaOptions = computed(() => {
+  try {
+    // 获取所有省份 (type === 0)
+    const provinces = areaData.filter(item => item.type === 0)
+
+    // 为每个省份添加子级(城市)
+    const treeData = provinces.map(province => {
+      // 获取该省份下的城市
+      const cities = areaData.filter(item =>
+          item.parent_code === province.code && item.type === 1
+      )
+
+      // 为每个城市添加子级(区县)
+      const cityChildren = cities.map(city => {
+        // 获取该城市下的区县
+        const districts = areaData.filter(item =>
+            item.parent_code === city.code && item.type === 2
+        )
+
+        return {
+          code: city.code,
+          name: city.name,
+          children: districts.map(district => ({
+            code: district.code,
+            name: district.name
+          }))
+        }
+      })
+
+      return {
+        code: province.code,
+        name: province.name,
+        children: cityChildren
+      }
+    })
+
+    console.log('地区数据构建完成,省份数量:', treeData.length)
+    return treeData
+  } catch (error) {
+    console.error('构建地区数据失败:', error)
+    return []
+  }
+})
+
+// 根据code获取地区名称
+const getAreaNameByCode = (code) => {
+  if (!code) return ''
+  const area = getAreaByCode(code)
+  return area ? area.name : ''
+}
+
+// 处理变化事件
+const handleChange = (codes) => {
+  selectedCodes.value = codes
+
+  // 构建完整的地区信息
+  const areaInfo = buildAreaInfo(codes)
+
+  emit('update:modelValue', codes)
+  emit('change', areaInfo)
+}
+
+// 构建地区信息对象
+const buildAreaInfo = (codes) => {
+  if (!codes || codes.length === 0) {
+    return {
+      provinceCode: '',
+      cityCode: '',
+      districtCode: '',
+      provinceName: '',
+      cityName: '',
+      districtName: '',
+      fullName: '',
+      fullAddress: ''
+    }
+  }
+
+  const provinceCode = codes[0] || ''
+  const cityCode = codes[1] || ''
+  const districtCode = codes[2] || ''
+
+  const provinceName = provinceCode ? getAreaNameByCode(provinceCode) : ''
+  const cityName = cityCode ? getAreaNameByCode(cityCode) : ''
+  const districtName = districtCode ? getAreaNameByCode(districtCode) : ''
+
+  const names = [provinceName, cityName, districtName].filter(Boolean)
+
+  return {
+    provinceCode,
+    cityCode,
+    districtCode,
+    provinceName,
+    cityName,
+    districtName,
+    codes: [...codes],
+    names,
+    fullName: names.join(props.separator),
+    fullAddress: names.join('')
+  }
+}
+
+// 监听外部值变化
+watch(() => props.modelValue, (newVal) => {
+  if (JSON.stringify(selectedCodes.value) !== JSON.stringify(newVal || [])) {
+    selectedCodes.value = newVal || []
+  }
+}, { immediate: true, deep: true })
+
+// 组件挂载时初始化
+onMounted(() => {
+  console.log('RegionCascader组件已加载,省份数量:', areaOptions.value.length)
+})
+</script>

Datei-Diff unterdrückt, da er zu groß ist
+ 3766 - 0
jd-logistics-ui-v3/src/utils/area-data.js


+ 367 - 0
jd-logistics-ui-v3/src/views/logistics/order/components/AddressBookDialog.vue

@@ -0,0 +1,367 @@
+<template>
+  <el-dialog
+      v-model="dialogVisible"
+      :title="title"
+      width="900px"
+      class="address-book-dialog"
+      @close="handleClose"
+  >
+    <div class="address-list">
+      <!-- 两列布局,每行显示两个地址 -->
+      <div
+          v-for="(addressRow, rowIndex) in addressRows"
+          :key="rowIndex"
+          class="address-row"
+      >
+        <div
+            v-for="address in addressRow"
+            :key="address?.id || rowIndex"
+            class="address-item"
+            :class="{ 'active': selectedAddressId === address?.id, 'empty': !address }"
+            @click="address && handleSelectAddress(address)"
+        >
+          <div v-if="address" class="address-content">
+            <div class="address-header">
+              <span class="address-name">{{ address.name }}</span>
+              <span class="address-phone">{{ address.phone }}</span>
+<!--              <div class="address-tags">-->
+<!--                <span class="tag default-tag" v-if="address.isDefault">默认</span>-->
+<!--                <span class="tag home-tag" v-if="address.tag === 'home'">家庭</span>-->
+<!--                <span class="tag company-tag" v-if="address.tag === 'company'">公司</span>-->
+<!--              </div>-->
+            </div>
+            <div class="address-body">
+              <div class="address-detail">{{ address.fullAddress }}</div>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+
+    <!-- 分页器 -->
+    <div class="pagination-wrapper">
+      <el-pagination
+          v-model:current-page="currentPage"
+          v-model:page-size="pageSize"
+          :total="totalAddresses"
+          layout="prev, pager, next, jumper"
+          background
+          @current-change="handlePageChange"
+      />
+    </div>
+
+    <template #footer>
+      <span class="dialog-footer">
+        <el-button @click="handleCancel">取消</el-button>
+        <el-button type="primary" @click="handleConfirm">确认选择</el-button>
+      </span>
+    </template>
+  </el-dialog>
+</template>
+
+<script setup>
+import { ref, computed, watch, defineProps, defineEmits } from 'vue'
+
+// 定义组件属性
+const props = defineProps({
+  // 是否显示弹窗
+  visible: {
+    type: Boolean,
+    required: true,
+    default: false
+  },
+  // 地址簿类型:sender 或 receiver
+  addressBookType: {
+    type: String,
+    required: true,
+    validator: (value) => ['sender', 'receiver'].includes(value)
+  },
+  // 地址列表数据
+  addressList: {
+    type: Array,
+    required: true,
+    default: () => []
+  },
+  // 总地址数
+  totalAddresses: {
+    type: Number,
+    default: 0
+  },
+  // 当前页码
+  initialCurrentPage: {
+    type: Number,
+    default: 1
+  },
+  // 每页显示数量
+  initialPageSize: {
+    type: Number,
+    default: 8
+  }
+})
+
+// 定义组件事件
+const emit = defineEmits([
+  'update:visible',
+  'select-address',
+  'close',
+  'cancel',
+  'confirm',
+  'page-change'
+])
+
+// 弹窗显示状态
+const dialogVisible = computed({
+  get() {
+    return props.visible
+  },
+  set(value) {
+    emit('update:visible', value)
+  }
+})
+
+// 标题
+const title = computed(() => {
+  return `选择${props.addressBookType === 'sender' ? '寄件人' : '收件人'}地址`
+})
+
+// 当前页码
+const currentPage = ref(props.initialCurrentPage)
+
+// 每页显示数量
+const pageSize = ref(props.initialPageSize)
+
+// 选中的地址ID
+const selectedAddressId = ref(null)
+
+// 选中的地址
+const selectedAddress = ref(null)
+
+// 监听visible变化
+watch(() => props.visible, (newVal) => {
+  if (newVal) {
+    // 重置选择状态
+    selectedAddressId.value = null
+    selectedAddress.value = null
+    currentPage.value = props.initialCurrentPage
+  }
+})
+
+// 监听页码变化
+watch(() => props.initialCurrentPage, (newVal) => {
+  currentPage.value = newVal
+})
+
+// 计算属性:将地址数据分组为每行2个
+const addressRows = computed(() => {
+  const rows = []
+  const start = (currentPage.value - 1) * pageSize.value
+  const end = start + pageSize.value
+  const currentPageAddresses = props.addressList.slice(start, end)
+
+  // 将地址分组为每行2个
+  for (let i = 0; i < currentPageAddresses.length; i += 2) {
+    const row = currentPageAddresses.slice(i, i + 2)
+    // 如果这行只有1个地址,补一个空位保持布局
+    if (row.length < 2) {
+      row.push(null)
+    }
+    rows.push(row)
+  }
+
+  // 如果当前页没有地址,添加空行
+  if (rows.length === 0) {
+    rows.push([null, null])
+  }
+
+  return rows
+})
+
+// 处理选择地址
+const handleSelectAddress = (address) => {
+  if (!address) return
+
+  selectedAddressId.value = address.id
+  selectedAddress.value = { ...address }
+
+  // 触发选择事件
+  emit('select-address', address)
+}
+
+// 处理分页变化
+const handlePageChange = (page) => {
+  currentPage.value = page
+  selectedAddressId.value = null
+  selectedAddress.value = null
+
+  // 触发分页变化事件
+  emit('page-change', {
+    page,
+    pageSize: pageSize.value
+  })
+}
+
+// 处理取消
+const handleCancel = () => {
+  dialogVisible.value = false
+  emit('cancel')
+}
+
+// 处理确认
+const handleConfirm = () => {
+  if (selectedAddress.value) {
+    emit('confirm', selectedAddress.value)
+    dialogVisible.value = false
+  } else {
+    // 如果没有选中地址,可以选择触发一个错误提示
+    emit('confirm', null)
+  }
+}
+
+// 处理关闭
+const handleClose = () => {
+  selectedAddressId.value = null
+  selectedAddress.value = null
+  emit('close')
+}
+</script>
+
+<style scoped>
+.address-book-dialog :deep(.el-dialog__header) {
+  padding: 20px 20px 10px;
+  border-bottom: 1px solid #e8e8e8;
+}
+
+.address-book-dialog :deep(.el-dialog__body) {
+  padding: 0;
+}
+
+/* 地址列表样式 - 两列布局 */
+.address-list {
+  padding: 20px;
+  max-height: 500px;
+  overflow-y: auto;
+}
+
+.address-row {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 16px;
+  margin-bottom: 16px;
+}
+
+.address-item {
+  background: #F5F7FA;
+  border-radius: 16px;
+  padding: 16px;
+  cursor: pointer;
+  transition: all 0.3s ease;
+  display: flex;
+  flex-direction: column;
+  min-height: 100px;
+}
+
+.address-item:hover {
+  background: #EAF1FF;
+  box-shadow: 0 2px 8px rgba(24, 144, 255, 0.1);
+}
+
+.address-item.active {
+  border: 1px solid #2D71FF;
+  background-color: #EAF1FF;
+  box-shadow: 0 2px 8px rgba(24, 144, 255, 0.2);
+}
+
+.address-item.empty {
+  visibility: hidden;
+  pointer-events: none;
+}
+
+.address-content {
+  display: flex;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.address-header {
+  display: flex;
+  justify-content: flex-start;
+  align-items: flex-start;
+  gap: 8px;
+}
+
+.address-name {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333333;
+
+}
+
+.address-phone {
+  font-size: 16px;
+  font-weight: 600;
+  color: #333333;
+  margin-left: 20px;
+}
+
+.address-tags {
+  display: flex;
+  gap: 4px;
+  flex-wrap: wrap;
+}
+
+.tag {
+  font-size: 10px;
+  padding: 2px 6px;
+  border-radius: 2px;
+  color: #fff;
+  white-space: nowrap;
+}
+
+.default-tag {
+  background-color: #1890ff;
+}
+
+.home-tag {
+  background-color: #52c41a;
+}
+
+.company-tag {
+  background-color: #722ed1;
+}
+
+.address-body {
+  display: flex;
+  align-items: center;
+}
+
+.address-detail {
+  font-size: 14px;
+  color: #333;
+  line-height: 22px;
+  word-break: break-all;
+}
+
+.pagination-wrapper {
+  padding: 20px;
+  display: flex;
+  justify-content: center;
+  background-color: #fafafa;
+  border-top: 1px solid #e8e8e8;
+}
+
+.address-book-dialog :deep(.el-dialog__footer) {
+  padding: 12px 20px;
+  border-top: 1px solid #e8e8e8;
+}
+
+/* 响应式调整 */
+@media (max-width: 768px) {
+  .address-book-dialog {
+    width: 95% !important;
+  }
+
+  .address-row {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 769 - 0
jd-logistics-ui-v3/src/views/logistics/order/components/PickupTimeCascader.vue

@@ -0,0 +1,769 @@
+<template>
+  <div class="pickup-time-cascader">
+    <el-cascader
+        ref="cascaderRef"
+        v-model="selectedValue"
+        :options="cascaderOptions"
+        :props="cascaderProps"
+        :show-all-levels="false"
+        :placeholder="placeholder"
+        :clearable="clearable"
+        :filterable="filterable"
+        :disabled="disabled"
+        :size="size"
+        @change="handleChange"
+        @visible-change="handleVisibleChange"
+        @expand-change="handleExpandChange"
+    >
+      <template #default="{ node, data }">
+        <div class="cascader-item" :class="{'is-date': !data.timeLabel, 'is-time': data.timeLabel}">
+          <span class="item-label">{{ data.label }}</span>
+          <span v-if="data.tag" class="item-tag" :class="data.tagClass">
+            {{ data.tag }}
+          </span>
+          <span v-if="data.recommend" class="item-recommend">推荐</span>
+          <span v-if="data.night" class="item-night">夜间</span>
+        </div>
+      </template>
+
+      <template #empty>
+        <div class="empty-content">
+          <span class="empty-text">无可用时间段</span>
+        </div>
+      </template>
+    </el-cascader>
+  </div>
+</template>
+
+<script setup>
+import { ref, computed, watch, onMounted, onUnmounted, nextTick } from 'vue'
+
+const props = defineProps({
+  modelValue: {
+    type: Array,
+    default: () => []
+  },
+  placeholder: {
+    type: String,
+    default: '请选择上门时间'
+  },
+  clearable: {
+    type: Boolean,
+    default: true
+  },
+  filterable: {
+    type: Boolean,
+    default: false
+  },
+  disabled: {
+    type: Boolean,
+    default: false
+  },
+  size: {
+    type: String,
+    default: 'large',
+    validator: (value) => ['large', 'default', 'small'].includes(value)
+  },
+  // 显示天数
+  days: {
+    type: Number,
+    default: 3
+  },
+  // 时间间隔(分钟)
+  timeInterval: {
+    type: Number,
+    default: 60
+  },
+  // 开始时间
+  startTime: {
+    type: Number,
+    default: 9
+  },
+  // 结束时间
+  endTime: {
+    type: Number,
+    default: 21
+  },
+  // 当前时间(用于测试)
+  currentTime: {
+    type: Date,
+    default: () => new Date()
+  },
+  // 是否自动选择推荐时间
+  autoSelect: {
+    type: Boolean,
+    default: true
+  }
+})
+
+const emit = defineEmits(['update:modelValue', 'change', 'select', 'clear'])
+
+const selectedValue = ref([])
+const cascaderRef = ref(null)
+const currentTime = ref(new Date())
+const timer = ref(null)
+
+// 级联选择器配置
+const cascaderProps = {
+  expandTrigger: 'click',
+  multiple: false,
+  emitPath: true,
+  value: 'value',
+  label: 'label',
+  children: 'children',
+  disabled: 'disabled',
+  checkStrictly: false,
+  lazy: false
+}
+
+// 基础时间段配置
+const baseTimeSlots = computed(() => {
+  const slots = []
+  const startHour = props.startTime
+  const endHour = props.endTime
+  const interval = props.timeInterval / 60 // 转换为小时
+
+  for (let hour = startHour; hour < endHour; hour += interval) {
+    const start = hour
+    const end = hour + interval
+    const startStr = start.toString().padStart(2, '0')
+    const endStr = end.toString().padStart(2, '0')
+
+    slots.push({
+      start: start,
+      end: end,
+      label: `${startStr}:00-${endStr}:00`,
+      value: `${startStr}:00-${endStr}:00`
+    })
+  }
+
+  return slots
+})
+
+// 获取今天日期字符串
+const todayDate = computed(() => {
+  const today = new Date()
+  return formatDate(today)
+})
+
+// 生成日期列表
+const dateList = computed(() => {
+  const days = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
+  const list = []
+  const today = new Date()
+
+  for (let i = 0; i < props.days; i++) {
+    const date = new Date(today)
+    date.setDate(date.getDate() + i)
+
+    const year = date.getFullYear()
+    const month = date.getMonth() + 1
+    const day = date.getDate()
+    const week = days[date.getDay()]
+    const dateStr = formatDate(date)
+
+    let label = ''
+    if (i === 0) {
+      label = `今天 ${month}月${day}日 ${week}`
+    } else if (i === 1) {
+      label = `明天 ${month}月${day}日 ${week}`
+    } else if (i === 2) {
+      label = `后天 ${month}月${day}日 ${week}`
+    } else {
+      label = `${month}月${day}日 ${week}`
+    }
+
+    list.push({
+      value: dateStr,
+      label: label,
+      year: year,
+      month: month,
+      day: day,
+      week: week,
+      isToday: i === 0,
+      isTomorrow: i === 1,
+      isDayAfterTomorrow: i === 2,
+      fullLabel: `${month}月${day}日 ${week}`,
+      displayDate: `${year}-${month.toString().padStart(2, '0')}-${day.toString().padStart(2, '0')}`
+    })
+  }
+
+  return list
+})
+
+// 推荐时间段(今天的一小时内)
+const recommendTime = computed(() => {
+  if (!dateList.value.length || !dateList.value[0].isToday) return null
+
+  const now = currentTime.value
+  const currentHour = now.getHours()
+  const currentMinute = now.getMinutes()
+
+  // 找到下一个可用的时间段
+  for (const slot of baseTimeSlots.value) {
+    if (slot.start > currentHour ||
+        (slot.start === currentHour && currentMinute < 50)) {
+      // 如果当前时间在 startTime 到 endTime 之间
+      if (currentHour >= props.startTime && currentHour < props.endTime) {
+        // 计算推荐时间段(当前时间+1小时)
+        let startHour = currentHour
+        let startMinute = Math.ceil(currentMinute / 10) * 10
+        if (startMinute >= 60) {
+          startHour += 1
+          startMinute = 0
+        }
+
+        const endHour = startHour + 1
+        const recommendation = `${formatTime(startHour, startMinute)}-${endHour.toString().padStart(2, '0')}:00`
+
+        // 检查推荐时间是否在可用时间段内
+        if (isTimeSlotAvailable(recommendation)) {
+          return recommendation
+        }
+      }
+      return slot.label
+    }
+  }
+
+  return null
+})
+
+// 检查时间段是否可用
+const isTimeSlotAvailable = (timeLabel) => {
+  const timeMatch = timeLabel.match(/(\d{2}):(\d{2})-(\d{2}):(\d{2})/)
+  if (!timeMatch) return false
+
+  const startHour = parseInt(timeMatch[1])
+  const startMinute = parseInt(timeMatch[2])
+
+  // 检查是否在允许的时间范围内
+  if (startHour < props.startTime || startHour >= props.endTime) {
+    return false
+  }
+
+  return true
+}
+
+// 生成级联选择器选项
+const cascaderOptions = computed(() => {
+  const options = []
+
+  dateList.value.forEach(date => {
+    const dateOption = {
+      value: date.value,
+      label: date.label,
+      date: date.value,
+      dateLabel: date.label,
+      isToday: date.isToday,
+      children: [],
+      disabled: false
+    }
+
+    // 生成时间选项
+    const isToday = date.isToday
+    const now = currentTime.value
+
+    // 今天的时间段需要根据当前时间过滤
+    if (isToday) {
+      const currentHour = now.getHours()
+      const currentMinute = now.getMinutes()
+
+      // 添加推荐时间段(如果有)
+      if (recommendTime.value && props.autoSelect) {
+        dateOption.children.push({
+          value: recommendTime.value,
+          label: recommendTime.value,
+          timeLabel: recommendTime.value,
+          recommend: true,
+          tag: '推荐',
+          tagClass: 'tag-recommend',
+          disabled: false,
+          soon: false,
+          night: isNightTime(recommendTime.value),
+          startHour: getStartHour(recommendTime.value),
+          endHour: getEndHour(recommendTime.value)
+        })
+      }
+
+      // 添加其他时间段
+      baseTimeSlots.value.forEach(slot => {
+        const isDisabled = slot.end <= currentHour ||
+            (slot.start === currentHour && currentMinute > 50)
+        const isSoon = slot.start - currentHour <= 2 && slot.start > currentHour
+        const isRecommend = slot.label === recommendTime.value
+
+        if (!isDisabled || isRecommend) {
+          dateOption.children.push({
+            value: slot.label,
+            label: slot.label,
+            timeLabel: slot.label,
+            recommend: isRecommend,
+            tag: isSoon ? '约满' : (isRecommend ? '推荐' : ''),
+            tagClass: isSoon ? 'tag-soon' : (isRecommend ? 'tag-recommend' : ''),
+            disabled: isDisabled && !isRecommend,
+            soon: isSoon,
+            night: isNightTime(slot.label),
+            startHour: slot.start,
+            endHour: slot.end
+          })
+        }
+      })
+    } else {
+      // 明天及以后显示所有时间段
+      baseTimeSlots.value.forEach(slot => {
+        dateOption.children.push({
+          value: slot.label,
+          label: slot.label,
+          timeLabel: slot.label,
+          recommend: false,
+          tag: '',
+          tagClass: '',
+          disabled: false,
+          soon: false,
+          night: isNightTime(slot.label),
+          startHour: slot.start,
+          endHour: slot.end
+        })
+      })
+    }
+
+    // 如果没有可用的时间段,则禁用该日期
+    if (dateOption.children.length === 0) {
+      dateOption.disabled = true
+      dateOption.label = `${dateOption.label} (无可选时间)`
+    }
+
+    options.push(dateOption)
+  })
+
+  return options
+})
+
+// 是否是夜间时间段
+const isNightTime = (timeLabel) => {
+  const match = timeLabel.match(/^(\d{2})/)
+  if (match) {
+    const hour = parseInt(match[1])
+    return hour >= 18
+  }
+  return false
+}
+
+// 获取开始小时
+const getStartHour = (timeLabel) => {
+  const match = timeLabel.match(/^(\d{2})/)
+  return match ? parseInt(match[1]) : 0
+}
+
+// 获取结束小时
+const getEndHour = (timeLabel) => {
+  const match = timeLabel.match(/-(\d{2})/)
+  return match ? parseInt(match[1]) : 0
+}
+
+// 处理选择变化
+const handleChange = (value) => {
+  if (!value || value.length === 0) {
+    emit('update:modelValue', [])
+    emit('change', null)
+    emit('clear')
+    return
+  }
+
+  const [date, time] = value
+  const selectedDate = dateList.value.find(d => d.value === date)
+  const selectedTime = findTimeOption(date, time)
+
+  if (!selectedDate || !selectedTime) {
+    return
+  }
+
+  const timeData = {
+    date: date,
+    time: time,
+    dateLabel: selectedDate.label,
+    timeLabel: time,
+    fullLabel: formatDisplayText(selectedDate.label, time, selectedDate.isToday),
+    timestamp: new Date(date).setHours(selectedTime.startHour || 0),
+    isToday: selectedDate.isToday,
+    isImmediate: time === recommendTime.value,
+    isNight: selectedTime.night || false,
+    isSoon: selectedTime.soon || false,
+    displayText: formatDisplayText(selectedDate.label, time, selectedDate.isToday),
+    startHour: selectedTime.startHour,
+    endHour: selectedTime.endHour
+  }
+
+  emit('update:modelValue', value)
+  emit('change', timeData)
+  emit('select', timeData)
+}
+
+// 查找时间选项
+const findTimeOption = (date, time) => {
+  const dateOption = cascaderOptions.value.find(opt => opt.value === date)
+  if (!dateOption || !dateOption.children) return null
+  return dateOption.children.find(child => child.value === time)
+}
+
+// 处理弹窗显示/隐藏
+const handleVisibleChange = (visible) => {
+  if (visible) {
+    // 更新当前时间
+    currentTime.value = new Date()
+
+    // 如果没有选中值,自动选择推荐或第一个可用选项
+    if (selectedValue.value.length === 0 && props.autoSelect) {
+      nextTick(() => {
+        const defaultOption = getDefaultOption()
+        if (defaultOption) {
+          selectedValue.value = defaultOption
+          handleChange(defaultOption)
+        }
+      })
+    }
+  }
+}
+
+// 处理菜单展开
+const handleExpandChange = (activeLabels) => {
+  // 可以在这里添加菜单展开时的逻辑
+}
+
+// 获取默认选项
+const getDefaultOption = () => {
+  if (!cascaderOptions.value.length) return null
+
+  // 首先尝试今天的推荐时间段
+  const todayOption = cascaderOptions.value[0]
+  if (todayOption && todayOption.children && todayOption.children.length > 0) {
+    // 优先选择推荐时间段
+    const recommendOption = todayOption.children.find(child => child.recommend && !child.disabled)
+    if (recommendOption) {
+      return [todayOption.value, recommendOption.value]
+    }
+
+    // 否则选择第一个可用时间段
+    const firstAvailable = todayOption.children.find(child => !child.disabled)
+    if (firstAvailable) {
+      return [todayOption.value, firstAvailable.value]
+    }
+  }
+
+  // 尝试其他日期的第一个可用时间段
+  for (let i = 1; i < cascaderOptions.value.length; i++) {
+    const option = cascaderOptions.value[i]
+    if (option.children && option.children.length > 0) {
+      const firstAvailable = option.children.find(child => !child.disabled)
+      if (firstAvailable) {
+        return [option.value, firstAvailable.value]
+      }
+    }
+  }
+
+  return null
+}
+
+// 格式化显示文本
+const formatDisplayText = (dateLabel, timeLabel, isToday) => {
+  if (isToday && timeLabel === recommendTime.value) {
+    return '一小时内'
+  }
+  return `${dateLabel} ${timeLabel}`
+}
+
+// 工具函数
+const formatDate = (date) => {
+  const year = date.getFullYear()
+  const month = String(date.getMonth() + 1).padStart(2, '0')
+  const day = String(date.getDate()).padStart(2, '0')
+  return `${year}-${month}-${day}`
+}
+
+const formatTime = (hour, minute) => {
+  return `${hour.toString().padStart(2, '0')}:${minute.toString().padStart(2, '0')}`
+}
+
+// 监听外部值变化
+watch(() => props.modelValue, (newVal) => {
+  if (JSON.stringify(newVal) !== JSON.stringify(selectedValue.value)) {
+    selectedValue.value = newVal || []
+  }
+}, { immediate: true })
+
+// 监听当前时间变化(每分钟更新一次)
+watch(currentTime, () => {
+  // 如果今天的时间选项已经过期,需要重新计算
+  const todayOption = cascaderOptions.value[0]
+  if (todayOption && todayOption.isToday) {
+    // 这里可以添加逻辑来处理时间过期的选项
+  }
+})
+
+// 初始化
+onMounted(() => {
+  // 定时更新当前时间(每分钟更新一次)
+  timer.value = setInterval(() => {
+    if (cascaderRef.value && cascaderRef.value.dropDownVisible) {
+      currentTime.value = new Date()
+    }
+  }, 60000) // 每分钟更新一次
+})
+
+// 组件卸载时清除定时器
+onUnmounted(() => {
+  if (timer.value) {
+    clearInterval(timer.value)
+  }
+})
+
+// 暴露方法
+defineExpose({
+  getSelectedTimeData: () => {
+    if (selectedValue.value.length !== 2) return null
+    const [date, time] = selectedValue.value
+    const selectedDate = dateList.value.find(d => d.value === date)
+    const selectedTime = findTimeOption(date, time)
+
+    return {
+      date: date,
+      time: time,
+      dateLabel: selectedDate?.label,
+      timeLabel: time,
+      fullLabel: formatDisplayText(selectedDate?.label, time, selectedDate?.isToday),
+      timestamp: new Date(date).setHours(selectedTime?.startHour || 0),
+      isToday: selectedDate?.isToday,
+      isImmediate: time === recommendTime.value,
+      isNight: selectedTime?.night || false,
+      isSoon: selectedTime?.soon || false,
+      startHour: selectedTime?.startHour,
+      endHour: selectedTime?.endHour
+    }
+  },
+  clearSelection: () => {
+    selectedValue.value = []
+    emit('update:modelValue', [])
+    emit('change', null)
+    emit('clear')
+  },
+  refreshTime: () => {
+    currentTime.value = new Date()
+  },
+  setSelectedTime: (date, time) => {
+    const dateExists = dateList.value.some(d => d.value === date)
+    if (dateExists) {
+      selectedValue.value = [date, time]
+      handleChange([date, time])
+      return true
+    }
+    return false
+  }
+})
+</script>
+
+<style scoped lang="scss">
+.pickup-time-cascader {
+  width: 100%;
+
+  :deep(.el-cascader) {
+    width: 100%;
+
+    .el-input {
+      width: 100%;
+
+      .el-input__wrapper {
+        width: 100%;
+        box-sizing: border-box;
+
+        &.is-focus {
+          box-shadow: 0 0 0 1px var(--el-color-primary) inset;
+        }
+      }
+    }
+  }
+
+  :deep(.el-cascader__dropdown) {
+    max-height: 400px;
+    overflow-y: auto;
+    border-radius: 8px;
+    box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1);
+
+    .el-cascader-panel {
+      border: none;
+
+      .el-cascader-menu {
+        min-width: 200px;
+        max-height: 400px;
+        overflow-y: auto;
+
+        &:first-child {
+          min-width: 220px;
+        }
+
+        .el-cascader-node {
+          height: auto;
+          min-height: 40px;
+          padding: 8px 12px;
+
+          &:hover:not(.is-disabled) {
+            background-color: #f5f7fa;
+          }
+
+          &.is-selectable {
+            &.in-active-path,
+            &.is-active {
+              background-color: #f0f7ff;
+
+              .cascader-item {
+                .item-label {
+                  color: var(--el-color-primary);
+                  font-weight: 600;
+                }
+              }
+            }
+          }
+
+          &.is-disabled {
+            .cascader-item {
+              .item-label {
+                color: #c0c4cc;
+              }
+            }
+          }
+
+          .cascader-item {
+            display: flex;
+            align-items: center;
+            justify-content: space-between;
+            width: 100%;
+            min-height: 32px;
+            box-sizing: border-box;
+            font-size: 14px;
+
+            &.is-date {
+              .item-label {
+                font-weight: 500;
+                color: #333;
+              }
+
+              &.is-today {
+                .item-label {
+                  color: #f2270c;
+                }
+              }
+            }
+
+            &.is-time {
+              .item-label {
+                color: #666;
+              }
+            }
+
+            .item-label {
+              flex: 1;
+              text-align: left;
+              overflow: hidden;
+              text-overflow: ellipsis;
+              white-space: nowrap;
+            }
+
+            .item-tag {
+              font-size: 12px;
+              padding: 2px 8px;
+              border-radius: 10px;
+              margin-left: 8px;
+              font-weight: 500;
+
+              &.tag-recommend {
+                background-color: #fef2f0;
+                color: #f2270c;
+                border: 1px solid #f2270c;
+              }
+
+              &.tag-soon {
+                background-color: #fff7e6;
+                color: #ff9900;
+                border: 1px solid #ff9900;
+              }
+
+              &.tag-night {
+                background-color: #f0f5ff;
+                color: #4a5cff;
+                border: 1px solid #4a5cff;
+              }
+            }
+
+            .item-recommend {
+              font-size: 12px;
+              color: #f2270c;
+              font-weight: 500;
+              margin-left: 8px;
+              padding: 2px 6px;
+              background-color: #fef2f0;
+              border-radius: 4px;
+            }
+
+            .item-night {
+              font-size: 12px;
+              color: #4a5cff;
+              font-weight: 500;
+              margin-left: 8px;
+              padding: 2px 6px;
+              background-color: #f0f5ff;
+              border-radius: 4px;
+            }
+          }
+        }
+      }
+    }
+  }
+
+  .empty-content {
+    padding: 20px;
+    text-align: center;
+
+    .empty-text {
+      font-size: 14px;
+      color: #999;
+    }
+  }
+}
+
+// 夜间时间段特殊样式
+.night-time-option {
+  :deep(.el-cascader-node) {
+    .cascader-item {
+      .item-label {
+        color: #4a5cff !important;
+      }
+    }
+
+    &.is-active {
+      .cascader-item {
+        .item-label {
+          color: #4a5cff !important;
+        }
+      }
+    }
+  }
+}
+
+// 推荐时间段特殊样式
+.recommend-time-option {
+  :deep(.el-cascader-node) {
+    .cascader-item {
+      .item-label {
+        color: #f2270c !important;
+      }
+    }
+
+    &.is-active {
+      .cascader-item {
+        .item-label {
+          color: #f2270c !important;
+        }
+      }
+    }
+  }
+}
+</style>

Datei-Diff unterdrückt, da er zu groß ist
+ 1751 - 0
jd-logistics-ui-v3/src/views/logistics/order/createOrder.vue