Эх сурвалжийг харах

完成新增拜访地点任务类型

yuanmingze 1 сар өмнө
parent
commit
23d4bb486e

+ 2 - 2
.env.development

@@ -1,6 +1,6 @@
 VITE_MODE=development
 VITE_APP_TYPE=dev
 VITE_PROJECT_TITLE=要易小助手
-VITE_BASE_API=https://preapi.yaoyi.net
-# VITE_BASE_API=https://pre1.yaoyi.net
+# VITE_BASE_API=https://preapi.yaoyi.net
+VITE_BASE_API=https://pre1.yaoyi.net
 VITE_WX_APPID=wxd03398e1bff2b241

+ 254 - 0
src/pages-task/task-form/components/DynamicTaskFormFields.vue

@@ -0,0 +1,254 @@
+<template>
+  <view class="task-form-fields">
+    <template v-for="item in taskFieldConfigList" :key="item.id">
+      <view v-if="shouldRenderField(item) && form.value[item.id]" class="task-form-field">
+        <SingleSelect
+          v-if="isSingleSelect(item.taskFiledType)"
+          v-model:label="form.value[item.id].label"
+          v-model:value="form.value[item.id].value"
+          :task-field-config="item"
+          :disabled="item.readonly"
+          :before-open="() => handleSingleSelectBeforeOpen(item)"
+          @change="handleTaskSingleSelectChange(item, $event)"
+        />
+
+        <InputAutoSelect
+          v-else-if="isInputAutoSelect(item.taskFiledType)"
+          v-model:label="form.value[item.id].label"
+          v-model:value="form.value[item.id].value"
+          :task-field-config="item"
+          :disabled="item.readonly"
+          :before-open="() => handleSingleSelectBeforeOpen(item)"
+          @change="handleSingleSelectChange(item, $event)"
+        />
+
+        <SingleText
+          v-else-if="isTextInput(item.taskFiledType)"
+          v-model:label="form.value[item.id].label"
+          v-model:value="form.value[item.id].value"
+          :task-field-config="item"
+          :readonly="item.readonly || isReadonlyText(item.taskFiledType)"
+        />
+
+        <DateTimePicker
+          v-else-if="isDateTime(item.taskFiledType)"
+          v-model:label="form.value[item.id].label"
+          v-model:value="form.value[item.id].value"
+          :task-field-config="item"
+          :disabled="item.readonly"
+        />
+
+        <TaskLocation
+          v-else-if="isLocation(item.taskFiledType)"
+          v-model:label="form.value[item.id].label"
+          v-model:value="form.value[item.id].value"
+          :task-field-config="item"
+          :disabled="item.readonly"
+        />
+
+        <template v-else-if="isImgUpload(item.taskFiledType)">
+          <view class="line" />
+
+          <ImgUpload
+            v-model:label="form.value[item.id].label"
+            v-model:value="form.value[item.id].value"
+            :task-field-config="item"
+            :disabled="item.readonly"
+          />
+        </template>
+
+        <template v-else-if="isSign(item.taskFiledType)">
+          <view class="line" />
+
+          <Sign
+            v-model:label="form.value[item.id].label"
+            v-model:value="form.value[item.id].value"
+            :task-field-config="item"
+            :disabled="item.readonly"
+          />
+        </template>
+
+        <MultipleSelect
+          v-else-if="isMultipleSelect(item.taskFiledType)"
+          v-model:label="form.value[item.id].label"
+          v-model:value="form.value[item.id].value"
+          :task-field-config="item"
+          :disabled="item.readonly"
+          :before-open="() => handleMultipleSelectBeforeOpen(item)"
+        />
+
+        <DataTimeRange
+          v-else-if="isDateTimeRange(item.taskFiledType)"
+          v-model:label="form.value[item.id].label"
+          v-model:value="form.value[item.id].value"
+          :task-field-config="item"
+          :disabled="item.readonly"
+        />
+
+        <template v-else-if="isLongText(item.taskFiledType)">
+          <view class="line" />
+
+          <LongText
+            v-model:label="form.value[item.id].label"
+            v-model:value="form.value[item.id].value"
+            :task-field-config="item"
+            :disabled="item.readonly"
+          />
+
+          <view class="line" />
+        </template>
+
+        <Area
+          v-else-if="isArea(item.taskFiledType)"
+          v-model:label="form.value[item.id].label"
+          v-model:value="form.value[item.id].value"
+          :task-field-config="item"
+          :disabled="item.readonly"
+          :select-level="selectLevel"
+        />
+      </view>
+    </template>
+  </view>
+
+  <view v-if="activeTaskTypeId === '47'" class="otc-estimated-score">
+    <view class="otc-estimated-score__value">预计积分:{{ otcEstimatedScore }}</view>
+    <view v-if="otcIsScoreOverLimit" class="otc-estimated-score__limit">
+      该服务获取积分上限为{{ otcLargestTotal }}分
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { TaskTypeItem } from '@/services/modules/task/taskFrom/type'
+
+import { useTaskFieldType } from '../composables/useTaskFieldType'
+import type {
+  DerivedFieldChangeEvent,
+  DynamicFormState,
+  TaskFieldConfigViewItem,
+} from '../composables/useTaskForm'
+import type { AreaSelectLevel } from '../composables/useTaskFormPageFeatures'
+import {
+  type SingleSelectChangeEvent,
+  useTaskSelectHandlers,
+} from '../composables/useTaskSelectHandlers'
+import Area from './Area.vue'
+import DataTimeRange from './DataTimeRange.vue'
+import DateTimePicker from './DateTimePicker.vue'
+import ImgUpload from './ImgUpload.vue'
+import InputAutoSelect from './InputAutoSelect.vue'
+import LongText from './LongText.vue'
+import MultipleSelect from './MultipleSelect.vue'
+import Sign from './Sign.vue'
+import SingleSelect from './SingleSelect.vue'
+import SingleText from './SingleText.vue'
+import TaskLocation from './TaskLocation.vue'
+
+const props = withDefaults(
+  defineProps<{
+    taskFieldConfigList: TaskFieldConfigViewItem[]
+    activeTaskTypeId: string
+    taskTypes: TaskTypeItem[]
+    selectLevel: AreaSelectLevel
+    hiddenFieldTypes?: string[]
+    hiddenFieldKeys?: string[]
+    hiddenFieldAliases?: string[]
+  }>(),
+  {
+    hiddenFieldTypes: () => [],
+    hiddenFieldKeys: () => [],
+    hiddenFieldAliases: () => [],
+  }
+)
+
+const emit = defineEmits<{
+  (event: 'derived-change', field: TaskFieldConfigViewItem, change: DerivedFieldChangeEvent): void
+}>()
+
+const form = defineModel<DynamicFormState>({ required: true })
+
+const taskFieldConfigListRef = computed(() => props.taskFieldConfigList)
+const activeTaskTypeIdRef = computed(() => props.activeTaskTypeId)
+const taskTypesRef = computed(() => props.taskTypes)
+const hiddenFieldTypeSet = computed(() => new Set(props.hiddenFieldTypes))
+const hiddenFieldKeySet = computed(() => new Set(props.hiddenFieldKeys))
+const hiddenFieldAliasSet = computed(() => new Set(props.hiddenFieldAliases))
+
+const {
+  handleSingleSelectBeforeOpen,
+  handleSingleSelectChange,
+  handleMultipleSelectBeforeOpen,
+  otcEstimatedScore,
+  otcLargestTotal,
+  otcIsScoreOverLimit,
+} = useTaskSelectHandlers({
+  taskTypeId: activeTaskTypeIdRef,
+  form,
+  taskFieldConfigList: taskFieldConfigListRef,
+  taskTypes: taskTypesRef,
+})
+
+const {
+  isSingleSelect,
+  isTextInput,
+  isReadonlyText,
+  isDateTime,
+  isLocation,
+  isImgUpload,
+  isSign,
+  isMultipleSelect,
+  isInputAutoSelect,
+  isDateTimeRange,
+  isLongText,
+  isArea,
+} = useTaskFieldType()
+
+const shouldRenderField = (field: TaskFieldConfigViewItem) => {
+  return (
+    field.show &&
+    !hiddenFieldTypeSet.value.has(field.taskFiledType) &&
+    !hiddenFieldKeySet.value.has(field.taskFiledKey) &&
+    !hiddenFieldAliasSet.value.has(field.alias ?? '')
+  )
+}
+
+const handleTaskSingleSelectChange = (
+  field: TaskFieldConfigViewItem,
+  event: SingleSelectChangeEvent
+) => {
+  void handleSingleSelectChange(field, event)
+  emit('derived-change', field, event)
+}
+</script>
+
+<style lang="scss" scoped>
+.task-form-fields {
+  padding-top: 20rpx;
+}
+
+.task-form-field {
+  background: #fff;
+}
+
+.otc-estimated-score {
+  min-height: 120rpx;
+  padding: 28rpx;
+  color: #333;
+  font-size: 30rpx;
+  line-height: 44rpx;
+  box-sizing: border-box;
+}
+
+.otc-estimated-score__limit {
+  margin-top: 12rpx;
+  color: #cd423e;
+}
+
+.line {
+  width: 100%;
+  height: 20rpx;
+  background: #f4f4f4;
+}
+</style>

