| | |
| | | <script setup lang="ts"> |
| | | import { computed, nextTick, reactive, ref, watch } from 'vue' |
| | | import { computed, reactive, ref, watch } from 'vue' |
| | | import { onLoad, onUnload } from '@dcloudio/uni-app' |
| | | import { createAppointment, fetchMyAppointmentDetail } from '@/api/appointment' |
| | | import { |
| | | fetchMiniFieldConfigs, |
| | | fetchVisitReasons, |
| | | searchVisitedUsersByExactName, |
| | | } from '@/api/lookup' |
| | |
| | | MiniMyAppointmentDetailItem, |
| | | MiniMyAppointmentDetailRebookVisitorItem, |
| | | } from '@/types/appointment' |
| | | import type { MiniVisitReasonItem, MiniVisitedUserItem } from '@/types/lookup' |
| | | import type { MiniFieldConfigItem, MiniVisitReasonItem, MiniVisitedUserItem } from '@/types/lookup' |
| | | import type { MiniVisitorListItem } from '@/types/visitor' |
| | | |
| | | definePage({ |
| | |
| | | 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 APPROVAL_RESULT_SUBSCRIBE_TEMPLATE_ID = 'rIJpfN51_q818RTpJAqTe5ssTwWCHnxDM5rpq64uuKM' |
| | | const TIME_SLOT_LABEL_TO_CODE = { |
| | | '全天00:00-23:59': 'ALL_DAY', |
| | | '上午00:00-11:59': 'AM', |
| | | '下午12:00-23:59': 'PM', |
| | | } as const |
| | | const APPOINTMENT_FIELD_CODES = { |
| | | RESPONDENT: 'respondent', |
| | | VISIT_REASON: 'visit_reason', |
| | | VISIT_START_TIME: 'visit_start_time', |
| | | LICENSE_PLATE: 'license_plate', |
| | | DRIVER: 'driver', |
| | | } as const |
| | | |
| | | interface AppointmentVehicle { |
| | |
| | | }) |
| | | |
| | | const visitReasons = ref<MiniVisitReasonItem[]>([]) |
| | | const fieldConfigs = ref<MiniFieldConfigItem[]>([]) |
| | | const selectedVisitReason = ref<MiniVisitReasonItem | null>(null) |
| | | const reasonPickerValue = ref('') |
| | | const visitedOptions = ref<MiniVisitedUserItem[]>([]) |
| | |
| | | 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 isVisitReasonRequired = computed(() => isFieldRequired(APPOINTMENT_FIELD_CODES.VISIT_REASON, true)) |
| | | const isRespondentRequired = computed(() => isFieldRequired(APPOINTMENT_FIELD_CODES.RESPONDENT, true)) |
| | | const isVisitTimeRequired = computed(() => isFieldRequired(APPOINTMENT_FIELD_CODES.VISIT_START_TIME, true)) |
| | | const showVehicleEntryOption = computed(() => |
| | | isFieldVisible(APPOINTMENT_FIELD_CODES.LICENSE_PLATE, true) |
| | | || isFieldVisible(APPOINTMENT_FIELD_CODES.DRIVER, true), |
| | | ) |
| | | const showCustomReasonInput = computed(() => Boolean(selectedVisitReason.value)) |
| | | const showReasonPicker = ref(false) |
| | | const appointmentIcons = { |
| | | male: `${appointmentAssetBase}/man.png`, |
| | |
| | | } |
| | | const focusedVehicleIndex = ref(-1) |
| | | const focusedCharIndex = ref(-1) |
| | | const plateKeyboardVehicleIndex = ref(-1) |
| | | const showPlateKeyboard = ref(false) |
| | | const plateKeyboardLang = ref<'zh' | 'en'>('zh') |
| | | const plateKeyboardValue = ref('') |
| | | const rebookOrderId = ref<number | null>(null) |
| | | const rebookOrderUuid = ref('') |
| | | |
| | |
| | | return String(value).padStart(2, '0') |
| | | } |
| | | |
| | | function findFieldConfig(fieldCode: string) { |
| | | return fieldConfigs.value.find(item => item.fieldCode === fieldCode) |
| | | } |
| | | |
| | | function isFieldRequired(fieldCode: string, defaultValue = false) { |
| | | const config = findFieldConfig(fieldCode) |
| | | if (!config) { |
| | | return defaultValue |
| | | } |
| | | return config.requiredFlag === 1 |
| | | } |
| | | |
| | | function isFieldVisible(fieldCode: string, defaultValue = true) { |
| | | const config = findFieldConfig(fieldCode) |
| | | if (!config) { |
| | | return defaultValue |
| | | } |
| | | return config.visible === 1 |
| | | } |
| | | |
| | | function handlePlateCharClick(vehicleIndex: number, charIndex: number) { |
| | | focusedVehicleIndex.value = vehicleIndex |
| | | focusedCharIndex.value = charIndex |
| | | const vehicle = formData.vehicles[vehicleIndex] |
| | | if (!vehicle) { |
| | | return |
| | | } |
| | | plateKeyboardVehicleIndex.value = vehicleIndex |
| | | plateKeyboardValue.value = buildVehiclePlateNo(vehicle) |
| | | plateKeyboardLang.value = charIndex === 0 ? 'zh' : 'en' |
| | | showPlateKeyboard.value = true |
| | | } |
| | | |
| | | function isPlateInputFocused(vehicleIndex: number, charIndex: number) { |
| | |
| | | } |
| | | |
| | | function focusNextPlateChar(vehicleIndex: number, charIndex: number) { |
| | | nextTick(() => { |
| | | focusedVehicleIndex.value = vehicleIndex |
| | | focusedCharIndex.value = charIndex |
| | | }) |
| | | focusedVehicleIndex.value = vehicleIndex |
| | | focusedCharIndex.value = charIndex |
| | | } |
| | | |
| | | function normalizePlateSegmentValue(rawValue: string) { |
| | |
| | | if (lastFilledIndex < 7) { |
| | | focusNextPlateChar(vehicleIndex, lastFilledIndex + 1) |
| | | } |
| | | } |
| | | |
| | | function handleIntervieweeInput(e: any) { |
| | | formData.interviewee = e?.detail?.value || '' |
| | | } |
| | | |
| | | function handlePlateDelete(vehicleIndex: number, charIndex: number) { |
| | |
| | | showMultiDatePicker.value = true |
| | | } |
| | | |
| | | function applyPlateValueToVehicle(vehicle: AppointmentVehicle, plateValue: string) { |
| | | const normalized = normalizePlateSegmentValue(plateValue).slice(0, 8) |
| | | const [plateRegion = '', ...rest] = normalized.split('') |
| | | const chars = rest.slice(0, 7) |
| | | while (chars.length < 7) { |
| | | chars.push('') |
| | | } |
| | | vehicle.plateRegion = plateRegion |
| | | vehicle.plateCharacters = chars |
| | | const nextIndex = Math.min(normalized.length, 7) |
| | | focusedCharIndex.value = nextIndex |
| | | } |
| | | |
| | | function handleUnifiedPlateInput(value: string) { |
| | | const vehicle = formData.vehicles[plateKeyboardVehicleIndex.value] |
| | | if (!vehicle) { |
| | | return |
| | | } |
| | | plateKeyboardValue.value = value || '' |
| | | applyPlateValueToVehicle(vehicle, plateKeyboardValue.value) |
| | | } |
| | | |
| | | function handlePlateKeyboardClose() { |
| | | showPlateKeyboard.value = false |
| | | } |
| | | |
| | | watch(plateKeyboardValue, (value) => { |
| | | if (!showPlateKeyboard.value) { |
| | | return |
| | | } |
| | | handleUnifiedPlateInput(value || '') |
| | | const nextValue = value || '' |
| | | if (nextValue.length <= 0) { |
| | | plateKeyboardLang.value = 'zh' |
| | | } else if (nextValue.length >= 1) { |
| | | plateKeyboardLang.value = 'en' |
| | | } |
| | | }) |
| | | |
| | | function handleDateTypeChange(type: 'single' | 'multi') { |
| | | if (type === 'multi' && !allowMulti.value) { |
| | | uni.showToast({ |
| | | title: '当前仅支持单日预约', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | formData.dateType = type |
| | | } |
| | | |
| | | function formatMultiDateStartDisplay(value: string) { |
| | | return value ? `${value} 00:00` : '' |
| | | } |
| | | |
| | | function formatMultiDateEndDisplay(value: string) { |
| | | return value ? `${value} 23:59` : '' |
| | | } |
| | | |
| | | function openReasonPicker() { |
| | |
| | | promiseHtml.value = settings.CONTENT_PARK_NOTICE_HTML || '<p>暂无入园承诺</p>' |
| | | pendingSubmitPayload.value = payload |
| | | showPromisePopup.value = true |
| | | void requestApprovalResultSubscribeAuthorization() |
| | | countdown.value = 5 |
| | | startCountdown() |
| | | } |
| | |
| | | } |
| | | |
| | | function handleVehicleEntryChange(enabled: boolean) { |
| | | const driverVisitorIds = formData.vehicles |
| | | .map(vehicle => vehicle.driverVisitorId) |
| | | .filter((id): id is number => Boolean(id)) |
| | | formData.vehicleEntry = enabled |
| | | if (enabled && formData.vehicles.length === 0) { |
| | | formData.vehicles = [createEmptyVehicle()] |
| | | } |
| | | if (!enabled) { |
| | | formData.vehicles = [] |
| | | if (driverVisitorIds.length > 0) { |
| | | const driverIdSet = new Set(driverVisitorIds) |
| | | formData.visitors = formData.visitors.filter(visitor => !driverIdSet.has(visitor.id)) |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | function handleDeleteVehicle(index: number) { |
| | | const targetVehicle = formData.vehicles[index] |
| | | if (!targetVehicle) { |
| | | return |
| | | } |
| | | const removedDriverId = targetVehicle.driverVisitorId |
| | | formData.vehicles.splice(index, 1) |
| | | if (!removedDriverId) { |
| | | return |
| | | } |
| | | const stillUsed = formData.vehicles.some(vehicle => vehicle.driverVisitorId === removedDriverId) |
| | | if (!stillUsed) { |
| | | formData.visitors = formData.visitors.filter(visitor => visitor.id !== removedDriverId) |
| | | } |
| | | } |
| | | |
| | | // 记录当前正在选择驾驶员的车辆索引,-1表示选择的是普通来访人员 |
| | |
| | | |
| | | function handleSelectDriver(index: number) { |
| | | selectingDriverForVehicleIndex.value = index |
| | | const currentDriverId = formData.vehicles[index]?.driverVisitorId |
| | | const occupiedDriverIds = formData.vehicles |
| | | .map(vehicle => vehicle.driverVisitorId) |
| | | .filter((driverVisitorId): driverVisitorId is number => |
| | | Boolean(driverVisitorId) && driverVisitorId !== currentDriverId, |
| | | ) |
| | | uni.navigateTo({ |
| | | url: `/pages/choosePerson/index?mode=driver&vehicleIndex=${index}${formData.vehicles[index]?.driverVisitorId ? `&selectedId=${formData.vehicles[index].driverVisitorId}` : ''}`, |
| | | url: `/pages/choosePerson/index?mode=driver&vehicleIndex=${index}${currentDriverId ? `&selectedId=${currentDriverId}` : ''}${occupiedDriverIds.length ? `&occupiedDriverIds=${occupiedDriverIds.join(',')}` : ''}`, |
| | | }) |
| | | } |
| | | |
| | |
| | | selectingDriverForVehicleIndex.value = -1 |
| | | const selectedIds = formData.visitors.map(item => item.id).join(',') |
| | | uni.navigateTo({ |
| | | url: `/pages/choosePerson/index?mode=pick${selectedIds ? `&selectedIds=${selectedIds}` : ''}`, |
| | | url: `/pages/choosePerson/index?mode=pick${selectedIds ? `&excludedIds=${selectedIds}` : ''}`, |
| | | }) |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | function isDriverUsedByOtherVehicle(driverVisitorId: number, currentVehicleIndex: number) { |
| | | return formData.vehicles.some((vehicle, index) => |
| | | index !== currentVehicleIndex && vehicle.driverVisitorId === driverVisitorId, |
| | | ) |
| | | } |
| | | |
| | | function handleVisitorSelection(payload: { |
| | | mode?: 'pick' | 'driver' |
| | | vehicleIndex?: number |
| | |
| | | } |
| | | const previousDriverId = formData.vehicles[vehicleIndex].driverVisitorId |
| | | const nextDriver = mapVisitorFromSelection(pickedDriver) |
| | | if (isDriverUsedByOtherVehicle(nextDriver.id, vehicleIndex)) { |
| | | uni.showToast({ |
| | | title: '该驾驶员已被其他车辆使用', |
| | | icon: 'none', |
| | | }) |
| | | return |
| | | } |
| | | formData.vehicles[vehicleIndex].driver = `${nextDriver.name}${nextDriver.phone ? `-${nextDriver.phone}` : ''}` |
| | | formData.vehicles[vehicleIndex].driverVisitorId = nextDriver.id |
| | | upsertVisitor(nextDriver, '驾驶员') |
| | |
| | | .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, |
| | | } |
| | | nextVisitors.forEach((visitor) => { |
| | | upsertVisitor(visitor, currentDriverIds.has(visitor.id) ? '驾驶员' : undefined) |
| | | }) |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | function sortVisitReasons(reasons: MiniVisitReasonItem[]) { |
| | | return [...reasons].sort((left, right) => { |
| | | const leftIsOther = left.reasonCode === 'OTHER' || left.reasonName === '其他' |
| | | const rightIsOther = right.reasonCode === 'OTHER' || right.reasonName === '其他' |
| | | if (leftIsOther !== rightIsOther) { |
| | | return leftIsOther ? 1 : -1 |
| | | } |
| | | |
| | | const leftOrder = left.sortOrder ?? Number.MAX_SAFE_INTEGER |
| | | const rightOrder = right.sortOrder ?? Number.MAX_SAFE_INTEGER |
| | | if (leftOrder !== rightOrder) { |
| | | return leftOrder - rightOrder |
| | | } |
| | | return left.id - right.id |
| | | }) |
| | | } |
| | | |
| | | async function loadVisitReasons() { |
| | | try { |
| | | visitReasons.value = await fetchVisitReasons() |
| | | visitReasons.value = sortVisitReasons(await fetchVisitReasons()) |
| | | if (selectedVisitReason.value) { |
| | | const nextReason = visitReasons.value.find(item => item.reasonCode === selectedVisitReason.value?.reasonCode) || null |
| | | selectedVisitReason.value = nextReason |
| | |
| | | } |
| | | } |
| | | |
| | | async function loadFieldConfigs() { |
| | | try { |
| | | fieldConfigs.value = await fetchMiniFieldConfigs() |
| | | if (!showVehicleEntryOption.value) { |
| | | handleVehicleEntryChange(false) |
| | | } |
| | | } |
| | | catch (error) { |
| | | fieldConfigs.value = [] |
| | | console.warn('load field configs failed', error) |
| | | } |
| | | } |
| | | |
| | | async function resolveVisitedUser() { |
| | | const keyword = formData.interviewee.trim() |
| | | if (!keyword) { |
| | | throw new Error('请输入被访人') |
| | | } |
| | | if (selectedVisitedUser.value?.realName === keyword) { |
| | | if (selectedVisitedUser.value?.id && selectedVisitedUser.value?.realName === keyword) { |
| | | return selectedVisitedUser.value |
| | | } |
| | | const users = await searchVisitedUsersByExactName(keyword) |
| | |
| | | const target = visitReasons.value.find(item => item.reasonName === reasonName) || null |
| | | selectedVisitReason.value = target |
| | | formData.reason = target?.reasonName || '' |
| | | if (!showCustomReasonInput.value) { |
| | | customVisitReason.value = '' |
| | | } |
| | | } |
| | | |
| | | function resolveVisitTimeMode() { |
| | |
| | | } |
| | | } |
| | | |
| | | async function prefillDefaultVisitorForCreate() { |
| | | if (rebookOrderId.value || rebookOrderUuid.value || formData.visitors.length > 0) { |
| | | return |
| | | } |
| | | try { |
| | | const visitors = await fetchMyVisitors() |
| | | const defaultVisitor = visitors.find(item => item.isDefault) |
| | | if (!defaultVisitor) { |
| | | return |
| | | } |
| | | if (defaultVisitor.missingFieldCodes?.length) { |
| | | uni.showToast({ |
| | | title: '默认来访人信息不完善,请先完善后再预约', |
| | | icon: 'none', |
| | | duration: 3000, |
| | | }) |
| | | return |
| | | } |
| | | formData.visitors = [mapVisitorFromCurrentList(defaultVisitor)] |
| | | } |
| | | catch (error) { |
| | | console.warn('prefill default visitor failed', error) |
| | | } |
| | | } |
| | | |
| | | 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('原被访人不可用,请重新选择') |
| | | } |
| | | selectedVisitedUser.value = { |
| | | id: detailData.visitedUserId, |
| | | realName: detailData.visitedUserName || '', |
| | | deptName: detailData.visitedDeptName || '', |
| | | } |
| | | visitedOptions.value = [] |
| | | |
| | | const reasonByCode = visitReasons.value.find(item => item.reasonCode === detailData.visitReasonCode) || null |
| | | if (reasonByCode) { |
| | |
| | | warnings.push('原拜访事由不可用,请重新选择') |
| | | } |
| | | } |
| | | customVisitReason.value = detailData.visitReasonCode === VISIT_REASON_CODE_OTHER |
| | | ? detailData.visitReasonOther || '' |
| | | : '' |
| | | customVisitReason.value = detailData.visitReasonOther || '' |
| | | |
| | | const currentVisitorMap = new Map(currentVisitors.map(item => [item.id, item])) |
| | | const restoredVisitors: AppointmentVisitor[] = [] |
| | |
| | | |
| | | async function buildAppointmentPayload(): Promise<MiniAppointmentCreateRequest> { |
| | | const visitedUser = await resolveVisitedUser() |
| | | if (!selectedVisitReason.value?.reasonCode) { |
| | | let visitReasonCode = selectedVisitReason.value?.reasonCode || '' |
| | | if (!visitReasonCode && !isVisitReasonRequired.value) { |
| | | visitReasonCode = visitReasons.value[0]?.reasonCode || '' |
| | | if (visitReasonCode) { |
| | | const fallbackReason = visitReasons.value.find(item => item.reasonCode === visitReasonCode) || null |
| | | selectedVisitReason.value = fallbackReason |
| | | formData.reason = fallbackReason?.reasonName || '' |
| | | } |
| | | } |
| | | if (!visitReasonCode) { |
| | | throw new Error('请选择拜访事由') |
| | | } |
| | | if (showCustomReasonInput.value && !customVisitReason.value.trim()) { |
| | | throw new Error('请输入具体来访事由') |
| | | const visitReasonOther = customVisitReason.value.trim() |
| | | if (!visitReasonOther) { |
| | | throw new Error('请输入事由说明') |
| | | } |
| | | if (visitReasonOther.length > 15) { |
| | | throw new Error('事由说明不能超过15个字') |
| | | } |
| | | if (!formData.visitors.length) { |
| | | throw new Error('请选择来访人员') |
| | |
| | | const visitTimeMode = resolveVisitTimeMode() |
| | | const payload: MiniAppointmentCreateRequest = { |
| | | visitedUserId: visitedUser.id, |
| | | visitReasonCode: selectedVisitReason.value.reasonCode, |
| | | visitReasonCode, |
| | | visitReasonOther, |
| | | 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('请选择拜访时间') |
| | |
| | | if (!formData.vehicles.length) { |
| | | throw new Error('请添加进场车辆') |
| | | } |
| | | const usedDriverIds = new Set<number>() |
| | | payload.vehicles = formData.vehicles.map((item) => { |
| | | const plateNo = buildVehiclePlateNo(item) |
| | | if (!plateNo || !item.driverVisitorId) { |
| | | throw new Error('请完善车辆信息') |
| | | } |
| | | if (usedDriverIds.has(item.driverVisitorId)) { |
| | | throw new Error('不同车辆的驾驶员不能相同') |
| | | } |
| | | usedDriverIds.add(item.driverVisitorId) |
| | | return { |
| | | plateNo, |
| | | driverVisitorId: item.driverVisitorId, |
| | |
| | | showPromisePopup.value = false |
| | | isSubmitting.value = true |
| | | try { |
| | | await requestApprovalResultSubscribeAuthorization() |
| | | const orderId = await createAppointment(pendingSubmitPayload.value) |
| | | resetForm() |
| | | const order = await createAppointment(pendingSubmitPayload.value) |
| | | pendingSubmitPayload.value = null |
| | | uni.redirectTo({ url: `/pages/success/index?orderId=${orderId}` }) |
| | | uni.redirectTo({ url: `/pages/success/index?orderId=${order.id}&orderUuid=${encodeURIComponent(order.uuid)}` }) |
| | | } |
| | | catch (error) { |
| | | uni.showToast({ |
| | |
| | | }, |
| | | ) |
| | | |
| | | watch( |
| | | showVehicleEntryOption, |
| | | (visible) => { |
| | | if (!visible) { |
| | | handleVehicleEntryChange(false) |
| | | } |
| | | }, |
| | | ) |
| | | |
| | | onLoad((options) => { |
| | | uni.$on('selectVisitors', handleVisitorSelection) |
| | | rebookOrderId.value = options?.rebookOrderId ? Number(options.rebookOrderId) : null |
| | | rebookOrderUuid.value = options?.rebookOrderUuid ? decodeURIComponent(options.rebookOrderUuid) : '' |
| | | void Promise.all([ |
| | | loadFieldConfigs(), |
| | | loadBookingSettings(), |
| | | loadVisitReasons(), |
| | | ]).then(() => prefillFromRebookDetail()) |
| | | ]).then(async () => { |
| | | if (rebookOrderId.value && rebookOrderUuid.value) { |
| | | await prefillFromRebookDetail() |
| | | return |
| | | } |
| | | await prefillDefaultVisitorForCreate() |
| | | }) |
| | | }) |
| | | |
| | | onUnload(() => { |
| | |
| | | class="mb-[48rpx] rounded-[16rpx] bg-white pl-[32rpx] pr-[12rpx] py-[8rpx] shadow-[0px_4px_8px_0px_rgba(6,47,108,0.1608)]"> |
| | | <wd-form :model="formData"> |
| | | <!-- 被访人 --> |
| | | <wd-input v-model="formData.interviewee" placeholder="请输入要接见谁" align="right" custom-class="custom-cell" |
| | | placeholder-class="custom-placeholder"> |
| | | <template #label> |
| | | <view class="inline-flex items-start"> |
| | | <text class="text-[28rpx] text-[#333333]">被访人</text> |
| | | <text class="text-[24rpx] text-[#FF4D4F] ml-[4rpx] mt-[-4rpx]">*</text> |
| | | </view> |
| | | </template> |
| | | </wd-input> |
| | | <view class="custom-cell flex items-center justify-between"> |
| | | <view class="inline-flex items-start shrink-0"> |
| | | <text class="text-[28rpx] text-[#333333]">被访人</text> |
| | | <text v-if="isRespondentRequired" class="text-[24rpx] text-[#FF4D4F] ml-[4rpx] mt-[-4rpx]">*</text> |
| | | </view> |
| | | <input |
| | | :value="formData.interviewee" |
| | | type="text" |
| | | placeholder="请输入要拜访谁" |
| | | placeholder-class="text-[#B3B3B3]" |
| | | class="ml-[32rpx] h-[64rpx] flex-1 text-right text-[28rpx] text-[#333333]" |
| | | confirm-type="search" |
| | | :adjust-position="true" |
| | | :cursor-spacing="120" |
| | | @input="handleIntervieweeInput" |
| | | @confirm="handleSearchVisitedUser" |
| | | /> |
| | | </view> |
| | | <view v-if="selectedVisitedUser" class="px-[20rpx] pb-[16rpx] text-right text-[22rpx] text-[#999999]"> |
| | | 已选:{{ selectedVisitedUser.realName }}{{ selectedVisitedUser.deptName ? ` / ${selectedVisitedUser.deptName}` : '' }} |
| | | </view> |
| | |
| | | <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> |
| | | <text v-if="isVisitReasonRequired" class="text-[24rpx] text-[#FF4D4F] ml-[4rpx] mt-[-4rpx]">*</text> |
| | | </view> |
| | | <view class="flex items-center justify-end flex-1"> |
| | | <text class="text-[28rpx]" :class="formData.reason ? 'text-[#333333]' : ''"> |
| | |
| | | <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="请输入具体来访事由" |
| | | maxlength="15" |
| | | placeholder="请输入事由说明" |
| | | placeholder-class="text-[#B3B3B3]" |
| | | /> |
| | | </view> |
| | | |
| | | <!-- 拜访时间类型切换 --> |
| | | <wd-cell custom-class="custom-cell cell-with-switch"> |
| | | <template #title> |
| | | <view class="inline-flex items-start"> |
| | | <text class="text-[26rpx] text-[#333333]">拜访时间</text> |
| | | <text class="text-[28rpx] text-[#FF4D4F] ml-[4rpx] mt-[-4rpx]">*</text> |
| | | </view> |
| | | </template> |
| | | <view class="custom-cell flex items-center justify-between"> |
| | | <view class="inline-flex items-start"> |
| | | <text class="text-[26rpx] text-[#333333]">拜访时间</text> |
| | | <text v-if="isVisitTimeRequired" class="text-[28rpx] text-[#FF4D4F] ml-[4rpx] mt-[-4rpx]">*</text> |
| | | </view> |
| | | <view class="flex justify-end"> |
| | | <view |
| | | 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="handleDateTypeChange('single')"> |
| | | class="h-[48rpx] w-[240rpx] flex rounded-full border border-[#0068FF] bg-white box-border overflow-hidden"> |
| | | <view |
| | | class="flex w-[120rpx] items-center justify-center text-[24rpx] transition-all" |
| | | :class="[ |
| | | 'border-r border-[#0068FF]', |
| | | formData.dateType === 'single' ? 'bg-[#0068FF] text-white' : 'bg-white text-[#0068FF]', |
| | | ]" |
| | | @tap.stop="handleDateTypeChange('single')" |
| | | @click.stop="handleDateTypeChange('single')" |
| | | > |
| | | 单日 |
| | | </view> |
| | | <view class="flex w-[120rpx] items-center justify-center text-[24rpx] transition-all" |
| | | <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]', |
| | | formData.dateType === 'multi' ? 'bg-[#0068FF] text-white' : 'bg-white text-[#0068FF]', |
| | | !allowMulti ? 'opacity-40' : '', |
| | | ]" |
| | | @click="handleDateTypeChange('multi')"> |
| | | @tap.stop="handleDateTypeChange('multi')" |
| | | @click.stop="handleDateTypeChange('multi')" |
| | | > |
| | | 多日 |
| | | </view> |
| | | </view> |
| | | </view> |
| | | </wd-cell> |
| | | </view> |
| | | |
| | | <!-- 单日时间选择 --> |
| | | <view v-if="formData.dateType === 'single'" class="custom-cell flex items-center justify-between" @click="openTimePicker('single')"> |
| | |
| | | <text class="text-[28rpx] text-[#333333]">开始时间</text> |
| | | <view class="flex items-center justify-end flex-1"> |
| | | <text class="text-[28rpx]" :class="formData.visitTimeMultiStart ? 'text-[#333333]' : 'text-[#999999]'"> |
| | | {{ formData.visitTimeMultiStart || '请选择开始时间' }} |
| | | {{ formatMultiDateStartDisplay(formData.visitTimeMultiStart) || '请选择开始时间' }} |
| | | </text> |
| | | <wd-icon name="arrow-right" size="32rpx" color="#999999" custom-class="ml-[8rpx]" /> |
| | | </view> |
| | |
| | | <text class="text-[28rpx] text-[#333333]">结束时间</text> |
| | | <view class="flex items-center justify-end flex-1"> |
| | | <text class="text-[28rpx]" :class="formData.visitTimeMultiEnd ? 'text-[#333333]' : 'text-[#999999]'"> |
| | | {{ formData.visitTimeMultiEnd || '请选择结束时间' }} |
| | | {{ formatMultiDateEndDisplay(formData.visitTimeMultiEnd) || '请选择结束时间' }} |
| | | </text> |
| | | <wd-icon name="arrow-right" size="32rpx" color="#999999" custom-class="ml-[8rpx]" /> |
| | | </view> |
| | |
| | | <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="'border-[#1677FF]'"> |
| | | <view class="w-[16rpx] h-[16rpx] rounded-full bg-[#1677FF]"> |
| | | :class="'border-[#CFCFCF]'"> |
| | | <view class="w-[16rpx] h-[16rpx] rounded-full bg-[#CFCFCF]"> |
| | | </view> |
| | | </view> |
| | | <text class="ml-[12rpx] text-[28rpx] text-[#333333]">人员进场</text> |
| | | <text class="ml-[12rpx] text-[28rpx] text-[#999999]">人员进场</text> |
| | | </view> |
| | | <view class="flex items-center" @click="handleVehicleEntryChange(!formData.vehicleEntry)"> |
| | | <view v-if="showVehicleEntryOption" 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.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.vehicleEntry ? 'text-[#333333]' : 'text-[#999999]'">车辆进场</text> |
| | | <text class="ml-[12rpx] text-[28rpx]" :class="formData.vehicleEntry ? 'text-[#333333]' : 'text-[#999999]'">接送货</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | |
| | | :class="{ 'plate-box-active': focusedVehicleIndex === index && focusedCharIndex === 0 }" |
| | | @click="handlePlateCharClick(index, 0)" |
| | | > |
| | | <input |
| | | class="plate-input" |
| | | :value="vehicle.plateRegion" |
| | | :focus="isPlateInputFocused(index, 0)" |
| | | maxlength="1" |
| | | @input="handlePlateInput($event, index, 0)" |
| | | @focus="handlePlateCharClick(index, 0)" |
| | | @keydown.delete="handlePlateDelete(index, 0)" |
| | | /> |
| | | <text class="plate-input-text">{{ vehicle.plateRegion }}</text> |
| | | </view> |
| | | <!-- 第二格:字母 --> |
| | | <view |
| | |
| | | :class="{ 'plate-box-active': focusedVehicleIndex === index && focusedCharIndex === 1 }" |
| | | @click="handlePlateCharClick(index, 1)" |
| | | > |
| | | <input |
| | | class="plate-input" |
| | | :value="vehicle.plateCharacters[0]" |
| | | :focus="isPlateInputFocused(index, 1)" |
| | | maxlength="1" |
| | | @input="handlePlateInput($event, index, 1)" |
| | | @focus="handlePlateCharClick(index, 1)" |
| | | @keydown.delete="handlePlateDelete(index, 1)" |
| | | /> |
| | | <text class="plate-input-text">{{ vehicle.plateCharacters[0] }}</text> |
| | | </view> |
| | | <!-- 圆点 --> |
| | | <text class="text-[28rpx] text-[#333333] font-bold mx-[4rpx]">·</text> |
| | |
| | | :class="{ 'plate-box-active': focusedVehicleIndex === index && focusedCharIndex === i + 1 }" |
| | | @click="handlePlateCharClick(index, i + 1)" |
| | | > |
| | | <input |
| | | class="plate-input" |
| | | :value="vehicle.plateCharacters[i]" |
| | | :focus="isPlateInputFocused(index, i + 1)" |
| | | maxlength="1" |
| | | @input="handlePlateInput($event, index, i + 1)" |
| | | @focus="handlePlateCharClick(index, i + 1)" |
| | | @keydown.delete="handlePlateDelete(index, i + 1)" |
| | | /> |
| | | <text class="plate-input-text">{{ vehicle.plateCharacters[i] }}</text> |
| | | </view> |
| | | <!-- 新能源位 --> |
| | | <view |
| | |
| | | :class="{ 'plate-box-active': focusedVehicleIndex === index && focusedCharIndex === 7 }" |
| | | @click="handlePlateCharClick(index, 7)" |
| | | > |
| | | <input |
| | | class="plate-input" |
| | | :value="vehicle.plateCharacters[6]" |
| | | :focus="isPlateInputFocused(index, 7)" |
| | | maxlength="1" |
| | | @input="handlePlateInput($event, index, 7)" |
| | | @focus="handlePlateCharClick(index, 7)" |
| | | @keydown.delete="handlePlateDelete(index, 7)" |
| | | /> |
| | | <text class="plate-input-text">{{ vehicle.plateCharacters[6] }}</text> |
| | | <text v-if="!vehicle.plateCharacters[6]" class="energy-placeholder">新能源</text> |
| | | </view> |
| | | </view> |
| | |
| | | class="visitor-row flex items-center justify-between px-[8rpx] py-[20rpx]" |
| | | > |
| | | <view class="flex items-center min-w-0 flex-1"> |
| | | <text class="w-[72rpx] shrink-0 text-[28rpx] text-[#333333]"> |
| | | <text class="w-[96rpx] shrink-0 truncate text-[28rpx] text-[#333333]"> |
| | | {{ visitor.name }} |
| | | </text> |
| | | <view class="ml-[20rpx] min-w-0 flex-1"> |
| | | <view class="ml-[12rpx] min-w-0 flex-1"> |
| | | <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.idCardMasked || '-' }} |
| | | {{ visitor.idCardMasked || '-' }} |
| | | </text> |
| | | </view> |
| | | <view class="mt-[8rpx] flex items-center min-w-0"> |
| | |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view class="ml-[16rpx] flex items-center shrink-0"> |
| | | <view class="ml-[8rpx] flex items-center shrink-0"> |
| | | <view |
| | | v-if="visitor.badge" |
| | | class="rounded-full border border-[#1677FF] border-solid px-[20rpx] py-[8rpx] text-[24rpx] text-[#1677FF]" |
| | | class="rounded-full border border-[#1677FF] border-solid px-[16rpx] py-[8rpx] text-[24rpx] text-[#1677FF]" |
| | | > |
| | | {{ visitor.badge }} |
| | | </view> |
| | |
| | | :columns="reasonColumns" |
| | | :item-height="48" |
| | | :columns-height="144" |
| | | :immediate-change="true" |
| | | custom-class="custom-picker-view" |
| | | /> |
| | | </view> |
| | |
| | | </wd-popup> |
| | | <!-- 自定义拜访时间弹窗 (单日) --> |
| | | <wd-popup v-model="showTimePicker" position="bottom" custom-class="time-popup" z-index="100"> |
| | | <view class="flex flex-col bg-white rounded-t-[32rpx] overflow-hidden h-[830rpx]"> |
| | | <view class="flex flex-col bg-white rounded-t-[32rpx] overflow-hidden h-[608rpx]"> |
| | | <!-- 弹窗头部 --> |
| | | <view class="relative flex items-center justify-center shrink-0 h-[96rpx] border-b border-[#F5F5F5] border-solid"> |
| | | <view class="absolute left-[32rpx] flex items-center justify-center w-[48rpx] h-[48rpx]" |
| | |
| | | </view> |
| | | |
| | | <!-- 弹窗内容区 --> |
| | | <view class="px-[32rpx] flex-1 flex flex-col justify-center relative"> |
| | | <view class="px-[32rpx] pt-[20rpx] flex-1 flex flex-col justify-start"> |
| | | <!-- 头部列名 --> |
| | | <view class="flex items-center absolute top-[40rpx] left-[32rpx] right-[32rpx] z-10 text-[24rpx] text-[#333333] font-medium"> |
| | | <view class="mb-[12rpx] flex items-center text-[24rpx] text-[#333333] font-medium"> |
| | | <view class="flex-1 flex justify-center"><text class="bg-[#F5F5F5] rounded-[8rpx] px-[24rpx] py-[8rpx]">年</text></view> |
| | | <view class="flex-1 flex justify-center"><text class="bg-[#F5F5F5] rounded-[8rpx] px-[24rpx] py-[8rpx]">月</text></view> |
| | | <view class="flex-1 flex justify-center"><text class="bg-[#F5F5F5] rounded-[8rpx] px-[24rpx] py-[8rpx]">日</text></view> |
| | | <view v-if="timeColumns[3]?.length > 0" class="w-[204rpx] flex justify-center shrink-0"><text class="bg-[#F5F5F5] rounded-[8rpx] px-[48rpx] py-[8rpx]">时段</text></view> |
| | | </view> |
| | | <picker-view |
| | | class="custom-picker-view time-picker-view mt-[60rpx]" |
| | | class="custom-picker-view time-picker-view" |
| | | :value="timePickerIndex" |
| | | :immediate-change="true" |
| | | indicator-style="height: 48px;" |
| | | style="width: 100%; height: 192px;" |
| | | style="width: 100%; height: 144px;" |
| | | @change="handleTimeChange" |
| | | > |
| | | <picker-view-column v-for="(col, index) in timeColumns" :key="index" class="wd-picker-view__column"> |
| | |
| | | </view> |
| | | |
| | | <!-- 弹窗底部按钮 --> |
| | | <view class="px-[32rpx] pb-[48rpx] shrink-0"> |
| | | <view class="px-[32rpx] pb-[8rpx] shrink-0"> |
| | | <wd-button type="primary" size="large" block custom-class="popup-submit-btn" |
| | | @click="handleTimeConfirm"> |
| | | 提交 |
| | |
| | | </wd-popup> |
| | | |
| | | <wd-popup v-model="showMultiDatePicker" position="bottom" custom-class="multi-date-popup" z-index="100"> |
| | | <view class="flex flex-col bg-white rounded-t-[32rpx] overflow-hidden h-[830rpx]"> |
| | | <view class="flex flex-col bg-white rounded-t-[32rpx] overflow-hidden h-[608rpx]"> |
| | | <view class="relative flex items-center justify-center shrink-0 h-[96rpx] border-b border-[#F5F5F5] border-solid"> |
| | | <view class="absolute left-[32rpx] flex items-center justify-center w-[48rpx] h-[48rpx]" |
| | | @click="showMultiDatePicker = false"> |
| | |
| | | <text class="text-[32rpx] text-[#333333] font-medium">选择时间</text> |
| | | </view> |
| | | |
| | | <view class="px-[32rpx] flex-1 flex flex-col justify-center relative"> |
| | | <view class="flex items-center absolute top-[40rpx] left-[32rpx] right-[32rpx] z-10 text-[24rpx] text-[#333333] font-medium"> |
| | | <view class="px-[32rpx] pt-[20rpx] flex-1 flex flex-col justify-start"> |
| | | <view class="mb-[12rpx] flex items-center text-[24rpx] text-[#333333] font-medium"> |
| | | <view class="flex-1 flex justify-center"><text class="bg-[#F5F5F5] rounded-[8rpx] px-[24rpx] py-[8rpx]">年</text></view> |
| | | <view class="flex-1 flex justify-center"><text class="bg-[#F5F5F5] rounded-[8rpx] px-[24rpx] py-[8rpx]">月</text></view> |
| | | <view class="flex-1 flex justify-center"><text class="bg-[#F5F5F5] rounded-[8rpx] px-[24rpx] py-[8rpx]">日</text></view> |
| | | </view> |
| | | <picker-view |
| | | class="custom-picker-view multi-date-picker-view mt-[60rpx]" |
| | | class="custom-picker-view multi-date-picker-view" |
| | | :value="multiDatePickerIndex" |
| | | :immediate-change="true" |
| | | indicator-style="height: 48px;" |
| | | style="width: 100%; height: 192px;" |
| | | style="width: 100%; height: 144px;" |
| | | @change="handleMultiDateChange" |
| | | > |
| | | <picker-view-column |
| | |
| | | </picker-view> |
| | | </view> |
| | | |
| | | <view class="px-[32rpx] pb-[48rpx] shrink-0"> |
| | | <view class="px-[32rpx] pb-[8rpx] shrink-0"> |
| | | <wd-button type="primary" size="large" block custom-class="popup-submit-btn" |
| | | @click="handleMultiDateConfirm"> |
| | | 提交 |
| | |
| | | </view> |
| | | </view> |
| | | </wd-popup> |
| | | |
| | | <wd-keyboard |
| | | v-model:visible="showPlateKeyboard" |
| | | v-model:model-value="plateKeyboardValue" |
| | | mode="car" |
| | | :maxlength="8" |
| | | :car-lang="plateKeyboardLang" |
| | | :auto-switch-lang="true" |
| | | close-text="完成" |
| | | delete-text="删除" |
| | | :hide-on-click-outside="true" |
| | | @update:car-lang="plateKeyboardLang = $event" |
| | | @close="handlePlateKeyboardClose" |
| | | /> |
| | | </view> |
| | | </template> |
| | | |
| | |
| | | color: #333333; |
| | | font-weight: 600; |
| | | background: transparent; |
| | | } |
| | | |
| | | .plate-input-text { |
| | | width: 100%; |
| | | text-align: center; |
| | | font-size: 28rpx; |
| | | color: #333333; |
| | | font-weight: 600; |
| | | line-height: 56rpx; |
| | | } |
| | | |
| | | .plate-box-energy { |
| | |
| | | color: #B3B3B3 !important; |
| | | font-size: 28rpx !important; |
| | | background-color: transparent !important; |
| | | transition: all 0.3s; |
| | | transition: color 0.08s linear, background-color 0.08s linear; |
| | | z-index: 10; |
| | | position: relative; |
| | | } |
| | |
| | | :deep(.custom-picker-view .wd-picker-view-column__item) { |
| | | font-size: 28rpx; |
| | | color: #999999; |
| | | transition: all 0.3s; |
| | | transition: color 0.08s linear, background-color 0.08s linear; |
| | | position: relative; |
| | | z-index: 10; |
| | | } |