index.vue 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. <template>
  2. <view class="visit-add-page">
  3. <scroll-view
  4. class="visit-add-page__scroll"
  5. scroll-y
  6. enhanced
  7. :show-scrollbar="false"
  8. :bounces="false"
  9. >
  10. <view class="visit-add-page__content" :style="contentStyle">
  11. <VisitCheckInMap
  12. :current-latitude="currentLocation?.latitude"
  13. :current-longitude="currentLocation?.longitude"
  14. :selected-latitude="selectedPoint?.latitude"
  15. :selected-longitude="selectedPoint?.longitude"
  16. :selected-address="selectedPoint?.address || ''"
  17. :check-in-time="checkInTime"
  18. :field-title="addressField?.taskFiledValue || '拜访地址'"
  19. :field-required="addressField?.isMustfill === '1'"
  20. :target-type-name="targetTypeName"
  21. :location-status="locationStatus"
  22. :range-radius="rangeRadius"
  23. :remaining-seconds="remainingSeconds"
  24. :countdown-text="countdownText"
  25. :form-ready="visitFormReady"
  26. :selecting="pointSelecting"
  27. :checking-in="checkInLoading"
  28. @point-tap="handlePointTap"
  29. @refresh-location="refreshCurrentLocation"
  30. @check-in="handleCheckIn"
  31. @reset="resetCheckIn"
  32. />
  33. <view v-if="showNotice" class="visit-add-page__notice">
  34. <wd-notice-bar
  35. :text="noticeText"
  36. prefix="warn-bold"
  37. type="info"
  38. color="#f90"
  39. background-color="#fdf6ec"
  40. custom-style="border-radius: 12rpx; min-height: 64rpx; padding: 0 20rpx;"
  41. />
  42. </view>
  43. <view v-if="formLoading" class="visit-add-page__state">正在加载表单...</view>
  44. <view v-else-if="formErrorMessage" class="visit-add-page__state">
  45. <view>{{ formErrorMessage }}</view>
  46. <button class="visit-add-page__retry" @click="loadVisitForm">重新加载</button>
  47. </view>
  48. <template v-else>
  49. <view v-if="formCompatibilityError" class="visit-add-page__config-error">
  50. {{ formCompatibilityError }}
  51. </view>
  52. <DynamicTaskFormFields
  53. v-model="form"
  54. :task-field-config-list="taskFieldConfigList"
  55. :active-task-type-id="activeTaskTypeId"
  56. :task-types="taskTypes"
  57. :select-level="selectLevel"
  58. :hidden-field-types="hiddenFieldTypes"
  59. :hidden-field-aliases="hiddenFieldAliases"
  60. @derived-change="handleDerivedFieldChange"
  61. />
  62. </template>
  63. </view>
  64. </scroll-view>
  65. <DuplicateImageDialog
  66. v-model="duplicateImageVisible"
  67. :images="duplicateImageList"
  68. @confirm="clearDuplicateImages"
  69. />
  70. <view
  71. v-if="taskFieldConfigList.length"
  72. v-show="!keyboardVisible"
  73. class="visit-add-page__footer"
  74. :style="footerStyle"
  75. >
  76. <button
  77. class="visit-add-page__submit"
  78. hover-class="visit-add-page__submit--hover"
  79. :disabled="isActionPending || formLoading"
  80. @click="handleVisitSubmit"
  81. >
  82. 提交
  83. </button>
  84. </view>
  85. </view>
  86. </template>
  87. <script setup lang="ts">
  88. import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
  89. import { onLoad } from '@dcloudio/uni-app'
  90. import { reverseGeocodeByCoordinate } from '@/lib/location'
  91. import { getLocaltimeApi, getSignListByUserIdApi } from '@/services/modules/task/taskVisit'
  92. import type { VisitSignRecordItem } from '@/services/modules/task/taskVisit/type'
  93. import { useUserStore } from '@/stores/modules/user'
  94. import DuplicateImageDialog from '../task-form/components/DuplicateImageDialog.vue'
  95. import DynamicTaskFormFields from '../task-form/components/DynamicTaskFormFields.vue'
  96. import type { TaskFieldConfigViewItem } from '../task-form/composables/useTaskForm'
  97. import { useTaskForm } from '../task-form/composables/useTaskForm'
  98. import { useTaskFormAction } from '../task-form/composables/useTaskFormAction'
  99. import { useTaskFormFields } from '../task-form/composables/useTaskFormFields'
  100. import { useTaskFormPageFeatures } from '../task-form/composables/useTaskFormPageFeatures'
  101. import { useTaskFormSafeArea } from '../task-form/composables/useTaskFormSafeArea'
  102. import { useVisitLocation } from '../task-visit/composables/useVisitLocation'
  103. import { getDistanceInMeters } from '../task-visit/utils/distance'
  104. import VisitCheckInMap from './components/VisitCheckInMap.vue'
  105. interface PageLoadOptions {
  106. taskTypeId?: string
  107. taskTitle?: string
  108. }
  109. interface MapPoiTapEvent {
  110. detail?: {
  111. latitude?: number
  112. longitude?: number
  113. name?: string
  114. }
  115. }
  116. interface SelectedPoint {
  117. latitude: number
  118. longitude: number
  119. address: string
  120. district: string
  121. name: string
  122. }
  123. interface ReverseGeocoderRaw {
  124. result?: {
  125. formatted_addresses?: {
  126. recommend?: string
  127. }
  128. address_component?: {
  129. province?: string
  130. city?: string
  131. district?: string
  132. }
  133. }
  134. }
  135. const TASK_TYPE_META: Readonly<Record<string, { typeName: string; title: string }>> = {
  136. '82': { typeName: '药店', title: '新增药店拜访' },
  137. '83': { typeName: '医院', title: '新增医院拜访' },
  138. '84': { typeName: '商业公司', title: '新增商业公司拜访' },
  139. }
  140. const GLOBAL_COOLDOWN_SECONDS = 30 * 60
  141. const hiddenFieldTypes = ['map', 'location']
  142. const hiddenFieldAliases = ['taskDate']
  143. const userStore = useUserStore()
  144. const taskTypeId = ref('')
  145. const formLoading = ref(true)
  146. const formErrorMessage = ref('')
  147. const selectedPoint = ref<SelectedPoint | null>(null)
  148. const checkInTime = ref('')
  149. const pointSelecting = ref(false)
  150. const checkInLoading = ref(false)
  151. const signRecords = ref<VisitSignRecordItem[]>([])
  152. const cooldownReady = ref(false)
  153. const now = ref(Date.now())
  154. const {
  155. form,
  156. taskFieldConfigList,
  157. activeTaskTypeId,
  158. taskTypes,
  159. loadTaskForm,
  160. handleDerivedFieldChange,
  161. } = useTaskForm()
  162. const {
  163. isActionPending,
  164. duplicateImageVisible,
  165. duplicateImageList,
  166. clearDuplicateImages,
  167. handleSubmit,
  168. } = useTaskFormAction({
  169. taskTypeId: activeTaskTypeId,
  170. form,
  171. taskFieldConfigList,
  172. })
  173. const { setFieldFormValue, clearFieldValue } = useTaskFormFields({
  174. form,
  175. taskFieldConfigList,
  176. })
  177. const { showNotice, noticeText, selectLevel, initializeTaskFormPage } = useTaskFormPageFeatures()
  178. const { contentStyle, footerStyle, keyboardVisible, initSafeArea } = useTaskFormSafeArea()
  179. const { currentLocation, locationStatus, rangeRadius, refreshCurrentLocation } = useVisitLocation()
  180. const userId = computed(() => String(userStore.currentUserInfo?.userId ?? '').trim())
  181. const taskTypeMeta = computed(() => TASK_TYPE_META[taskTypeId.value])
  182. const targetTypeName = computed(() => taskTypeMeta.value?.typeName || '地点')
  183. const findField = (predicate: (field: TaskFieldConfigViewItem) => boolean) => {
  184. return taskFieldConfigList.value.find(predicate)
  185. }
  186. const findFieldByAliases = (aliases: readonly string[]) => {
  187. return findField((field) => Boolean(field.alias) && aliases.includes(field.alias as string))
  188. }
  189. const coordinateField = computed(() => {
  190. return (
  191. findField((field) => field.taskFiledType === 'map') ??
  192. findFieldByAliases(['coordinates', 'coordinate', 'locationCoordinate'])
  193. )
  194. })
  195. const addressField = computed(() => {
  196. return (
  197. findField((field) => field.taskFiledType === 'location') ??
  198. findFieldByAliases(['visitAddress', 'signAddress'])
  199. )
  200. })
  201. const checkInField = computed(() => {
  202. return findFieldByAliases(['taskDate', 'checkInTime', 'signDate'])
  203. })
  204. const districtField = computed(() => {
  205. return findFieldByAliases(['shengShiQu', 'provinceCityDistrict'])
  206. })
  207. const targetNameField = computed(() => {
  208. return (
  209. findField((field) => field.taskFiledValue.trim() === '拜访对象名称') ??
  210. findFieldByAliases(['visitTargetName', 'targetName', 'signEntName'])
  211. )
  212. })
  213. const taskTypeField = computed(() => {
  214. return (
  215. findField((field) => field.taskFiledKey === 'temp30') ??
  216. findFieldByAliases(['sourceTaskTypeId'])
  217. )
  218. })
  219. const formCompatibilityError = computed(() => {
  220. if (!taskFieldConfigList.value.length) return ''
  221. const missingFields = [
  222. coordinateField.value ? '' : '经纬度字段',
  223. addressField.value ? '' : '打卡地址字段',
  224. checkInField.value ? '' : '打卡时间字段',
  225. ].filter(Boolean)
  226. return missingFields.length ? `表单配置缺少${missingFields.join('、')},请联系管理员。` : ''
  227. })
  228. const visitFormReady = computed(() => {
  229. return (
  230. !formLoading.value &&
  231. !formErrorMessage.value &&
  232. !formCompatibilityError.value &&
  233. Boolean(taskFieldConfigList.value.length) &&
  234. cooldownReady.value
  235. )
  236. })
  237. const cooldownDeadline = computed(() => {
  238. let latestSignTime = 0
  239. for (const record of signRecords.value) {
  240. latestSignTime = Math.max(latestSignTime, parseSignTime(record.signDate))
  241. }
  242. return latestSignTime + GLOBAL_COOLDOWN_SECONDS * 1000
  243. })
  244. const remainingSeconds = computed(() => {
  245. return Math.max(0, Math.ceil((cooldownDeadline.value - now.value) / 1000))
  246. })
  247. const countdownText = computed(() => formatCountdown(remainingSeconds.value))
  248. let clockTimer: ReturnType<typeof setInterval> | undefined
  249. const setFieldText = (field: TaskFieldConfigViewItem | undefined, value: string) => {
  250. if (!field) return
  251. setFieldFormValue(field, { label: value, value })
  252. }
  253. const syncTaskTypeField = () => {
  254. setFieldText(taskTypeField.value, taskTypeId.value)
  255. }
  256. const syncSelectedPointLocation = (point: SelectedPoint) => {
  257. setFieldText(coordinateField.value, `${point.longitude},${point.latitude}`)
  258. setFieldText(addressField.value, point.address)
  259. setFieldText(districtField.value, point.district)
  260. syncTaskTypeField()
  261. }
  262. const initializeSelectedPointFields = (point: SelectedPoint) => {
  263. syncSelectedPointLocation(point)
  264. setFieldText(targetNameField.value, point.name)
  265. }
  266. const loadVisitForm = async () => {
  267. if (!taskTypeId.value) return
  268. formLoading.value = true
  269. formErrorMessage.value = ''
  270. try {
  271. await loadTaskForm(taskTypeId.value)
  272. syncTaskTypeField()
  273. } catch (error) {
  274. console.error('[task-visit-add] 表单配置加载失败', error)
  275. formErrorMessage.value = '表单加载失败,请稍后重试'
  276. } finally {
  277. formLoading.value = false
  278. }
  279. }
  280. const loadSignRecords = async () => {
  281. const signUserid = userId.value
  282. if (!signUserid) {
  283. signRecords.value = []
  284. cooldownReady.value = false
  285. return
  286. }
  287. cooldownReady.value = false
  288. try {
  289. const res = await getSignListByUserIdApi({ signUserid })
  290. signRecords.value = Array.isArray(res.data) ? res.data : []
  291. } catch (error) {
  292. console.error('[task-visit-add] 打卡记录加载失败', error)
  293. signRecords.value = []
  294. uni.showToast({ title: '打卡记录加载失败', icon: 'none' })
  295. } finally {
  296. now.value = Date.now()
  297. cooldownReady.value = true
  298. }
  299. }
  300. const handlePointTap = async (event: MapPoiTapEvent) => {
  301. if (!visitFormReady.value) {
  302. showToast(formCompatibilityError.value || '表单或打卡状态尚未就绪')
  303. return
  304. }
  305. if (checkInTime.value) {
  306. showToast('已完成打卡,请先重新选择')
  307. return
  308. }
  309. if (remainingSeconds.value > 0) {
  310. showToast(`${countdownText.value} 后可再次打卡`)
  311. return
  312. }
  313. const latitude = event.detail?.latitude
  314. const longitude = event.detail?.longitude
  315. const current = currentLocation.value
  316. if (typeof latitude !== 'number' || typeof longitude !== 'number') return
  317. if (!current) {
  318. showToast('正在获取当前位置,请稍后重试')
  319. return
  320. }
  321. const distance = getDistanceInMeters(
  322. current.longitude,
  323. current.latitude,
  324. longitude,
  325. latitude
  326. )
  327. if (distance > rangeRadius.value) {
  328. showToast(`不能超出当前位置 ${rangeRadius.value} 米范围`)
  329. return
  330. }
  331. pointSelecting.value = true
  332. try {
  333. const location = await reverseGeocodeByCoordinate(latitude, longitude)
  334. const raw = location.raw as ReverseGeocoderRaw | undefined
  335. const recommendedName = raw?.result?.formatted_addresses?.recommend?.trim()
  336. const pointName = event.detail?.name?.trim() || recommendedName || location.address
  337. const district = formatDistrict(raw, location.provinceCityDistrict)
  338. const point: SelectedPoint = {
  339. latitude,
  340. longitude,
  341. address: location.address,
  342. district,
  343. name: pointName,
  344. }
  345. selectedPoint.value = point
  346. initializeSelectedPointFields(point)
  347. } catch (error) {
  348. console.error('[task-visit-add] 地点解析失败', error)
  349. showToast('获取地点信息失败')
  350. } finally {
  351. pointSelecting.value = false
  352. }
  353. }
  354. const handleCheckIn = async () => {
  355. if (!selectedPoint.value) {
  356. showToast(`请先选择要打卡的${targetTypeName.value}`)
  357. return
  358. }
  359. if (remainingSeconds.value > 0) {
  360. showToast(`${countdownText.value} 后可再次打卡`)
  361. return
  362. }
  363. if (checkInLoading.value || checkInTime.value) return
  364. checkInLoading.value = true
  365. try {
  366. const res = await getLocaltimeApi()
  367. const localtime = String(res.data ?? '').trim()
  368. if (!localtime) {
  369. throw new Error('Empty localtime')
  370. }
  371. checkInTime.value = localtime
  372. setFieldText(checkInField.value, localtime)
  373. } catch (error) {
  374. console.error('[task-visit-add] 打卡失败', error)
  375. showToast('打卡失败,请稍后重试')
  376. } finally {
  377. checkInLoading.value = false
  378. }
  379. }
  380. const resetCheckIn = () => {
  381. selectedPoint.value = null
  382. checkInTime.value = ''
  383. for (const field of [
  384. coordinateField.value,
  385. addressField.value,
  386. districtField.value,
  387. targetNameField.value,
  388. checkInField.value,
  389. ]) {
  390. if (field) clearFieldValue(field)
  391. }
  392. syncTaskTypeField()
  393. }
  394. const handleVisitSubmit = async () => {
  395. if (formCompatibilityError.value) {
  396. showToast(formCompatibilityError.value)
  397. return
  398. }
  399. if (!selectedPoint.value) {
  400. showToast(`请先选择要新增的${targetTypeName.value}`)
  401. return
  402. }
  403. if (!checkInTime.value) {
  404. showToast('请先完成地图打卡')
  405. return
  406. }
  407. syncSelectedPointLocation(selectedPoint.value)
  408. setFieldText(checkInField.value, checkInTime.value)
  409. await handleSubmit()
  410. }
  411. watch(userId, () => void loadSignRecords(), { immediate: true })
  412. onLoad(async (options: PageLoadOptions = {}) => {
  413. initSafeArea()
  414. taskTypeId.value = options.taskTypeId?.trim() ?? ''
  415. if (!taskTypeId.value || !TASK_TYPE_META[taskTypeId.value]) {
  416. formLoading.value = false
  417. formErrorMessage.value = '缺少有效的新增地点任务类型'
  418. showToast(formErrorMessage.value)
  419. return
  420. }
  421. const taskTitle = decodeRouteParam(options.taskTitle) || taskTypeMeta.value?.title
  422. void initializeTaskFormPage({ taskTypeId: taskTypeId.value, taskTitle })
  423. await loadVisitForm()
  424. })
  425. onMounted(() => {
  426. clockTimer = setInterval(() => {
  427. now.value = Date.now()
  428. }, 1000)
  429. })
  430. onUnmounted(() => {
  431. if (clockTimer) clearInterval(clockTimer)
  432. })
  433. const formatDistrict = (raw: ReverseGeocoderRaw | undefined, fallback: string) => {
  434. const component = raw?.result?.address_component
  435. const district = [component?.province, component?.city, component?.district]
  436. .filter(Boolean)
  437. .join('-')
  438. return district || fallback
  439. }
  440. const parseSignTime = (value: string) => {
  441. if (!value) return 0
  442. const timestamp = new Date(value.replace(/-/g, '/')).getTime()
  443. return Number.isFinite(timestamp) ? timestamp : 0
  444. }
  445. const formatCountdown = (seconds: number) => {
  446. const hours = Math.floor(seconds / 3600)
  447. const minutes = Math.floor((seconds % 3600) / 60)
  448. const restSeconds = seconds % 60
  449. return [hours, minutes, restSeconds]
  450. .map((value) => String(value).padStart(2, '0'))
  451. .join(':')
  452. }
  453. const decodeRouteParam = (value?: string) => {
  454. if (!value) return ''
  455. try {
  456. return decodeURIComponent(value)
  457. } catch {
  458. return value
  459. }
  460. }
  461. const showToast = (title: string) => {
  462. uni.showToast({ title, icon: 'none' })
  463. }
  464. </script>
  465. <style lang="scss" scoped>
  466. .visit-add-page {
  467. position: relative;
  468. min-height: 100vh;
  469. overflow: hidden;
  470. background: #f3f7fb;
  471. }
  472. .visit-add-page__scroll {
  473. width: 100%;
  474. height: 100vh;
  475. }
  476. .visit-add-page__content {
  477. min-height: 100vh;
  478. box-sizing: border-box;
  479. }
  480. .visit-add-page__notice {
  481. padding: 20rpx 24rpx 0;
  482. }
  483. .visit-add-page__state,
  484. .visit-add-page__config-error {
  485. margin: 22rpx 24rpx 0;
  486. padding: 34rpx 24rpx;
  487. color: #718096;
  488. font-size: 25rpx;
  489. line-height: 38rpx;
  490. text-align: center;
  491. background: #fff;
  492. border-radius: 18rpx;
  493. }
  494. .visit-add-page__config-error {
  495. color: #b45309;
  496. background: #fff8ec;
  497. }
  498. .visit-add-page__retry {
  499. width: 200rpx;
  500. height: 64rpx;
  501. margin: 22rpx auto 0;
  502. color: #2f8cf7;
  503. font-size: 24rpx;
  504. line-height: 64rpx;
  505. background: #edf6ff;
  506. border: 0;
  507. border-radius: 32rpx;
  508. }
  509. .visit-add-page__retry::after {
  510. border: 0;
  511. }
  512. .visit-add-page__footer {
  513. position: fixed;
  514. right: 0;
  515. bottom: 0;
  516. left: 0;
  517. z-index: 9;
  518. padding: 20rpx 28rpx;
  519. background: #fff;
  520. box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
  521. box-sizing: border-box;
  522. }
  523. .visit-add-page__submit {
  524. width: 100%;
  525. height: 88rpx;
  526. margin: 0;
  527. padding: 0;
  528. color: #fff;
  529. font-size: 32rpx;
  530. line-height: 88rpx;
  531. background: #3b9bed;
  532. border: 0;
  533. border-radius: 12rpx;
  534. }
  535. .visit-add-page__submit::after {
  536. border: 0;
  537. }
  538. .visit-add-page__submit--hover {
  539. opacity: 0.85;
  540. }
  541. </style>