+ 10 - 199
src/pages-task/task-form/index.vue

@@ -22,123 +22,14 @@
             />
           </view>
 
-          <view class="task-form-fields">
-            <template v-for="item in taskFieldConfigList" :key="item.id">
-              <view v-if="item.show && form.value[item.id]" class="task-form-field">
-                <SingleSelect
-                  v-if="isSingleSelect(item.taskFiledType)"
-                  v-model:label="form.value[item.id].label"
-                  v-model:value="form.value[item.id].value"
-                  :task-field-config="item"
-                  :disabled="item.readonly"
-                  :before-open="() => handleSingleSelectBeforeOpen(item)"
-                  @change="handleTaskSingleSelectChange(item, $event)"
-                />
-
-                <InputAutoSelect
-                  v-else-if="isInputAutoSelect(item.taskFiledType)"
-                  v-model:label="form.value[item.id].label"
-                  v-model:value="form.value[item.id].value"
-                  :task-field-config="item"
-                  :disabled="item.readonly"
-                  :before-open="() => handleSingleSelectBeforeOpen(item)"
-                  @change="handleSingleSelectChange(item, $event)"
-                />
-
-                <SingleText
-                  v-else-if="isTextInput(item.taskFiledType)"
-                  v-model:label="form.value[item.id].label"
-                  v-model:value="form.value[item.id].value"
-                  :task-field-config="item"
-                  :readonly="item.readonly || isReadonlyText(item.taskFiledType)"
-                />
-
-                <DateTimePicker
-                  v-else-if="isDateTime(item.taskFiledType)"
-                  v-model:label="form.value[item.id].label"
-                  v-model:value="form.value[item.id].value"
-                  :task-field-config="item"
-                  :disabled="item.readonly"
-                />
-
-                <TaskLocation
-                  v-else-if="isLocation(item.taskFiledType)"
-                  v-model:label="form.value[item.id].label"
-                  v-model:value="form.value[item.id].value"
-                  :task-field-config="item"
-                  :disabled="item.readonly"
-                />
-
-                <template v-else-if="isImgUpload(item.taskFiledType)">
-                  <view class="line" />
-
-                  <ImgUpload
-                    v-model:label="form.value[item.id].label"
-                    v-model:value="form.value[item.id].value"
-                    :task-field-config="item"
-                    :disabled="item.readonly"
-                  />
-                </template>
-
-                <template v-else-if="isSign(item.taskFiledType)">
-                  <view class="line" />
-
-                  <Sign
-                    v-model:label="form.value[item.id].label"
-                    v-model:value="form.value[item.id].value"
-                    :task-field-config="item"
-                    :disabled="item.readonly"
-                  />
-                </template>
-
-                <MultipleSelect
-                  v-else-if="isMultipleSelect(item.taskFiledType)"
-                  v-model:label="form.value[item.id].label"
-                  v-model:value="form.value[item.id].value"
-                  :task-field-config="item"
-                  :disabled="item.readonly"
-                  :before-open="() => handleMultipleSelectBeforeOpen(item)"
-                />
-
-                <DataTimeRange
-                  v-else-if="isDateTimeRange(item.taskFiledType)"
-                  v-model:label="form.value[item.id].label"
-                  v-model:value="form.value[item.id].value"
-                  :task-field-config="item"
-                  :disabled="item.readonly"
-                />
-
-                <template v-else-if="isLongText(item.taskFiledType)">
-                  <view class="line" />
-
-                  <LongText
-                    v-model:label="form.value[item.id].label"
-                    v-model:value="form.value[item.id].value"
-                    :task-field-config="item"
-                    :disabled="item.readonly"
-                  />
-
-                  <view class="line" />
-                </template>
-
-                <Area
-                  v-else-if="isArea(item.taskFiledType)"
-                  v-model:label="form.value[item.id].label"
-                  v-model:value="form.value[item.id].value"
-                  :task-field-config="item"
-                  :disabled="item.readonly"
-                  :select-level="selectLevel"
-                />
-              </view>
-            </template>
-          </view>
-
-          <view v-if="activeTaskTypeId === '47'" class="otc-estimated-score">
-            <view class="otc-estimated-score__value">预计积分:{{ otcEstimatedScore }}</view>
-            <view v-if="otcIsScoreOverLimit" class="otc-estimated-score__limit">
-              该服务获取积分上限为{{ otcLargestTotal }}分
-            </view>
-          </view>
+          <DynamicTaskFormFields
+            v-model="form"
+            :task-field-config-list="taskFieldConfigList"
+            :active-task-type-id="activeTaskTypeId"
+            :task-types="taskTypes"
+            :select-level="selectLevel"
+            @derived-change="handleDerivedFieldChange"
+          />
         </template>
       </view>
     </scroll-view>
