| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650 |
- <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-aliases="hiddenFieldAliases"
- @derived-change="handleDerivedFieldChange"
- />
- </template>
- </view>
- </scroll-view>
- <DuplicateImageDialog
- v-model="duplicateImageVisible"
- :images="duplicateImageList"
- @confirm="clearDuplicateImages"
- />
- <view
- v-if="taskFieldConfigList.length"
- v-show="!keyboardVisible"
- 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 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, keyboardVisible, 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 syncSelectedPointLocation = (point: SelectedPoint) => {
- setFieldText(coordinateField.value, `${point.longitude},${point.latitude}`)
- setFieldText(addressField.value, point.address)
- setFieldText(districtField.value, point.district)
- syncTaskTypeField()
- }
- const initializeSelectedPointFields = (point: SelectedPoint) => {
- syncSelectedPointLocation(point)
- setFieldText(targetNameField.value, point.name)
- }
- 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
- initializeSelectedPointFields(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
- }
- syncSelectedPointLocation(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>
|