|
|
@@ -0,0 +1,647 @@
|
|
|
+<template>
|
|
|
+ <view class="visit-add-page">
|
|
|
+ <scroll-view
|
|
|
+ class="visit-add-page__scroll"
|
|
|
+ scroll-y
|
|
|
+ enhanced
|
|
|
+ :show-scrollbar="false"
|
|
|
+ :bounces="false"
|
|
|
+ >
|
|
|
+ <view class="visit-add-page__content" :style="contentStyle">
|
|
|
+ <VisitCheckInMap
|
|
|
+ :current-latitude="currentLocation?.latitude"
|
|
|
+ :current-longitude="currentLocation?.longitude"
|
|
|
+ :selected-latitude="selectedPoint?.latitude"
|
|
|
+ :selected-longitude="selectedPoint?.longitude"
|
|
|
+ :selected-address="selectedPoint?.address || ''"
|
|
|
+ :check-in-time="checkInTime"
|
|
|
+ :field-title="addressField?.taskFiledValue || '拜访地址'"
|
|
|
+ :field-required="addressField?.isMustfill === '1'"
|
|
|
+ :target-type-name="targetTypeName"
|
|
|
+ :location-status="locationStatus"
|
|
|
+ :range-radius="rangeRadius"
|
|
|
+ :remaining-seconds="remainingSeconds"
|
|
|
+ :countdown-text="countdownText"
|
|
|
+ :form-ready="visitFormReady"
|
|
|
+ :selecting="pointSelecting"
|
|
|
+ :checking-in="checkInLoading"
|
|
|
+ @point-tap="handlePointTap"
|
|
|
+ @refresh-location="refreshCurrentLocation"
|
|
|
+ @check-in="handleCheckIn"
|
|
|
+ @reset="resetCheckIn"
|
|
|
+ />
|
|
|
+
|
|
|
+ <view v-if="showNotice" class="visit-add-page__notice">
|
|
|
+ <wd-notice-bar
|
|
|
+ :text="noticeText"
|
|
|
+ prefix="warn-bold"
|
|
|
+ type="info"
|
|
|
+ color="#f90"
|
|
|
+ background-color="#fdf6ec"
|
|
|
+ custom-style="border-radius: 12rpx; min-height: 64rpx; padding: 0 20rpx;"
|
|
|
+ />
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <view v-if="formLoading" class="visit-add-page__state">正在加载表单...</view>
|
|
|
+
|
|
|
+ <view v-else-if="formErrorMessage" class="visit-add-page__state">
|
|
|
+ <view>{{ formErrorMessage }}</view>
|
|
|
+ <button class="visit-add-page__retry" @click="loadVisitForm">重新加载</button>
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <template v-else>
|
|
|
+ <view v-if="formCompatibilityError" class="visit-add-page__config-error">
|
|
|
+ {{ formCompatibilityError }}
|
|
|
+ </view>
|
|
|
+
|
|
|
+ <DynamicTaskFormFields
|
|
|
+ v-model="form"
|
|
|
+ :task-field-config-list="taskFieldConfigList"
|
|
|
+ :active-task-type-id="activeTaskTypeId"
|
|
|
+ :task-types="taskTypes"
|
|
|
+ :select-level="selectLevel"
|
|
|
+ :hidden-field-types="hiddenFieldTypes"
|
|
|
+ :hidden-field-keys="hiddenFieldKeys"
|
|
|
+ :hidden-field-aliases="hiddenFieldAliases"
|
|
|
+ @derived-change="handleDerivedFieldChange"
|
|
|
+ />
|
|
|
+ </template>
|
|
|
+ </view>
|
|
|
+ </scroll-view>
|
|
|
+
|
|
|
+ <DuplicateImageDialog
|
|
|
+ v-model="duplicateImageVisible"
|
|
|
+ :images="duplicateImageList"
|
|
|
+ @confirm="clearDuplicateImages"
|
|
|
+ />
|
|
|
+
|
|
|
+ <view
|
|
|
+ v-if="taskFieldConfigList.length"
|
|
|
+ class="visit-add-page__footer"
|
|
|
+ :style="footerStyle"
|
|
|
+ >
|
|
|
+ <button
|
|
|
+ class="visit-add-page__submit"
|
|
|
+ hover-class="visit-add-page__submit--hover"
|
|
|
+ :disabled="isActionPending || formLoading"
|
|
|
+ @click="handleVisitSubmit"
|
|
|
+ >
|
|
|
+ 提交
|
|
|
+ </button>
|
|
|
+ </view>
|
|
|
+ </view>
|
|
|
+</template>
|
|
|
+
|
|
|
+<script setup lang="ts">
|
|
|
+import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
|
|
+
|
|
|
+import { onLoad } from '@dcloudio/uni-app'
|
|
|
+
|
|
|
+import { reverseGeocodeByCoordinate } from '@/lib/location'
|
|
|
+
|
|
|
+import { getLocaltimeApi, getSignListByUserIdApi } from '@/services/modules/task/taskVisit'
|
|
|
+import type { VisitSignRecordItem } from '@/services/modules/task/taskVisit/type'
|
|
|
+
|
|
|
+import { useUserStore } from '@/stores/modules/user'
|
|
|
+
|
|
|
+import DuplicateImageDialog from '../task-form/components/DuplicateImageDialog.vue'
|
|
|
+import DynamicTaskFormFields from '../task-form/components/DynamicTaskFormFields.vue'
|
|
|
+import type { TaskFieldConfigViewItem } from '../task-form/composables/useTaskForm'
|
|
|
+import { useTaskForm } from '../task-form/composables/useTaskForm'
|
|
|
+import { useTaskFormAction } from '../task-form/composables/useTaskFormAction'
|
|
|
+import { useTaskFormFields } from '../task-form/composables/useTaskFormFields'
|
|
|
+import { useTaskFormPageFeatures } from '../task-form/composables/useTaskFormPageFeatures'
|
|
|
+import { useTaskFormSafeArea } from '../task-form/composables/useTaskFormSafeArea'
|
|
|
+import { useVisitLocation } from '../task-visit/composables/useVisitLocation'
|
|
|
+import { getDistanceInMeters } from '../task-visit/utils/distance'
|
|
|
+import VisitCheckInMap from './components/VisitCheckInMap.vue'
|
|
|
+
|
|
|
+interface PageLoadOptions {
|
|
|
+ taskTypeId?: string
|
|
|
+ taskTitle?: string
|
|
|
+}
|
|
|
+
|
|
|
+interface MapPoiTapEvent {
|
|
|
+ detail?: {
|
|
|
+ latitude?: number
|
|
|
+ longitude?: number
|
|
|
+ name?: string
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+interface SelectedPoint {
|
|
|
+ latitude: number
|
|
|
+ longitude: number
|
|
|
+ address: string
|
|
|
+ district: string
|
|
|
+ name: string
|
|
|
+}
|
|
|
+
|
|
|
+interface ReverseGeocoderRaw {
|
|
|
+ result?: {
|
|
|
+ formatted_addresses?: {
|
|
|
+ recommend?: string
|
|
|
+ }
|
|
|
+ address_component?: {
|
|
|
+ province?: string
|
|
|
+ city?: string
|
|
|
+ district?: string
|
|
|
+ }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const TASK_TYPE_META: Readonly<Record<string, { typeName: string; title: string }>> = {
|
|
|
+ '82': { typeName: '药店', title: '新增药店拜访' },
|
|
|
+ '83': { typeName: '医院', title: '新增医院拜访' },
|
|
|
+ '84': { typeName: '商业公司', title: '新增商业公司拜访' },
|
|
|
+}
|
|
|
+
|
|
|
+const GLOBAL_COOLDOWN_SECONDS = 30 * 60
|
|
|
+const hiddenFieldTypes = ['map', 'location']
|
|
|
+const hiddenFieldKeys = ['temp30']
|
|
|
+const hiddenFieldAliases = ['taskDate']
|
|
|
+
|
|
|
+const userStore = useUserStore()
|
|
|
+const taskTypeId = ref('')
|
|
|
+const formLoading = ref(true)
|
|
|
+const formErrorMessage = ref('')
|
|
|
+const selectedPoint = ref<SelectedPoint | null>(null)
|
|
|
+const checkInTime = ref('')
|
|
|
+const pointSelecting = ref(false)
|
|
|
+const checkInLoading = ref(false)
|
|
|
+const signRecords = ref<VisitSignRecordItem[]>([])
|
|
|
+const cooldownReady = ref(false)
|
|
|
+const now = ref(Date.now())
|
|
|
+
|
|
|
+const {
|
|
|
+ form,
|
|
|
+ taskFieldConfigList,
|
|
|
+ activeTaskTypeId,
|
|
|
+ taskTypes,
|
|
|
+ loadTaskForm,
|
|
|
+ handleDerivedFieldChange,
|
|
|
+} = useTaskForm()
|
|
|
+
|
|
|
+const {
|
|
|
+ isActionPending,
|
|
|
+ duplicateImageVisible,
|
|
|
+ duplicateImageList,
|
|
|
+ clearDuplicateImages,
|
|
|
+ handleSubmit,
|
|
|
+} = useTaskFormAction({
|
|
|
+ taskTypeId: activeTaskTypeId,
|
|
|
+ form,
|
|
|
+ taskFieldConfigList,
|
|
|
+})
|
|
|
+
|
|
|
+const { setFieldFormValue, clearFieldValue } = useTaskFormFields({
|
|
|
+ form,
|
|
|
+ taskFieldConfigList,
|
|
|
+})
|
|
|
+
|
|
|
+const { showNotice, noticeText, selectLevel, initializeTaskFormPage } = useTaskFormPageFeatures()
|
|
|
+const { contentStyle, footerStyle, initSafeArea } = useTaskFormSafeArea()
|
|
|
+const { currentLocation, locationStatus, rangeRadius, refreshCurrentLocation } = useVisitLocation()
|
|
|
+
|
|
|
+const userId = computed(() => String(userStore.currentUserInfo?.userId ?? '').trim())
|
|
|
+const taskTypeMeta = computed(() => TASK_TYPE_META[taskTypeId.value])
|
|
|
+const targetTypeName = computed(() => taskTypeMeta.value?.typeName || '地点')
|
|
|
+
|
|
|
+const findField = (predicate: (field: TaskFieldConfigViewItem) => boolean) => {
|
|
|
+ return taskFieldConfigList.value.find(predicate)
|
|
|
+}
|
|
|
+
|
|
|
+const findFieldByAliases = (aliases: readonly string[]) => {
|
|
|
+ return findField((field) => Boolean(field.alias) && aliases.includes(field.alias as string))
|
|
|
+}
|
|
|
+
|
|
|
+const coordinateField = computed(() => {
|
|
|
+ return (
|
|
|
+ findField((field) => field.taskFiledType === 'map') ??
|
|
|
+ findFieldByAliases(['coordinates', 'coordinate', 'locationCoordinate'])
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+const addressField = computed(() => {
|
|
|
+ return (
|
|
|
+ findField((field) => field.taskFiledType === 'location') ??
|
|
|
+ findFieldByAliases(['visitAddress', 'signAddress'])
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+const checkInField = computed(() => {
|
|
|
+ return findFieldByAliases(['taskDate', 'checkInTime', 'signDate'])
|
|
|
+})
|
|
|
+
|
|
|
+const districtField = computed(() => {
|
|
|
+ return findFieldByAliases(['shengShiQu', 'provinceCityDistrict'])
|
|
|
+})
|
|
|
+
|
|
|
+const targetNameField = computed(() => {
|
|
|
+ return (
|
|
|
+ findField((field) => field.taskFiledValue.trim() === '拜访对象名称') ??
|
|
|
+ findFieldByAliases(['visitTargetName', 'targetName', 'signEntName'])
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+const taskTypeField = computed(() => {
|
|
|
+ return (
|
|
|
+ findField((field) => field.taskFiledKey === 'temp30') ??
|
|
|
+ findFieldByAliases(['sourceTaskTypeId'])
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+const formCompatibilityError = computed(() => {
|
|
|
+ if (!taskFieldConfigList.value.length) return ''
|
|
|
+
|
|
|
+ const missingFields = [
|
|
|
+ coordinateField.value ? '' : '经纬度字段',
|
|
|
+ addressField.value ? '' : '打卡地址字段',
|
|
|
+ checkInField.value ? '' : '打卡时间字段',
|
|
|
+ ].filter(Boolean)
|
|
|
+
|
|
|
+ return missingFields.length ? `表单配置缺少${missingFields.join('、')},请联系管理员。` : ''
|
|
|
+})
|
|
|
+
|
|
|
+const visitFormReady = computed(() => {
|
|
|
+ return (
|
|
|
+ !formLoading.value &&
|
|
|
+ !formErrorMessage.value &&
|
|
|
+ !formCompatibilityError.value &&
|
|
|
+ Boolean(taskFieldConfigList.value.length) &&
|
|
|
+ cooldownReady.value
|
|
|
+ )
|
|
|
+})
|
|
|
+
|
|
|
+const cooldownDeadline = computed(() => {
|
|
|
+ let latestSignTime = 0
|
|
|
+
|
|
|
+ for (const record of signRecords.value) {
|
|
|
+ latestSignTime = Math.max(latestSignTime, parseSignTime(record.signDate))
|
|
|
+ }
|
|
|
+
|
|
|
+ return latestSignTime + GLOBAL_COOLDOWN_SECONDS * 1000
|
|
|
+})
|
|
|
+
|
|
|
+const remainingSeconds = computed(() => {
|
|
|
+ return Math.max(0, Math.ceil((cooldownDeadline.value - now.value) / 1000))
|
|
|
+})
|
|
|
+
|
|
|
+const countdownText = computed(() => formatCountdown(remainingSeconds.value))
|
|
|
+
|
|
|
+let clockTimer: ReturnType<typeof setInterval> | undefined
|
|
|
+
|
|
|
+const setFieldText = (field: TaskFieldConfigViewItem | undefined, value: string) => {
|
|
|
+ if (!field) return
|
|
|
+ setFieldFormValue(field, { label: value, value })
|
|
|
+}
|
|
|
+
|
|
|
+const syncTaskTypeField = () => {
|
|
|
+ setFieldText(taskTypeField.value, taskTypeId.value)
|
|
|
+}
|
|
|
+
|
|
|
+const syncSelectedPoint = (point: SelectedPoint) => {
|
|
|
+ setFieldText(coordinateField.value, `${point.longitude},${point.latitude}`)
|
|
|
+ setFieldText(addressField.value, point.address)
|
|
|
+ setFieldText(districtField.value, point.district)
|
|
|
+ setFieldText(targetNameField.value, point.name)
|
|
|
+ syncTaskTypeField()
|
|
|
+}
|
|
|
+
|
|
|
+const loadVisitForm = async () => {
|
|
|
+ if (!taskTypeId.value) return
|
|
|
+
|
|
|
+ formLoading.value = true
|
|
|
+ formErrorMessage.value = ''
|
|
|
+
|
|
|
+ try {
|
|
|
+ await loadTaskForm(taskTypeId.value)
|
|
|
+ syncTaskTypeField()
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[task-visit-add] 表单配置加载失败', error)
|
|
|
+ formErrorMessage.value = '表单加载失败,请稍后重试'
|
|
|
+ } finally {
|
|
|
+ formLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const loadSignRecords = async () => {
|
|
|
+ const signUserid = userId.value
|
|
|
+
|
|
|
+ if (!signUserid) {
|
|
|
+ signRecords.value = []
|
|
|
+ cooldownReady.value = false
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ cooldownReady.value = false
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await getSignListByUserIdApi({ signUserid })
|
|
|
+ signRecords.value = Array.isArray(res.data) ? res.data : []
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[task-visit-add] 打卡记录加载失败', error)
|
|
|
+ signRecords.value = []
|
|
|
+ uni.showToast({ title: '打卡记录加载失败', icon: 'none' })
|
|
|
+ } finally {
|
|
|
+ now.value = Date.now()
|
|
|
+ cooldownReady.value = true
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handlePointTap = async (event: MapPoiTapEvent) => {
|
|
|
+ if (!visitFormReady.value) {
|
|
|
+ showToast(formCompatibilityError.value || '表单或打卡状态尚未就绪')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (checkInTime.value) {
|
|
|
+ showToast('已完成打卡,请先重新选择')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (remainingSeconds.value > 0) {
|
|
|
+ showToast(`${countdownText.value} 后可再次打卡`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const latitude = event.detail?.latitude
|
|
|
+ const longitude = event.detail?.longitude
|
|
|
+ const current = currentLocation.value
|
|
|
+
|
|
|
+ if (typeof latitude !== 'number' || typeof longitude !== 'number') return
|
|
|
+
|
|
|
+ if (!current) {
|
|
|
+ showToast('正在获取当前位置,请稍后重试')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const distance = getDistanceInMeters(
|
|
|
+ current.longitude,
|
|
|
+ current.latitude,
|
|
|
+ longitude,
|
|
|
+ latitude
|
|
|
+ )
|
|
|
+
|
|
|
+ if (distance > rangeRadius.value) {
|
|
|
+ showToast(`不能超出当前位置 ${rangeRadius.value} 米范围`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ pointSelecting.value = true
|
|
|
+
|
|
|
+ try {
|
|
|
+ const location = await reverseGeocodeByCoordinate(latitude, longitude)
|
|
|
+ const raw = location.raw as ReverseGeocoderRaw | undefined
|
|
|
+ const recommendedName = raw?.result?.formatted_addresses?.recommend?.trim()
|
|
|
+ const pointName = event.detail?.name?.trim() || recommendedName || location.address
|
|
|
+ const district = formatDistrict(raw, location.provinceCityDistrict)
|
|
|
+
|
|
|
+ const point: SelectedPoint = {
|
|
|
+ latitude,
|
|
|
+ longitude,
|
|
|
+ address: location.address,
|
|
|
+ district,
|
|
|
+ name: pointName,
|
|
|
+ }
|
|
|
+
|
|
|
+ selectedPoint.value = point
|
|
|
+ syncSelectedPoint(point)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[task-visit-add] 地点解析失败', error)
|
|
|
+ showToast('获取地点信息失败')
|
|
|
+ } finally {
|
|
|
+ pointSelecting.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const handleCheckIn = async () => {
|
|
|
+ if (!selectedPoint.value) {
|
|
|
+ showToast(`请先选择要打卡的${targetTypeName.value}`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (remainingSeconds.value > 0) {
|
|
|
+ showToast(`${countdownText.value} 后可再次打卡`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (checkInLoading.value || checkInTime.value) return
|
|
|
+
|
|
|
+ checkInLoading.value = true
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await getLocaltimeApi()
|
|
|
+ const localtime = String(res.data ?? '').trim()
|
|
|
+
|
|
|
+ if (!localtime) {
|
|
|
+ throw new Error('Empty localtime')
|
|
|
+ }
|
|
|
+
|
|
|
+ checkInTime.value = localtime
|
|
|
+ setFieldText(checkInField.value, localtime)
|
|
|
+ } catch (error) {
|
|
|
+ console.error('[task-visit-add] 打卡失败', error)
|
|
|
+ showToast('打卡失败,请稍后重试')
|
|
|
+ } finally {
|
|
|
+ checkInLoading.value = false
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const resetCheckIn = () => {
|
|
|
+ selectedPoint.value = null
|
|
|
+ checkInTime.value = ''
|
|
|
+
|
|
|
+ for (const field of [
|
|
|
+ coordinateField.value,
|
|
|
+ addressField.value,
|
|
|
+ districtField.value,
|
|
|
+ targetNameField.value,
|
|
|
+ checkInField.value,
|
|
|
+ ]) {
|
|
|
+ if (field) clearFieldValue(field)
|
|
|
+ }
|
|
|
+
|
|
|
+ syncTaskTypeField()
|
|
|
+}
|
|
|
+
|
|
|
+const handleVisitSubmit = async () => {
|
|
|
+ if (formCompatibilityError.value) {
|
|
|
+ showToast(formCompatibilityError.value)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!selectedPoint.value) {
|
|
|
+ showToast(`请先选择要新增的${targetTypeName.value}`)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!checkInTime.value) {
|
|
|
+ showToast('请先完成地图打卡')
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ syncSelectedPoint(selectedPoint.value)
|
|
|
+ setFieldText(checkInField.value, checkInTime.value)
|
|
|
+
|
|
|
+ await handleSubmit()
|
|
|
+}
|
|
|
+
|
|
|
+watch(userId, () => void loadSignRecords(), { immediate: true })
|
|
|
+
|
|
|
+onLoad(async (options: PageLoadOptions = {}) => {
|
|
|
+ initSafeArea()
|
|
|
+
|
|
|
+ taskTypeId.value = options.taskTypeId?.trim() ?? ''
|
|
|
+
|
|
|
+ if (!taskTypeId.value || !TASK_TYPE_META[taskTypeId.value]) {
|
|
|
+ formLoading.value = false
|
|
|
+ formErrorMessage.value = '缺少有效的新增地点任务类型'
|
|
|
+ showToast(formErrorMessage.value)
|
|
|
+ return
|
|
|
+ }
|
|
|
+
|
|
|
+ const taskTitle = decodeRouteParam(options.taskTitle) || taskTypeMeta.value?.title
|
|
|
+
|
|
|
+ void initializeTaskFormPage({ taskTypeId: taskTypeId.value, taskTitle })
|
|
|
+ await loadVisitForm()
|
|
|
+})
|
|
|
+
|
|
|
+onMounted(() => {
|
|
|
+ clockTimer = setInterval(() => {
|
|
|
+ now.value = Date.now()
|
|
|
+ }, 1000)
|
|
|
+})
|
|
|
+
|
|
|
+onUnmounted(() => {
|
|
|
+ if (clockTimer) clearInterval(clockTimer)
|
|
|
+})
|
|
|
+
|
|
|
+const formatDistrict = (raw: ReverseGeocoderRaw | undefined, fallback: string) => {
|
|
|
+ const component = raw?.result?.address_component
|
|
|
+ const district = [component?.province, component?.city, component?.district]
|
|
|
+ .filter(Boolean)
|
|
|
+ .join('-')
|
|
|
+
|
|
|
+ return district || fallback
|
|
|
+}
|
|
|
+
|
|
|
+const parseSignTime = (value: string) => {
|
|
|
+ if (!value) return 0
|
|
|
+ const timestamp = new Date(value.replace(/-/g, '/')).getTime()
|
|
|
+ return Number.isFinite(timestamp) ? timestamp : 0
|
|
|
+}
|
|
|
+
|
|
|
+const formatCountdown = (seconds: number) => {
|
|
|
+ const hours = Math.floor(seconds / 3600)
|
|
|
+ const minutes = Math.floor((seconds % 3600) / 60)
|
|
|
+ const restSeconds = seconds % 60
|
|
|
+
|
|
|
+ return [hours, minutes, restSeconds]
|
|
|
+ .map((value) => String(value).padStart(2, '0'))
|
|
|
+ .join(':')
|
|
|
+}
|
|
|
+
|
|
|
+const decodeRouteParam = (value?: string) => {
|
|
|
+ if (!value) return ''
|
|
|
+
|
|
|
+ try {
|
|
|
+ return decodeURIComponent(value)
|
|
|
+ } catch {
|
|
|
+ return value
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+const showToast = (title: string) => {
|
|
|
+ uni.showToast({ title, icon: 'none' })
|
|
|
+}
|
|
|
+</script>
|
|
|
+
|
|
|
+<style lang="scss" scoped>
|
|
|
+.visit-add-page {
|
|
|
+ position: relative;
|
|
|
+ min-height: 100vh;
|
|
|
+ overflow: hidden;
|
|
|
+ background: #f3f7fb;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__scroll {
|
|
|
+ width: 100%;
|
|
|
+ height: 100vh;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__content {
|
|
|
+ min-height: 100vh;
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__notice {
|
|
|
+ padding: 20rpx 24rpx 0;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__state,
|
|
|
+.visit-add-page__config-error {
|
|
|
+ margin: 22rpx 24rpx 0;
|
|
|
+ padding: 34rpx 24rpx;
|
|
|
+ color: #718096;
|
|
|
+ font-size: 25rpx;
|
|
|
+ line-height: 38rpx;
|
|
|
+ text-align: center;
|
|
|
+ background: #fff;
|
|
|
+ border-radius: 18rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__config-error {
|
|
|
+ color: #b45309;
|
|
|
+ background: #fff8ec;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__retry {
|
|
|
+ width: 200rpx;
|
|
|
+ height: 64rpx;
|
|
|
+ margin: 22rpx auto 0;
|
|
|
+ color: #2f8cf7;
|
|
|
+ font-size: 24rpx;
|
|
|
+ line-height: 64rpx;
|
|
|
+ background: #edf6ff;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 32rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__retry::after {
|
|
|
+ border: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__footer {
|
|
|
+ position: fixed;
|
|
|
+ right: 0;
|
|
|
+ bottom: 0;
|
|
|
+ left: 0;
|
|
|
+ z-index: 9;
|
|
|
+ padding: 20rpx 28rpx;
|
|
|
+ background: #fff;
|
|
|
+ box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
|
|
|
+ box-sizing: border-box;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__submit {
|
|
|
+ width: 100%;
|
|
|
+ height: 88rpx;
|
|
|
+ margin: 0;
|
|
|
+ padding: 0;
|
|
|
+ color: #fff;
|
|
|
+ font-size: 32rpx;
|
|
|
+ line-height: 88rpx;
|
|
|
+ background: #3b9bed;
|
|
|
+ border: 0;
|
|
|
+ border-radius: 12rpx;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__submit::after {
|
|
|
+ border: 0;
|
|
|
+}
|
|
|
+
|
|
|
+.visit-add-page__submit--hover {
|
|
|
+ opacity: 0.85;
|
|
|
+}
|
|
|
+</style>
|