@@ -181,28 +72,13 @@ import { computed, ref } from 'vue'
 
 import { onLoad } from '@dcloudio/uni-app'
 
-import Area from './components/Area.vue'
-import DataTimeRange from './components/DataTimeRange.vue'
-import DateTimePicker from './components/DateTimePicker.vue'
 import DuplicateImageDialog from './components/DuplicateImageDialog.vue'
-import ImgUpload from './components/ImgUpload.vue'
-import InputAutoSelect from './components/InputAutoSelect.vue'
-import LongText from './components/LongText.vue'
-import MultipleSelect from './components/MultipleSelect.vue'
+import DynamicTaskFormFields from './components/DynamicTaskFormFields.vue'
 import QuestionnaireTask from './components/QuestionnaireTask.vue'
-import Sign from './components/Sign.vue'
-import SingleSelect from './components/SingleSelect.vue'
-import SingleText from './components/SingleText.vue'
-import TaskLocation from './components/TaskLocation.vue'
-import { useTaskFieldType } from './composables/useTaskFieldType'
-import { type TaskFieldConfigViewItem, useTaskForm } from './composables/useTaskForm'
+import { useTaskForm } from './composables/useTaskForm'
 import { useTaskFormAction } from './composables/useTaskFormAction'
 import { useTaskFormPageFeatures } from './composables/useTaskFormPageFeatures'
 import { useTaskFormSafeArea } from './composables/useTaskFormSafeArea'
