longz
2026-04-27 25df88d0ab9d7444ed1d162b9efa1259ac2f6f66
yuyueMiniProgram/src/pages/appointment/index.vue
@@ -1,6 +1,20 @@
<script setup lang="ts">
import { nextTick, reactive, ref } from 'vue'
import { computed, nextTick, reactive, ref, watch } from 'vue'
import { onLoad, onUnload } from '@dcloudio/uni-app'
import { createAppointment, fetchMyAppointmentDetail } from '@/api/appointment'
import {
  fetchVisitReasons,
  searchVisitedUsersByExactName,
} from '@/api/lookup'
import { fetchSystemSettings } from '@/api/systemSettings'
import { fetchMyVisitors } from '@/api/visitor'
import type {
  MiniAppointmentCreateRequest,
  MiniMyAppointmentDetailItem,
  MiniMyAppointmentDetailRebookVisitorItem,
} from '@/types/appointment'
import type { MiniVisitReasonItem, MiniVisitedUserItem } from '@/types/lookup'
import type { MiniVisitorListItem } from '@/types/visitor'
definePage({
  name: 'appointment',
@@ -13,19 +27,33 @@
const router = useRouter()
const appointmentAssetBase = '/static/appointment'
const defaultPlateTag = '新能源'
const VISIT_REASON_CODE_OTHER = 'OTHER'
const VISIT_REASON_NAME_OTHER = '其他'
const VISIT_TIME_MODE_SINGLE = 'SINGLE'
const VISIT_TIME_MODE_MULTI = 'MULTI'
const APPROVAL_RESULT_SUBSCRIBE_TEMPLATE_ID = '4FQovCBNFM7rth42AWHTusHajO788eT9VpplSN4ZG2s'
const TIME_SLOT_LABEL_TO_CODE = {
  '全天00:00-23:59': 'ALL_DAY',
  '上午00:00-11:59': 'AM',
  '下午12:00-23:59': 'PM',
} as const
interface AppointmentVehicle {
  plateRegion: string
  plateCharacters: string[]
  plateTag: string
  driver: string
  driverVisitorId?: number | null
}
interface AppointmentVisitor {
  id: number
  uuid?: string
  name: string
  phone: string
  company: string
  gender: 'male' | 'female'
  idCardMasked?: string
  badge?: string
}
@@ -42,13 +70,28 @@
  visitTimeSingle: '',
  visitTimeMultiStart: '',
  visitTimeMultiEnd: '',
  entryType: 'person', // person 人员 | vehicle 车辆
  vehicleEntry: false,
  visitors: [] as AppointmentVisitor[],
  vehicles: [] as AppointmentVehicle[],
})
// UI 选项
const reasonColumns = ['会议', '拜访', '邀约', '合作', '其他']
const visitReasons = ref<MiniVisitReasonItem[]>([])
const selectedVisitReason = ref<MiniVisitReasonItem | null>(null)
const reasonPickerValue = ref('')
const visitedOptions = ref<MiniVisitedUserItem[]>([])
const selectedVisitedUser = ref<MiniVisitedUserItem | null>(null)
const customVisitReason = ref('')
const bookingRangeMode = ref<typeof VISIT_TIME_MODE_SINGLE | typeof VISIT_TIME_MODE_MULTI>(VISIT_TIME_MODE_SINGLE)
const maxConsecutiveDays = ref(3)
const reasonColumns = computed(() => visitReasons.value.map(item => item.reasonName))
const allowMulti = computed(() => bookingRangeMode.value === VISIT_TIME_MODE_MULTI)
const showCustomReasonInput = computed(() => {
  if (!selectedVisitReason.value) {
    return false
  }
  return selectedVisitReason.value.reasonCode === VISIT_REASON_CODE_OTHER
    || selectedVisitReason.value.reasonName === VISIT_REASON_NAME_OTHER
})
const showReasonPicker = ref(false)
const appointmentIcons = {
  male: `${appointmentAssetBase}/man.png`,
@@ -56,13 +99,14 @@
  company: `${appointmentAssetBase}/company.png`,
  delete: `${appointmentAssetBase}/delete.png`,
}
const mockDrivers = [
  '张琪-5120********258',
  '李林-138****5621',
  '王敏-136****2018',
]
const focusedVehicleIndex = ref(-1)
const focusedCharIndex = ref(-1)
const rebookOrderId = ref<number | null>(null)
const rebookOrderUuid = ref('')
function formatDatePart(value: number | string) {
  return String(value).padStart(2, '0')
}
function handlePlateCharClick(vehicleIndex: number, charIndex: number) {
  focusedVehicleIndex.value = vehicleIndex
@@ -80,20 +124,47 @@
  })
}
function handlePlateInput(e: any, vehicleIndex: number, charIndex: number) {
  const value = e.detail.value.toUpperCase().trim()
  const vehicle = formData.vehicles[vehicleIndex]
function normalizePlateSegmentValue(rawValue: string) {
  return (rawValue || '')
    .replace(/\s+/g, '')
    .replace(/·/g, '')
    .toUpperCase()
}
function setPlateChar(vehicle: AppointmentVehicle, charIndex: number, value: string) {
  if (charIndex === 0) {
    vehicle.plateRegion = value.slice(-1)
  } else {
    // 字符位,只取最后一个字符
    vehicle.plateCharacters[charIndex - 1] = value.slice(-1)
    vehicle.plateRegion = value
    return
  }
  vehicle.plateCharacters[charIndex - 1] = value
}
function handlePlateInput(e: any, vehicleIndex: number, charIndex: number) {
  const value = normalizePlateSegmentValue(e.detail.value)
  const vehicle = formData.vehicles[vehicleIndex]
  if (!vehicle) {
    return
  }
  // 自动跳到下一个输入框
  if (value.length > 0 && charIndex < 7) {
    focusNextPlateChar(vehicleIndex, charIndex + 1)
  if (!value) {
    setPlateChar(vehicle, charIndex, '')
    return
  }
  const chars = value.split('')
  let lastFilledIndex = charIndex
  chars.forEach((char, offset) => {
    const nextIndex = charIndex + offset
    if (nextIndex > 7) {
      return
    }
    setPlateChar(vehicle, nextIndex, char)
    lastFilledIndex = nextIndex
  })
  if (lastFilledIndex < 7) {
    focusNextPlateChar(vehicleIndex, lastFilledIndex + 1)
  }
}
@@ -111,32 +182,9 @@
  }
}
const mockVehicles: AppointmentVehicle[] = [
  {
    plateRegion: '',
    plateCharacters: ['', '', '', '', '', '', ''],
    plateTag: defaultPlateTag,
    driver: '张琪-5120********258',
  },
]
const mockVisitors: AppointmentVisitor[] = [
  {
    name: '张三',
    phone: '5120********258',
    company: '成都XXXX有限公司',
    gender: 'male',
    badge: '驾驶员',
  },
  {
    name: '张三',
    phone: '5120********258',
    company: '成都有限公司',
    gender: 'female',
  },
]
// 提交 loading 状态
const isSubmitting = ref(false)
const pendingSubmitPayload = ref<MiniAppointmentCreateRequest | null>(null)
// 拜访时间弹窗控制
const showTimePicker = ref(false)
@@ -355,15 +403,75 @@
  showMultiDatePicker.value = true
}
function handleDateTypeChange(type: 'single' | 'multi') {
  if (type === 'multi' && !allowMulti.value) {
    return
  }
  formData.dateType = type
}
function openReasonPicker() {
  if (!visitReasons.value.length) {
    uni.showToast({
      title: '暂无可选拜访事由',
      icon: 'none',
    })
    return
  }
  reasonPickerValue.value = formData.reason || visitReasons.value[0]?.reasonName || ''
  showReasonPicker.value = true
}
function handleReasonConfirm() {
  const nextReasonName = reasonPickerValue.value || visitReasons.value[0]?.reasonName || ''
  selectReasonByName(nextReasonName)
  showReasonPicker.value = false
}
async function handleSearchVisitedUser() {
  const keyword = formData.interviewee.trim()
  if (!keyword) {
    visitedOptions.value = []
    selectedVisitedUser.value = null
    return
  }
  try {
    visitedOptions.value = await searchVisitedUsersByExactName(keyword)
  }
  catch (error) {
    visitedOptions.value = []
    uni.showToast({
      title: error instanceof Error ? error.message : '搜索被访人失败',
      icon: 'none',
    })
  }
}
function selectVisitedUser(item: MiniVisitedUserItem) {
  selectedVisitedUser.value = item
  formData.interviewee = item.realName
  visitedOptions.value = []
}
function handleMultiDateConfirm() {
  const year = multiDateColumns.value[0][multiDatePickerIndex.value[0]]
  const month = multiDateColumns.value[1][multiDatePickerIndex.value[1]]
  const day = multiDateColumns.value[2][multiDatePickerIndex.value[2]]
  const formattedDate = `${year}-${month}-${day}`
  const formattedDate = `${year}-${formatDatePart(month)}-${formatDatePart(day)}`
  if (activeMultiDateField.value === 'start') {
    formData.visitTimeMultiStart = formattedDate
    if (formData.visitTimeMultiEnd && !isValidMultiDateRange(formData.visitTimeMultiStart, formData.visitTimeMultiEnd)) {
      formData.visitTimeMultiEnd = ''
    }
  } else {
    const nextStart = formData.visitTimeMultiStart
    if (nextStart && !isValidMultiDateRange(nextStart, formattedDate)) {
      return uni.showToast({
        title: `结束日期最长可选${maxConsecutiveDays.value}天内`,
        icon: 'none',
      })
    }
    formData.visitTimeMultiEnd = formattedDate
  }
@@ -391,7 +499,7 @@
  const day = timeColumns.value[2][timePickerIndex.value[2]]
  const time = timeColumns.value[3][timePickerIndex.value[3]]
  const formattedTime = `${year}-${month}-${day} ${time}`
  const formattedTime = `${year}-${formatDatePart(month)}-${formatDatePart(day)} ${time}`
  if (activeTimeField.value === 'single') {
    formData.visitTimeSingle = formattedTime
  } else if (activeTimeField.value === 'start') {
@@ -403,22 +511,10 @@
}
const showPromisePopup = ref(false)
const promiseHtml = ref('')
const countdown = ref(5)
let timer: ReturnType<typeof setInterval> | null = null
// 校验与提交
function handleSubmit() {
  if (!formData.interviewee || !formData.reason ||
     (formData.dateType === 'single' && !formData.visitTimeSingle) ||
     (formData.dateType === 'multi' && (!formData.visitTimeMultiStart || !formData.visitTimeMultiEnd)) ||
     formData.visitors.length === 0) {
    return uni.showToast({ title: '请填写完整必填信息', icon: 'none' })
  }
  showPromisePopup.value = true
  countdown.value = 5
  startCountdown()
}
let visitedSearchTimer: ReturnType<typeof setTimeout> | null = null
function startCountdown() {
  if (timer) clearInterval(timer)
@@ -431,26 +527,39 @@
  }, 1000)
}
function handlePromiseConfirm() {
  if (countdown.value > 0) return
  showPromisePopup.value = false
  isSubmitting.value = true
  setTimeout(() => {
    isSubmitting.value = false
    uni.redirectTo({ url: '/pages/success/index' })
  }, 1000)
}
function handlePromiseClose() {
  showPromisePopup.value = false
  if (timer) clearInterval(timer)
  pendingSubmitPayload.value = null
  if (timer) {
    clearInterval(timer)
    timer = null
  }
}
function handleEntryTypeChange(type: 'person' | 'vehicle') {
  formData.entryType = type
  if (type === 'vehicle' && formData.vehicles.length === 0) {
async function openParkNotice(payload: MiniAppointmentCreateRequest) {
  try {
    const settings = await fetchSystemSettings('CONTENT_PARK_NOTICE_HTML')
    promiseHtml.value = settings.CONTENT_PARK_NOTICE_HTML || '<p>暂无入园承诺</p>'
    pendingSubmitPayload.value = payload
    showPromisePopup.value = true
    countdown.value = 5
    startCountdown()
  }
  catch (error) {
    uni.showToast({
      title: error instanceof Error ? error.message : '加载入园承诺失败',
      icon: 'none',
    })
  }
}
function handleVehicleEntryChange(enabled: boolean) {
  formData.vehicleEntry = enabled
  if (enabled && formData.vehicles.length === 0) {
    formData.vehicles = [createEmptyVehicle()]
  }
  if (!enabled) {
    formData.vehicles = []
  }
}
@@ -460,6 +569,7 @@
    plateCharacters: ['', '', '', '', '', '', ''],
    plateTag: defaultPlateTag,
    driver: '',
    driverVisitorId: null,
  }
}
@@ -477,18 +587,28 @@
function handleSelectDriver(index: number) {
  selectingDriverForVehicleIndex.value = index
  uni.navigateTo({
    url: '/pages/choosePerson/index'
    url: `/pages/choosePerson/index?mode=driver&vehicleIndex=${index}${formData.vehicles[index]?.driverVisitorId ? `&selectedId=${formData.vehicles[index].driverVisitorId}` : ''}`,
  })
}
function handleSelectVisitor() {
  selectingDriverForVehicleIndex.value = -1
  const selectedIds = formData.visitors.map(item => item.id).join(',')
  uni.navigateTo({
    url: '/pages/choosePerson/index'
    url: `/pages/choosePerson/index?mode=pick${selectedIds ? `&selectedIds=${selectedIds}` : ''}`,
  })
}
function handleDeleteVisitor(index: number) {
  const target = formData.visitors[index]
  if (!target) {
    return
  }
  const usedByVehicle = formData.vehicles.some(vehicle => vehicle.driverVisitorId === target.id)
  if (usedByVehicle) {
    uni.showToast({ title: '该来访人已作为驾驶员使用,请先删除对应车辆或更换驾驶员', icon: 'none' })
    return
  }
  formData.visitors.splice(index, 1)
}
@@ -496,49 +616,563 @@
  return gender === 'female' ? appointmentIcons.female : appointmentIcons.male
}
onLoad(() => {
  uni.$on('acceptVisitor', (visitor: any) => {
    // 正在选择驾驶员
    if (selectingDriverForVehicleIndex.value > -1) {
      const vehicleIndex = selectingDriverForVehicleIndex.value
      // 设置车辆的驾驶员信息,格式:姓名-手机号(为了保持与原有 UI 的展示兼容,你可以修改这部分展示逻辑)
      formData.vehicles[vehicleIndex].driver = `${visitor.name}-${visitor.phone}`
      // 检查下方来访人员中是否已有该人员
      const exists = formData.visitors.find(v => v.phone === visitor.phone && v.name === visitor.name)
      if (exists) {
        // 如果存在,打上司机标签
        exists.badge = '驾驶员'
      } else {
        // 如果不存在,添加该人员并打上司机标签
        formData.visitors.push({
          name: visitor.name,
          phone: visitor.phone,
          company: visitor.company,
          gender: visitor.gender,
          badge: '驾驶员',
        })
      }
      // 重置选择状态
      selectingDriverForVehicleIndex.value = -1
    } else {
      // 普通来访人员选择
      const exists = formData.visitors.find(v => v.phone === visitor.phone && v.name === visitor.name)
      if (!exists) {
        formData.visitors.push({
          name: visitor.name,
          phone: visitor.phone,
          company: visitor.company,
          gender: visitor.gender,
        })
      }
function mapVisitorFromSelection(visitor: {
  id: number
  uuid?: string
  name: string
  phone?: string
  company?: string
  gender?: 'male' | 'female'
  idCardMasked?: string
}): AppointmentVisitor {
  return {
    id: visitor.id,
    uuid: visitor.uuid,
    name: visitor.name,
    phone: visitor.phone || '',
    company: visitor.company || '',
    gender: visitor.gender || 'male',
    idCardMasked: visitor.idCardMasked || '',
  }
}
function normalizeVisitorGender(gender?: string | null): AppointmentVisitor['gender'] {
  return gender === '女' || gender === 'FEMALE' ? 'female' : 'male'
}
function mapVisitorFromCurrentList(
  visitor: MiniVisitorListItem,
  snapshot?: MiniMyAppointmentDetailRebookVisitorItem,
): AppointmentVisitor {
  return {
    id: visitor.id,
    uuid: visitor.uuid,
    name: visitor.visitorName || snapshot?.name || '来访人',
    phone: snapshot?.mobileMasked || '',
    company: visitor.visitorCompany || snapshot?.companyName || '',
    gender: normalizeVisitorGender(snapshot?.gender || visitor.gender),
    idCardMasked: snapshot?.idCardMasked || '',
  }
}
function buildVehicleDriverLabel(driver: AppointmentVisitor, fallbackName?: string | null) {
  return `${driver.name || fallbackName || '驾驶员'}${driver.phone ? `-${driver.phone}` : ''}`
}
function parsePlateNo(plateNo?: string | null) {
  const normalized = (plateNo || '').trim().toUpperCase()
  if (!normalized) {
    return createEmptyVehicle()
  }
  const [plateRegion = '', ...rest] = normalized.split('')
  const characters = rest.slice(0, 7)
  while (characters.length < 7) {
    characters.push('')
  }
  return {
    plateRegion,
    plateCharacters: characters,
    plateTag: defaultPlateTag,
    driver: '',
    driverVisitorId: null,
  } satisfies AppointmentVehicle
}
function upsertVisitor(nextVisitor: AppointmentVisitor, badge?: string) {
  const exists = formData.visitors.find(item => item.id === nextVisitor.id)
  if (exists) {
    exists.name = nextVisitor.name
    exists.phone = nextVisitor.phone
    exists.company = nextVisitor.company
    exists.gender = nextVisitor.gender
    exists.uuid = nextVisitor.uuid
    exists.idCardMasked = nextVisitor.idCardMasked
    exists.badge = badge ?? exists.badge
    return
  }
  formData.visitors.push({
    ...nextVisitor,
    badge,
  })
}
function removeDriverBadgeIfUnused(visitorId?: number | null) {
  if (!visitorId) {
    return
  }
  const stillDriver = formData.vehicles.some(vehicle => vehicle.driverVisitorId === visitorId)
  if (stillDriver) {
    return
  }
  const visitor = formData.visitors.find(item => item.id === visitorId)
  if (visitor?.badge === '驾驶员') {
    delete visitor.badge
  }
}
function handleVisitorSelection(payload: {
  mode?: 'pick' | 'driver'
  vehicleIndex?: number
  visitors?: Array<{
    id: number
    uuid?: string
    name: string
    phone?: string
    company?: string
    gender?: 'male' | 'female'
    idCardMasked?: string
  }>
}) {
  const picked = payload.visitors || []
  if (payload.mode === 'driver') {
    const vehicleIndex = payload.vehicleIndex ?? selectingDriverForVehicleIndex.value
    const pickedDriver = picked[0]
    if (vehicleIndex < 0 || !pickedDriver || !formData.vehicles[vehicleIndex]) {
      return
    }
    const previousDriverId = formData.vehicles[vehicleIndex].driverVisitorId
    const nextDriver = mapVisitorFromSelection(pickedDriver)
    formData.vehicles[vehicleIndex].driver = `${nextDriver.name}${nextDriver.phone ? `-${nextDriver.phone}` : ''}`
    formData.vehicles[vehicleIndex].driverVisitorId = nextDriver.id
    upsertVisitor(nextDriver, '驾驶员')
    removeDriverBadgeIfUnused(previousDriverId)
    selectingDriverForVehicleIndex.value = -1
    return
  }
  const nextVisitors = picked.map(mapVisitorFromSelection)
  const currentDriverIds = new Set(
    formData.vehicles
      .map(vehicle => vehicle.driverVisitorId)
      .filter((id): id is number => Boolean(id)),
  )
  formData.visitors = nextVisitors.map((visitor) => {
    const existing = formData.visitors.find(item => item.id === visitor.id)
    return {
      ...existing,
      ...visitor,
      badge: currentDriverIds.has(visitor.id) ? '驾驶员' : undefined,
    }
  })
}
function parseDate(value: string) {
  return new Date(`${value}T00:00:00`)
}
function diffDaysInclusive(start: string, end: string) {
  const startDate = parseDate(start)
  const endDate = parseDate(end)
  const diff = endDate.getTime() - startDate.getTime()
  return Math.floor(diff / (24 * 60 * 60 * 1000)) + 1
}
function isValidMultiDateRange(start: string, end: string) {
  if (!start || !end) {
    return true
  }
  if (parseDate(end).getTime() < parseDate(start).getTime()) {
    return false
  }
  return diffDaysInclusive(start, end) <= maxConsecutiveDays.value
}
async function loadBookingSettings() {
  try {
    const result = await fetchSystemSettings('BOOKING_RANGE_MODE,BOOKING_MAX_CONSECUTIVE_DAYS')
    bookingRangeMode.value
      = result.BOOKING_RANGE_MODE?.trim().toUpperCase() === VISIT_TIME_MODE_MULTI
        ? VISIT_TIME_MODE_MULTI
        : VISIT_TIME_MODE_SINGLE
    const parsedDays = Number.parseInt(result.BOOKING_MAX_CONSECUTIVE_DAYS?.trim() || '', 10)
    maxConsecutiveDays.value = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : 3
    if (!allowMulti.value && formData.dateType === 'multi') {
      formData.dateType = 'single'
      formData.visitTimeMultiStart = ''
      formData.visitTimeMultiEnd = ''
    }
  }
  catch (error) {
    bookingRangeMode.value = VISIT_TIME_MODE_SINGLE
    maxConsecutiveDays.value = 3
    uni.showToast({
      title: error instanceof Error ? error.message : '加载预约配置失败',
      icon: 'none',
    })
  }
}
async function loadVisitReasons() {
  try {
    visitReasons.value = await fetchVisitReasons()
    if (selectedVisitReason.value) {
      const nextReason = visitReasons.value.find(item => item.reasonCode === selectedVisitReason.value?.reasonCode) || null
      selectedVisitReason.value = nextReason
      formData.reason = nextReason?.reasonName || ''
    }
    if (!reasonPickerValue.value) {
      reasonPickerValue.value = formData.reason || visitReasons.value[0]?.reasonName || ''
    }
  }
  catch (error) {
    visitReasons.value = []
    uni.showToast({
      title: error instanceof Error ? error.message : '加载来访事由失败',
      icon: 'none',
    })
  }
}
async function resolveVisitedUser() {
  const keyword = formData.interviewee.trim()
  if (!keyword) {
    throw new Error('请输入被访人')
  }
  if (selectedVisitedUser.value?.realName === keyword) {
    return selectedVisitedUser.value
  }
  const users = await searchVisitedUsersByExactName(keyword)
  if (!users.length) {
    throw new Error('未匹配到被访人')
  }
  if (users.length > 1) {
    throw new Error('存在多个同名被访人,请输入更准确姓名')
  }
  selectedVisitedUser.value = users[0]
  return users[0]
}
function selectReasonByName(reasonName: string) {
  const target = visitReasons.value.find(item => item.reasonName === reasonName) || null
  selectedVisitReason.value = target
  formData.reason = target?.reasonName || ''
  if (!showCustomReasonInput.value) {
    customVisitReason.value = ''
  }
}
function resolveVisitTimeMode() {
  return formData.dateType === 'multi' ? VISIT_TIME_MODE_MULTI : VISIT_TIME_MODE_SINGLE
}
function buildVehiclePlateNo(vehicle: AppointmentVehicle) {
  return `${vehicle.plateRegion}${vehicle.plateCharacters.join('')}`.trim().toUpperCase()
}
function resetForm() {
  formData.interviewee = ''
  formData.reason = ''
  formData.dateType = 'single'
  formData.visitTimeSingle = ''
  formData.visitTimeMultiStart = ''
  formData.visitTimeMultiEnd = ''
  formData.vehicleEntry = false
  formData.visitors = []
  formData.vehicles = []
  visitedOptions.value = []
  selectedVisitReason.value = null
  selectedVisitedUser.value = null
  customVisitReason.value = ''
  selectingDriverForVehicleIndex.value = -1
}
async function prefillFromRebookDetail() {
  if (!rebookOrderId.value || !rebookOrderUuid.value) {
    return
  }
  try {
    const [detailData, currentVisitors] = await Promise.all([
      fetchMyAppointmentDetail(rebookOrderId.value, rebookOrderUuid.value),
      fetchMyVisitors(),
    ])
    await applyRebookPrefill(detailData, currentVisitors)
  }
  catch (error) {
    uni.showToast({
      title: error instanceof Error ? error.message : '加载再次预约数据失败',
      icon: 'none',
    })
  }
  finally {
  }
}
async function applyRebookPrefill(detailData: MiniMyAppointmentDetailItem, currentVisitors: MiniVisitorListItem[]) {
  const warnings: string[] = []
  resetForm()
  formData.interviewee = detailData.visitedUserName || ''
  selectedVisitedUser.value = null
  visitedOptions.value = []
  const visitedKeyword = detailData.visitedUserName?.trim()
  if (visitedKeyword) {
    try {
      const users = await searchVisitedUsersByExactName(visitedKeyword)
      const matched = users.find(item => item.id === detailData.visitedUserId)
      if (matched) {
        selectedVisitedUser.value = matched
        formData.interviewee = matched.realName
      }
      else {
        warnings.push('原被访人不可用,请重新选择')
      }
    }
    catch {
      warnings.push('原被访人不可用,请重新选择')
    }
  }
  const reasonByCode = visitReasons.value.find(item => item.reasonCode === detailData.visitReasonCode) || null
  if (reasonByCode) {
    selectedVisitReason.value = reasonByCode
    formData.reason = reasonByCode.reasonName
    reasonPickerValue.value = reasonByCode.reasonName
  }
  else {
    selectedVisitReason.value = null
    formData.reason = ''
    if (detailData.visitReasonCode) {
      warnings.push('原拜访事由不可用,请重新选择')
    }
  }
  customVisitReason.value = detailData.visitReasonCode === VISIT_REASON_CODE_OTHER
    ? detailData.visitReasonOther || ''
    : ''
  const currentVisitorMap = new Map(currentVisitors.map(item => [item.id, item]))
  const restoredVisitors: AppointmentVisitor[] = []
  const missingVisitors: string[] = []
  for (const item of detailData.rebookVisitors || []) {
    const current = currentVisitorMap.get(item.visitorId)
    if (!current) {
      missingVisitors.push(item.name || '来访人')
      continue
    }
    restoredVisitors.push(mapVisitorFromCurrentList(current, item))
  }
  formData.visitors = restoredVisitors
  if (missingVisitors.length > 0) {
    warnings.push(`已跳过${missingVisitors.length}位失效来访人`)
  }
  formData.vehicleEntry = Boolean(detailData.vehicleEntry)
  const restoredVisitorMap = new Map(formData.visitors.map(item => [item.id, item]))
  const vehicleWarnings: string[] = []
  formData.vehicles = (detailData.rebookVehicles || []).map((item) => {
    const vehicle = parsePlateNo(item.plateNo)
    const driver = item.driverVisitorId ? restoredVisitorMap.get(item.driverVisitorId) : undefined
    if (driver) {
      vehicle.driverVisitorId = driver.id
      vehicle.driver = buildVehicleDriverLabel(driver, item.driverName)
      driver.badge = '驾驶员'
    }
    else if (item.driverVisitorId) {
      vehicle.driver = item.driverName || ''
      vehicleWarnings.push(item.plateNo || '车辆')
    }
    return vehicle
  })
  if (formData.vehicleEntry && formData.vehicles.length === 0) {
    formData.vehicles = [createEmptyVehicle()]
  }
  if (vehicleWarnings.length > 0) {
    warnings.push(`部分车辆驾驶员失效,请重新选择驾驶员`)
  }
  formData.dateType = 'single'
  formData.visitTimeSingle = ''
  formData.visitTimeMultiStart = ''
  formData.visitTimeMultiEnd = ''
  if (warnings.length > 0) {
    uni.showToast({
      title: warnings.join(';'),
      icon: 'none',
      duration: 3000,
    })
  }
}
async function buildAppointmentPayload(): Promise<MiniAppointmentCreateRequest> {
  const visitedUser = await resolveVisitedUser()
  if (!selectedVisitReason.value?.reasonCode) {
    throw new Error('请选择拜访事由')
  }
  if (showCustomReasonInput.value && !customVisitReason.value.trim()) {
    throw new Error('请输入具体来访事由')
  }
  if (!formData.visitors.length) {
    throw new Error('请选择来访人员')
  }
  const visitTimeMode = resolveVisitTimeMode()
  const payload: MiniAppointmentCreateRequest = {
    visitedUserId: visitedUser.id,
    visitReasonCode: selectedVisitReason.value.reasonCode,
    visitTimeMode,
    personEntry: true,
    visitorIds: formData.visitors.map(item => item.id),
    vehicleEntry: formData.vehicleEntry,
  }
  if (showCustomReasonInput.value) {
    payload.visitReasonOther = customVisitReason.value.trim()
  }
  if (visitTimeMode === VISIT_TIME_MODE_SINGLE) {
    if (!formData.visitTimeSingle) {
      throw new Error('请选择拜访时间')
    }
    const [visitDate, visitTimeLabel = ''] = formData.visitTimeSingle.split(' ')
    const visitTimeSlot = TIME_SLOT_LABEL_TO_CODE[visitTimeLabel as keyof typeof TIME_SLOT_LABEL_TO_CODE]
    if (!visitDate || !visitTimeSlot) {
      throw new Error('请选择有效的单日拜访时间')
    }
    payload.visitDate = visitDate
    payload.visitTimeSlot = visitTimeSlot
  } else {
    if (!allowMulti.value) {
      throw new Error('当前预约配置不支持多日')
    }
    if (!formData.visitTimeMultiStart || !formData.visitTimeMultiEnd) {
      throw new Error('请选择开始时间和结束时间')
    }
    if (!isValidMultiDateRange(formData.visitTimeMultiStart, formData.visitTimeMultiEnd)) {
      throw new Error(`结束日期最长可选${maxConsecutiveDays.value}天内`)
    }
    payload.visitStartDate = formData.visitTimeMultiStart
    payload.visitEndDate = formData.visitTimeMultiEnd
  }
  if (payload.vehicleEntry) {
    if (!formData.vehicles.length) {
      throw new Error('请添加进场车辆')
    }
    payload.vehicles = formData.vehicles.map((item) => {
      const plateNo = buildVehiclePlateNo(item)
      if (!plateNo || !item.driverVisitorId) {
        throw new Error('请完善车辆信息')
      }
      return {
        plateNo,
        driverVisitorId: item.driverVisitorId,
      }
    })
  }
  return payload
}
async function handleSubmit() {
  try {
    const payload = await buildAppointmentPayload()
    await openParkNotice(payload)
  }
  catch (error) {
    uni.showToast({
      title: error instanceof Error ? error.message : '请填写完整必填信息',
      icon: 'none',
    })
  }
}
async function handlePromiseConfirm() {
  if (countdown.value > 0 || !pendingSubmitPayload.value) {
    return
  }
  showPromisePopup.value = false
  isSubmitting.value = true
  try {
    await requestApprovalResultSubscribeAuthorization()
    const orderId = await createAppointment(pendingSubmitPayload.value)
    resetForm()
    pendingSubmitPayload.value = null
    uni.redirectTo({ url: `/pages/success/index?orderId=${orderId}` })
  }
  catch (error) {
    uni.showToast({
      title: error instanceof Error ? error.message : '提交预约失败',
      icon: 'none',
    })
  }
  finally {
    isSubmitting.value = false
  }
}
async function requestApprovalResultSubscribeAuthorization() {
  // #ifdef MP-WEIXIN
  try {
    await new Promise<void>((resolve) => {
      uni.requestSubscribeMessage({
        tmplIds: [APPROVAL_RESULT_SUBSCRIBE_TEMPLATE_ID],
        complete: () => resolve(),
      })
    })
  }
  catch (error) {
    console.warn('requestSubscribeMessage failed', error)
  }
  // #endif
}
watch(
  () => formData.interviewee,
  (keyword) => {
    if (visitedSearchTimer) {
      clearTimeout(visitedSearchTimer)
      visitedSearchTimer = null
    }
    const trimmedKeyword = keyword.trim()
    if (!trimmedKeyword) {
      visitedOptions.value = []
      selectedVisitedUser.value = null
      return
    }
    if (selectedVisitedUser.value?.realName === trimmedKeyword) {
      return
    }
    selectedVisitedUser.value = null
    visitedSearchTimer = setTimeout(() => {
      void handleSearchVisitedUser()
    }, 300)
  },
)
watch(
  () => showCustomReasonInput.value,
  (visible) => {
    if (!visible) {
      customVisitReason.value = ''
    }
  },
)
onLoad((options) => {
  uni.$on('selectVisitors', handleVisitorSelection)
  rebookOrderId.value = options?.rebookOrderId ? Number(options.rebookOrderId) : null
  rebookOrderUuid.value = options?.rebookOrderUuid ? decodeURIComponent(options.rebookOrderUuid) : ''
  void Promise.all([
    loadBookingSettings(),
    loadVisitReasons(),
  ]).then(() => prefillFromRebookDetail())
})
onUnload(() => {
  uni.$off('acceptVisitor')
  uni.$off('selectVisitors', handleVisitorSelection)
  if (visitedSearchTimer) {
    clearTimeout(visitedSearchTimer)
    visitedSearchTimer = null
  }
  if (timer) {
    clearInterval(timer)
    timer = null
  }
})
</script>
@@ -582,9 +1216,22 @@
              </view>
            </template>
          </wd-input>
          <view v-if="selectedVisitedUser" class="px-[20rpx] pb-[16rpx] text-right text-[22rpx] text-[#999999]">
            已选:{{ selectedVisitedUser.realName }}{{ selectedVisitedUser.deptName ? ` / ${selectedVisitedUser.deptName}` : '' }}
          </view>
          <view v-if="visitedOptions.length" class="px-[20rpx] pb-[16rpx]">
            <view
              v-for="item in visitedOptions"
              :key="item.id"
              class="mb-[12rpx] rounded-[12rpx] bg-[#F5F8FF] px-[24rpx] py-[18rpx] text-right text-[24rpx] text-[#333333]"
              @click="selectVisitedUser(item)"
            >
              {{ item.realName }}{{ item.deptName ? ` / ${item.deptName}` : '' }}
            </view>
          </view>
          <!-- 拜访事由 -->
          <view class="custom-cell flex items-center justify-between" @click="showReasonPicker = true">
          <view class="custom-cell flex items-center justify-between" @click="openReasonPicker">
            <view class="inline-flex items-start">
              <text class="text-[28rpx] text-[#333333]">拜访事由</text>
              <text class="text-[24rpx] text-[#FF4D4F] ml-[4rpx] mt-[-4rpx]">*</text>
@@ -595,6 +1242,19 @@
              </text>
              <wd-icon name="arrow-right" size="32rpx" color="#999999" custom-class="ml-[8rpx]" />
            </view>
          </view>
          <view v-if="showCustomReasonInput" class="custom-cell">
            <view class="mb-[20rpx] inline-flex items-start">
              <text class="text-[28rpx] text-[#333333]">事由说明</text>
              <text class="text-[24rpx] text-[#FF4D4F] ml-[4rpx] mt-[-4rpx]">*</text>
            </view>
            <textarea
              v-model.trim="customVisitReason"
              class="min-h-[160rpx] w-full rounded-[16rpx] bg-[#F8FAFD] px-[24rpx] py-[20rpx] text-[26rpx] text-[#333333] box-border"
              maxlength="100"
              placeholder="请输入具体来访事由"
              placeholder-class="text-[#B3B3B3]"
            />
          </view>
          <!-- 拜访时间类型切换 -->
@@ -610,12 +1270,15 @@
                class="h-[48rpx] w-[240rpx] flex rounded-full bg-white box-border border border-[#0068FF] overflow-hidden">
                <view class="flex w-[120rpx] items-center justify-center text-[24rpx] transition-all"
                  :class="formData.dateType === 'single' ? 'bg-[#0068FF] text-white' : ' border bg-transparent text-[#0068FF] border-r border-[#0068FF]'"
                  @click="formData.dateType = 'single'">
                  @click="handleDateTypeChange('single')">
                  单日
                </view>
                <view class="flex w-[120rpx] items-center justify-center text-[24rpx] transition-all"
                  :class="formData.dateType === 'multi' ? 'bg-[#0068FF] text-white' : ' border bg-transparent text-[#0068FF]'"
                  @click="formData.dateType = 'multi'">
                  :class="[
                    formData.dateType === 'multi' ? 'bg-[#0068FF] text-white' : 'border bg-transparent text-[#0068FF]',
                    !allowMulti ? 'opacity-40' : '',
                  ]"
                  @click="handleDateTypeChange('multi')">
                  多日
                </view>
              </view>
@@ -652,6 +1315,9 @@
                <wd-icon name="arrow-right" size="32rpx" color="#999999" custom-class="ml-[8rpx]" />
              </view>
            </view>
            <view class="px-[20rpx] py-[16rpx] text-right text-[22rpx] text-[#999999]">
              结束日期最长可选 {{ maxConsecutiveDays }} 天内
            </view>
          </template>
        </wd-form>
      </view>
@@ -672,32 +1338,32 @@
          <view class="inline-flex items-start">
            <text class="text-[28rpx] text-[#333333]">进场类型</text>
          </view>
          <view class="flex justify-end items-center h-full">
            <view class="flex items-center gap-[32rpx]">
              <view class="flex items-center" @click="handleEntryTypeChange('person')">
            <view class="flex justify-end items-center h-full">
              <view class="flex items-center gap-[32rpx]">
              <view class="flex items-center">
                <view
                  class="flex items-center justify-center w-[28rpx] h-[28rpx] rounded-full border-[2rpx] border-solid transition-colors duration-200"
                  :class="formData.entryType === 'person' ? 'border-[#1677FF]' : 'border-[#CFCFCF]'">
                  <view v-if="formData.entryType === 'person'" class="w-[16rpx] h-[16rpx] rounded-full bg-[#1677FF]">
                  :class="'border-[#1677FF]'">
                  <view class="w-[16rpx] h-[16rpx] rounded-full bg-[#1677FF]">
                  </view>
                </view>
                <text class="ml-[12rpx] text-[28rpx]" :class="formData.entryType === 'person' ? 'text-[#333333]' : 'text-[#999999]'">人员进场</text>
                <text class="ml-[12rpx] text-[28rpx] text-[#333333]">人员进场</text>
              </view>
              <view class="flex items-center" @click="handleEntryTypeChange('vehicle')">
              <view class="flex items-center" @click="handleVehicleEntryChange(!formData.vehicleEntry)">
                <view
                  class="flex items-center justify-center w-[28rpx] h-[28rpx] rounded-full border-[2rpx] border-solid transition-colors duration-200"
                  :class="formData.entryType === 'vehicle' ? 'border-[#1677FF]' : 'border-[#CFCFCF]'">
                  <view v-if="formData.entryType === 'vehicle'" class="w-[16rpx] h-[16rpx] rounded-full bg-[#1677FF]">
                  :class="formData.vehicleEntry ? 'border-[#1677FF]' : 'border-[#CFCFCF]'">
                  <view v-if="formData.vehicleEntry" class="w-[16rpx] h-[16rpx] rounded-full bg-[#1677FF]">
                  </view>
                </view>
                <text class="ml-[12rpx] text-[28rpx]" :class="formData.entryType === 'vehicle' ? 'text-[#333333]' : 'text-[#999999]'">车辆进场</text>
                <text class="ml-[12rpx] text-[28rpx]" :class="formData.vehicleEntry ? 'text-[#333333]' : 'text-[#999999]'">车辆进场</text>
              </view>
            </view>
          </view>
        </view>
      </view>
      <view v-if="formData.entryType === 'vehicle'" class="mb-[24rpx] rounded-[16rpx] bg-white shadow-[0px_4px_8px_0px_rgba(6,47,108,0.1608)]">
      <view v-if="formData.vehicleEntry" class="mb-[24rpx] rounded-[16rpx] bg-white shadow-[0px_4px_8px_0px_rgba(6,47,108,0.1608)]">
        <view class="px-[32rpx] py-[28rpx] border-b1">
          <text class="text-[28rpx] text-[#333333] font-medium">进场车辆</text>
        </view>
@@ -827,7 +1493,7 @@
          <view v-if="formData.visitors.length > 0" class="mb-[16rpx]">
            <view
              v-for="(visitor, index) in formData.visitors"
              :key="`${visitor.name}-${index}`"
              :key="`${visitor.id}-${index}`"
              class="visitor-row flex items-center justify-between px-[8rpx] py-[20rpx]"
            >
              <view class="flex items-center min-w-0 flex-1">
@@ -838,13 +1504,13 @@
                  <view class="flex items-center min-w-0">
                    <image :src="getVisitorGenderIcon(visitor.gender)" class="h-[32rpx] w-[32rpx] shrink-0" />
                    <text class="ml-[10rpx] truncate text-[24rpx] text-[#999999]">
                      {{ visitor.phone }}
                      {{ visitor.phone || visitor.idCardMasked || '-' }}
                    </text>
                  </view>
                  <view class="mt-[8rpx] flex items-center min-w-0">
                    <image :src="appointmentIcons.company" class="h-[32rpx] w-[32rpx]  shrink-0" />
                    <text class="ml-[10rpx] truncate text-[24rpx] text-[#B3B3B3]">
                      {{ visitor.company }}
                      {{ visitor.company || '-' }}
                    </text>
                  </view>
                </view>
@@ -905,7 +1571,7 @@
          <!-- 弹窗内容区 (选择器) -->
          <view class="px-[32rpx] flex-1 flex flex-col justify-center">
            <wd-picker-view
              v-model="formData.reason"
              v-model="reasonPickerValue"
              :columns="reasonColumns"
              :item-height="48"
              :columns-height="144"
@@ -916,7 +1582,7 @@
          <!-- 弹窗底部按钮 -->
          <view class="px-[32rpx] pb-[48rpx] shrink-0">
            <wd-button type="primary" size="large" block custom-class="popup-submit-btn"
              @click="showReasonPicker = false">
              @click="handleReasonConfirm">
              提交
            </wd-button>
          </view>
@@ -1035,13 +1701,10 @@
        <!-- 承诺内容滚动区 -->
        <scroll-view scroll-y class="relative z-10 px-[40rpx] flex-1 min-h-0 box-border">
          <text class="text-[26rpx] text-[#666666] leading-[44rpx] block text-justify">
            确认入园承诺,内容后台配置(小程序显示5秒倒计时后才能进行下一步)
            确认入园承诺,内容后台配置(小程序显示5秒倒。计时后才能进行下一步)确认入园承诺,内容后台配置(小程序显示5秒倒计时后才能进行下一步)确认入园承诺,内容后台配置(小程序显示5秒倒计时后才能进行下一步)
            确认入园承诺,内容后台配置(小程序显示5秒倒计时后才能进行下一步)
            确认入园承诺,内容后台配置(小程序显示5秒倒。计时后才能进行下一步)确认入园承诺,内容后台配置(小程序显示5秒倒。计时后才能进行下一步)
            确认入园承诺,内容后台配置(小程序显示5秒倒。计时后才能进行下一步)
          </text>
          <rich-text
            class="promise-html text-[26rpx] text-[#666666] leading-[44rpx] block"
            :nodes="promiseHtml"
          />
        </scroll-view>
        <!-- 底部按钮区 -->
@@ -1151,6 +1814,10 @@
  box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.04);
}
.promise-html {
  word-break: break-word;
}
.visitor-row + .visitor-row {
  border-top: 1px solid #F0F0F0;
}