-import {
-  type SingleSelectChangeEvent,
-  useTaskSelectHandlers,
-} from './composables/useTaskSelectHandlers'
 
 interface PageLoadOptions {
   taskTypeId?: string
@@ -220,20 +96,6 @@ const {
   handleDerivedFieldChange,
 } = useTaskForm()
 
-const {
-  handleSingleSelectBeforeOpen,
-  handleSingleSelectChange,
-  handleMultipleSelectBeforeOpen,
-  otcEstimatedScore,
-  otcLargestTotal,
-  otcIsScoreOverLimit,
-} = useTaskSelectHandlers({
-  taskTypeId: activeTaskTypeId,
-  form,
-  taskFieldConfigList,
-  taskTypes,
-})
-
 const {
   isActionPending,
   duplicateImageVisible,
@@ -246,21 +108,6 @@ const {
   form,
   taskFieldConfigList,
 })
-const {
-  isSingleSelect,
-  isTextInput,
-  isReadonlyText,
-  isDateTime,
-  isLocation,
-  isImgUpload,
-  isSign,
-  isMultipleSelect,
-  isInputAutoSelect,
-  isDateTimeRange,
-  isLongText,
-  isArea,
-} = useTaskFieldType()
-
 const { showNotice, noticeText, selectLevel, initializeTaskFormPage } = useTaskFormPageFeatures()
 
 const { contentStyle, footerStyle, initSafeArea } = useTaskFormSafeArea()
@@ -277,14 +124,6 @@ const decodeRouteParam = (value?: string): string => {
   }
 }
 
-const handleTaskSingleSelectChange = (
-  field: TaskFieldConfigViewItem,
-  event: SingleSelectChangeEvent
-) => {
-  void handleSingleSelectChange(field, event)
-  handleDerivedFieldChange(field, event)
-}
-
 onLoad(async (options: PageLoadOptions = {}) => {
   initSafeArea()
 
@@ -341,34 +180,6 @@ onLoad(async (options: PageLoadOptions = {}) => {
   box-sizing: border-box;
 }
 
-.task-form-fields {
-  padding-top: 20rpx;
-}
-
-.task-form-field {
-  background: #fff;
-}
-
-.otc-estimated-score {
-  min-height: 120rpx;
-  padding: 28rpx;
-  box-sizing: border-box;
-  font-size: 30rpx;
-  line-height: 44rpx;
-  color: #333;
-}
-
-.otc-estimated-score__limit {
-  margin-top: 12rpx;
-  color: #cd423e;
-}
-
-.line {
-  width: 100%;
-  height: 20rpx;
-  background: #f4f4f4;
-}
-
 .task-form-footer {
   position: fixed;
   right: 0;

+ 330 - 0
src/pages-task/task-visit-add/components/VisitCheckInMap.vue

@@ -0,0 +1,330 @@
+<template>
+  <view class="visit-check-in">
+    <view class="visit-check-in__map-wrap">
+      <map
+        class="visit-check-in__map"
+        :latitude="mapLatitude"
+        :longitude="mapLongitude"
+        :circles="rangeCircles"
+        :markers="selectedMarkers"
+        :scale="17"
+        show-location
+        show-compass
+        @poitap="emit('point-tap', $event)"
+      />
+
+      <view class="visit-check-in__locate" @click.stop="emit('refresh-location')">⌖</view>
+
+      <view class="visit-check-in__range-tip">
+        请在当前位置 {{ rangeRadius }} 米范围内选择{{ targetTypeName }}
+      </view>
+    </view>
+
+    <view class="visit-check-in__card">
+      <view class="visit-check-in__heading">
+        <view class="visit-check-in__heading-icon">
+          <wd-icon name="location" size="34rpx" color="#2f8cf7" />
+        </view>
+        <view class="visit-check-in__heading-copy">
+          <view class="visit-check-in__title">
+            {{ fieldTitle }}
+            <text v-if="fieldRequired" class="visit-check-in__required">*</text>
+          </view>
+          <view class="visit-check-in__subtitle">点击上方地图选择要新增的地点</view>
+        </view>
+        <view
+          v-if="selectedAddress || checkInTime"
+          class="visit-check-in__reset"
+          @click="emit('reset')"
+        >
+          重新选择
+        </view>
+      </view>
+
+      <view class="visit-check-in__location">
+        <view class="visit-check-in__location-label">当前选择</view>
+        <view class="visit-check-in__location-value">
+          {{ selectedAddress || locationStatusText }}
+        </view>
+      </view>
+
+      <button
+        class="visit-check-in__button"
+        :class="{ 'visit-check-in__button--disabled': !canCheckIn }"
+        :disabled="!canCheckIn"
+        @click="emit('check-in')"
+      >
+        {{ actionText }}
+      </button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+type LocationStatus = 'idle' | 'locating' | 'ready' | 'error'
+
+interface MapPoiTapEvent {
+  detail?: {
+    latitude?: number
+    longitude?: number
+    name?: string
+  }
+}
+
+const props = defineProps<{
+  currentLatitude?: number
+  currentLongitude?: number
+  selectedLatitude?: number
+  selectedLongitude?: number
+  selectedAddress: string
+  checkInTime: string
+  fieldTitle: string
+  fieldRequired: boolean
+  targetTypeName: string
+  locationStatus: LocationStatus
+  rangeRadius: number
+  remainingSeconds: number
+  countdownText: string
+  formReady: boolean
+  selecting: boolean
+  checkingIn: boolean
+}>()
+
+const emit = defineEmits<{
+  (event: 'point-tap', value: MapPoiTapEvent): void
+  (event: 'refresh-location'): void
+  (event: 'check-in'): void
+  (event: 'reset'): void
+}>()
+
+const mapLatitude = computed(
+  () => props.currentLatitude ?? props.selectedLatitude ?? 22.543096
+)
+const mapLongitude = computed(
+  () => props.currentLongitude ?? props.selectedLongitude ?? 114.057865
+)
+
+const rangeCircles = computed(() => {
+  if (props.currentLatitude === undefined || props.currentLongitude === undefined) return []
+
+  return [
+    {
+      latitude: props.currentLatitude,
+      longitude: props.currentLongitude,
+      radius: props.rangeRadius,
+      color: '#42bde9aa',
+      fillColor: '#42bde926',
+      strokeWidth: 2,
+    },
+  ]
+})
+
+const selectedMarkers = computed(() => {
+  if (props.selectedLatitude === undefined || props.selectedLongitude === undefined) return []
+
+  return [
+    {
+      id: 1,
+      latitude: props.selectedLatitude,
+      longitude: props.selectedLongitude,
+      iconPath: '/static/images/common/map-marker.png',
+      width: 30,
+      height: 30,
+      callout: {
+        content: props.selectedAddress,
+        color: '#1f2937',
+        fontSize: 12,
+        borderRadius: 6,
+        borderWidth: 1,
+        borderColor: '#dbeafe',
+        bgColor: '#ffffff',
+        padding: 7,
+        textAlign: 'center' as const,
+        display: 'ALWAYS' as const,
+      },
+    },
+  ]
+})
+
+const locationStatusText = computed(() => {
+  if (props.locationStatus === 'error') return '定位失败,请点击右上角重新定位'
+  if (props.locationStatus === 'ready') return '请点击地图选择地点'
+  return '正在获取当前位置...'
+})
+
+const canCheckIn = computed(() => {
+  return (
+    props.formReady &&
+    props.locationStatus === 'ready' &&
+    Boolean(props.selectedAddress) &&
+    !props.checkInTime &&
+    props.remainingSeconds === 0 &&
+    !props.selecting &&
+    !props.checkingIn
+  )
+})
+
+const actionText = computed(() => {
+  if (props.checkInTime) return `${props.checkInTime} 完成打卡`
+  if (props.remainingSeconds > 0) return `${props.countdownText} 后可打卡`
+  if (!props.formReady) return '表单加载中'
+  if (props.selecting) return '正在获取地点'
+  if (props.checkingIn) return '正在打卡'
+  if (!props.selectedAddress) return '请先选择地点'
+  return '打卡'
+})
+</script>
+
+<style lang="scss" scoped>
+.visit-check-in {
+  background: #f3f7fb;
+}
+
+.visit-check-in__map-wrap {
+  position: relative;
+  height: 480rpx;
+  overflow: hidden;
+  background: #dce8f3;
+}
+
+.visit-check-in__map {
+  width: 100%;
+  height: 100%;
+}
+
+.visit-check-in__locate {
+  position: absolute;
+  top: 28rpx;
+  right: 28rpx;
+  width: 66rpx;
+  height: 66rpx;
+  color: #2f8cf7;
+  font-size: 44rpx;
+  font-weight: 700;
+  line-height: 66rpx;
+  text-align: center;
+  background: rgba(255, 255, 255, 0.96);
+  border-radius: 20rpx;
+  box-shadow: 0 10rpx 28rpx rgba(24, 39, 75, 0.14);
+}
+
+.visit-check-in__range-tip {
+  position: absolute;
+  right: 28rpx;
+  bottom: 28rpx;
+  left: 28rpx;
+  min-height: 58rpx;
+  padding: 10rpx 18rpx;
+  color: #315b80;
+  font-size: 22rpx;
+  line-height: 38rpx;
+  text-align: center;
+  background: rgba(255, 255, 255, 0.94);
+  border-radius: 16rpx;
+  box-shadow: 0 10rpx 28rpx rgba(24, 39, 75, 0.12);
+  box-sizing: border-box;
+}
+
+.visit-check-in__card {
+  margin: 22rpx 24rpx 0;
+  padding: 24rpx;
+  background: #fff;
+  border-radius: 20rpx;
+  box-shadow: 0 10rpx 30rpx rgba(24, 39, 75, 0.06);
+}
+
+.visit-check-in__heading {
+  display: flex;
+  align-items: center;
+}
+
+.visit-check-in__heading-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 58rpx;
+  height: 58rpx;
+  background: #edf6ff;
+  border-radius: 16rpx;
+}
+
+.visit-check-in__heading-copy {
+  flex: 1;
+  min-width: 0;
+  margin-left: 16rpx;
+}
+
+.visit-check-in__title {
+  color: #1e293b;
+  font-size: 28rpx;
+  font-weight: 650;
+  line-height: 40rpx;
+}
+
+.visit-check-in__required {
+  margin-left: 4rpx;
+  color: #e5484d;
+}
+
+.visit-check-in__subtitle {
+  color: #8491a2;
+  font-size: 21rpx;
+  line-height: 32rpx;
+}
+
+.visit-check-in__reset {
+  margin-left: 16rpx;
+  color: #2f8cf7;
+  font-size: 23rpx;
+}
+
+.visit-check-in__location {
+  display: flex;
+  align-items: flex-start;
+  margin-top: 22rpx;
+  padding: 18rpx;
+  background: #f7faff;
+  border-radius: 14rpx;
+}
+
+.visit-check-in__location-label {
+  flex-shrink: 0;
+  color: #607086;
+  font-size: 23rpx;
+  line-height: 34rpx;
+}
+
+.visit-check-in__location-value {
+  flex: 1;
+  min-width: 0;
+  margin-left: 18rpx;
+  color: #1e293b;
+  font-size: 23rpx;
+  line-height: 34rpx;
+  text-align: right;
+  word-break: break-all;
+}
+
+.visit-check-in__button {
+  height: 76rpx;
+  margin: 22rpx 0 0;
+  color: #fff;
+  font-size: 28rpx;
+  font-weight: 600;
+  line-height: 76rpx;
+  background: linear-gradient(135deg, #36a8f7, #2f7ff1);
+  border: 0;
+  border-radius: 38rpx;
+}
+
+.visit-check-in__button::after {
+  border: 0;
+}
+
+.visit-check-in__button--disabled {
+  color: #fff;
+  background: #aeb9c7;
+}
+</style>

+ 647 - 0
src/pages-task/task-visit-add/index.vue

@@ -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>

+ 2 - 1
src/pages-task/task-visit/components/visit-map/index.vue

@@ -17,7 +17,7 @@
       <view class="visit-map-panel__add-title">
         附近没有合适的{{ targetTypeLabel }}?
       </view>
-      <view class="visit-map-panel__add-action">+ 新增地点</view>
+      <view class="visit-map-panel__add-action" @click="emit('add-location')">+ 新增地点</view>
     </view>
   </view>
 </template>
@@ -38,6 +38,7 @@ const props = defineProps<{
 
 const emit = defineEmits<{
   (event: 'refresh-location'): void
+  (event: 'add-location'): void
 }>()
 
 const rangeCircles = computed(() => {

+ 25 - 0
src/pages-task/task-visit/index.vue

@@ -6,6 +6,7 @@
       :current-location="currentLocation"
       :range-radius="rangeRadius"
       @refresh-location="refreshCurrentLocation"
+      @add-location="handleAddLocation"
     />
 
     <view class="task-visit-page__content">
@@ -72,6 +73,18 @@ const visitToMap: Record<VisitTargetType, VisitToType> = {
   pharmacy: 'PHARMACY_VISIT',
 }
 
+const addLocationTaskTypeMap: Record<VisitTargetType, string> = {
+  hospital: '83',
+  company: '84',
+  pharmacy: '82',
+}
+
+const addLocationTaskTitleMap: Record<VisitTargetType, string> = {
+  hospital: '新增医院拜访',
+  company: '新增商业公司拜访',
+  pharmacy: '新增药店拜访',
+}
+
 const activeType = ref<VisitTargetType>('hospital')
 const visitTimeVisible = ref(false)
 
@@ -115,6 +128,18 @@ const handleTargetChange = (id: string) => {
   selectedTargetId.value = id
 }
 
+const handleAddLocation = () => {
+  const taskTypeId = addLocationTaskTypeMap[activeType.value]
+  const taskTitle = addLocationTaskTitleMap[activeType.value]
+
+  uni.navigateTo({
+    url:
+      `/pages-task/task-visit-add/index` +
+      `?taskTypeId=${encodeURIComponent(taskTypeId)}` +
+      `&taskTitle=${encodeURIComponent(taskTitle)}`,
+  })
+}
+
 const handleSignSuccess = () => {
   void visitRecordPanelRef.value?.refresh()
 }

+ 10 - 0
src/pages.json

@@ -237,6 +237,16 @@
             "backgroundColor": "#F3F7FB"
           }
         },
+        {
+          "path": "task-visit-add/index",
+          "style": {
+            "navigationBarTitleText": "新增拜访地点",
+            "navigationStyle": "default",
+            "navigationBarBackgroundColor": "#FFFFFF",
+            "navigationBarTextStyle": "black",
+            "backgroundColor": "#F3F7FB"
+          }
+        },
         {
           "path": "task-package/detail",
           "style": {

+ 0 - 2
src/pages/index/index.vue

@@ -154,8 +154,6 @@ const handleOpenStatus = (type: 'on-the-way' | 'wait-approve') => {
 }
 
 const handleSelectTask = (item: TASK_TYPE) => {
-  console.log('handleSelectTask', item)
-
   if (!ensureCanOperate()) return
   if (item.key === 'customer_visit') {
     uni.navigateTo({

+ 4 - 0
src/services/modules/task/taskVisit/index.ts

@@ -26,6 +26,10 @@ export const isVisitTimeApi = (params: IsVisitTimeRequest) => {
   return http.get<IsVisitTimeResult>('/admin/api/isVisitTime', params)
 }
 
+export const getLocaltimeApi = () => {
+  return http.get<string>('/admin/api/content/localtime')
+}
+
 export const getPointSignInfoApi = (params: GetPointSignInfoRequest) => {
   return http.get<PointSignInfoItem[]>('/admin/api/getPointSignInfo', params)
 }