소스 검색

版本升级 打卡功能实现

yuanmingze 1 개월 전
부모
커밋
2144f61952
79개의 변경된 파일3577개의 추가작업 그리고 1063개의 파일을 삭제
  1. 1 1
      .npmrc
  2. BIN
      .pnpm-store/v11/index.db
  3. 79 29
      components.d.ts
  4. 17 17
      package.json
  5. 214 204
      pnpm-lock.yaml
  6. 11 0
      pnpm-workspace.yaml
  7. 1 4
      src/components/auth/AuthInputItem.vue
  8. 1 1
      src/composables/useCodeCountdown.ts
  9. 4 25
      src/pages-auth/bank/index.vue
  10. 0 4
      src/pages-auth/certification-service/index.vue
  11. 5 6
      src/pages-auth/face/index.vue
  12. 2 8
      src/pages-auth/fesco/index.vue
  13. 2 11
      src/pages-auth/identity/index.vue
  14. 6 27
      src/pages-auth/settlement-channel-sign/index.vue
  15. 1 1
      src/pages-auth/web-view/index.vue
  16. 4 19
      src/pages-common/entity-selector/index.vue
  17. 30 21
      src/pages-common/location-select/index.vue
  18. 5 5
      src/pages-common/signature/components/SignaturePad.vue
  19. 8 19
      src/pages-common/signature/index.vue
  20. 1 1
      src/pages-common/web-view/index.vue
  21. 0 14
      src/pages-mine/agreement/index.vue
  22. 0 11
      src/pages-mine/compliance-education/detail.vue
  23. 0 11
      src/pages-mine/compliance-education/index.vue
  24. 9 35
      src/pages-mine/compliance-evaluation/ability-test.vue
  25. 0 14
      src/pages-mine/compliance-evaluation/index.vue
  26. 1 5
      src/pages-mine/feedback/index.vue
  27. 1 14
      src/pages-mine/notification/detail.vue
  28. 1 14
      src/pages-mine/notification/index.vue
  29. 4 10
      src/pages-mine/personal-card/components/ProfessionalSkillCard.vue
  30. 1 13
      src/pages-mine/personal-card/index.vue
  31. 5 14
      src/pages-mine/points-rule/index.vue
  32. 1 14
      src/pages-task/completed-record/index.vue
  33. 3 19
      src/pages-task/points-package-record/index.vue
  34. 1 1
      src/pages-task/task-entry-page/index.vue
  35. 0 5
      src/pages-task/task-form/components/InputAutoSelect.vue
  36. 0 4
      src/pages-task/task-form/components/MultipleSelect.vue
  37. 37 72
      src/pages-task/task-form/components/QuestionnaireTask.vue
  38. 0 4
      src/pages-task/task-form/components/SingleSelect.vue
  39. 0 13
      src/pages-task/task-form/composables/useTaskForm.ts
  40. 8 21
      src/pages-task/task-form/composables/useTaskFormAction.ts
  41. 8 8
      src/pages-task/task-form/composables/useTaskFormSafeArea.ts
  42. 6 31
      src/pages-task/task-form/composables/useTaskSelectHandlers.ts
  43. 0 4
      src/pages-task/task-form/index.vue
  44. 6 15
      src/pages-task/task-package/list.vue
  45. 161 0
      src/pages-task/task-visit/components/visit-map/index.vue
  46. 297 0
      src/pages-task/task-visit/components/visit-record/index.vue
  47. 1179 0
      src/pages-task/task-visit/components/visit-status/index.vue
  48. 306 0
      src/pages-task/task-visit/components/visit-target/index.vue
  49. 363 0
      src/pages-task/task-visit/components/visit-time/index.vue
  50. 99 0
      src/pages-task/task-visit/composables/useVisitLocation.ts
  51. 87 0
      src/pages-task/task-visit/composables/useVisitTargets.ts
  52. 166 3
      src/pages-task/task-visit/index.vue
  53. 12 0
      src/pages-task/task-visit/taskVisit.config.ts
  54. 20 0
      src/pages-task/task-visit/types.ts
  55. 36 0
      src/pages-task/task-visit/utils/distance.ts
  56. 4 1
      src/pages.json
  57. 1 1
      src/pages/index/components/LivenessReminderDialog.vue
  58. 0 1
      src/pages/index/components/WorkbenchTaskSection.vue
  59. 31 34
      src/pages/index/index.vue
  60. 1 14
      src/pages/information/components/academic/AcademicInfoPanel.vue
  61. 4 15
      src/pages/information/components/album/ProductAlbumPanel.vue
  62. 0 11
      src/pages/information/components/shared/LoginRequiredState.vue
  63. 0 5
      src/pages/information/detail/index.vue
  64. 2 2
      src/pages/information/index.vue
  65. 3 8
      src/pages/login/index.vue
  66. 6 13
      src/pages/mine/index.vue
  67. 2 1
      src/services/modules/auth/index.ts
  68. 3 1
      src/services/modules/task/taskFrom/index.ts
  69. 39 0
      src/services/modules/task/taskVisit/index.ts
  70. 82 0
      src/services/modules/task/taskVisit/type.d.ts
  71. 54 121
      src/services/request/index.ts
  72. 45 25
      src/services/request/utils.ts
  73. BIN
      src/static/images/common/map-marker.png
  74. 3 9
      src/stores/modules/user.ts
  75. 0 2
      src/themes/index.ts
  76. 0 21
      src/types/uqrcodejs.d.ts
  77. 21 0
      src/utils/latestTask.ts
  78. 66 5
      src/utils/loading.ts
  79. 0 1
      tsconfig.json

+ 1 - 1
.npmrc

@@ -1 +1 @@
-public-hoist-pattern[]=@vue*
+public-hoist-pattern[]=@vue*

BIN
.pnpm-store/v11/index.db


+ 79 - 29
components.d.ts

@@ -1,42 +1,92 @@
 /* eslint-disable */
-/* prettier-ignore */
 // @ts-nocheck
+// biome-ignore lint: disable
+// oxlint-disable
+// ------
 // Generated by vite-plugin-uni-components
 // Read more: https://github.com/vuejs/core/pull/3399
+import { GlobalComponents } from 'vue'
+
 export {}
 
+/* prettier-ignore */
 declare module 'vue' {
   export interface GlobalComponents {
     AuthInputItem: typeof import('./src/components/auth/AuthInputItem.vue')['default']
     BaseAuthDialog: typeof import('./src/components/BaseAuthDialog/index.vue')['default']
     FormField: typeof import('./src/components/FormField/index.vue')['default']
-    WdActionSheet: typeof import('@wot-ui/ui/components/wd-action-sheet/wd-action-sheet.vue')['default']
-    WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['default']
-    WdCalendar: typeof import('@wot-ui/ui/components/wd-calendar/wd-calendar.vue')['default']
-    WdCascader: typeof import('@wot-ui/ui/components/wd-cascader/wd-cascader.vue')['default']
-    WdCell: typeof import('@wot-ui/ui/components/wd-cell/wd-cell.vue')['default']
-    WdCheckbox: typeof import('@wot-ui/ui/components/wd-checkbox/wd-checkbox.vue')['default']
-    WdConfigProvider: typeof import('@wot-ui/ui/components/wd-config-provider/wd-config-provider.vue')['default']
-    WdDatetimePicker: typeof import('@wot-ui/ui/components/wd-datetime-picker/wd-datetime-picker.vue')['default']
-    WdDialog: typeof import('@wot-ui/ui/components/wd-dialog/wd-dialog.vue')['default']
-    WdEmpty: typeof import('@wot-ui/ui/components/wd-empty/wd-empty.vue')['default']
-    WdForm: typeof import('@wot-ui/ui/components/wd-form/wd-form.vue')['default']
-    WdFormItem: typeof import('@wot-ui/ui/components/wd-form-item/wd-form-item.vue')['default']
-    WdIcon: typeof import('@wot-ui/ui/components/wd-icon/wd-icon.vue')['default']
-    WdImg: typeof import('@wot-ui/ui/components/wd-img/wd-img.vue')['default']
-    WdInput: typeof import('@wot-ui/ui/components/wd-input/wd-input.vue')['default']
-    WdLoading: typeof import('@wot-ui/ui/components/wd-loading/wd-loading.vue')['default']
-    WdNavbar: typeof import('@wot-ui/ui/components/wd-navbar/wd-navbar.vue')['default']
-    WdNoticeBar: typeof import('@wot-ui/ui/components/wd-notice-bar/wd-notice-bar.vue')['default']
-    WdPicker: typeof import('@wot-ui/ui/components/wd-picker/wd-picker.vue')['default']
-    WdPickerView: typeof import('@wot-ui/ui/components/wd-picker-view/wd-picker-view.vue')['default']
-    WdPopup: typeof import('@wot-ui/ui/components/wd-popup/wd-popup.vue')['default']
-    WdProgress: typeof import('@wot-ui/ui/components/wd-progress/wd-progress.vue')['default']
-    WdSearch: typeof import('@wot-ui/ui/components/wd-search/wd-search.vue')['default']
-    WdSelectPicker: typeof import('@wot-ui/ui/components/wd-select-picker/wd-select-picker.vue')['default']
-    WdSwiper: typeof import('@wot-ui/ui/components/wd-swiper/wd-swiper.vue')['default']
-    WdTag: typeof import('@wot-ui/ui/components/wd-tag/wd-tag.vue')['default']
-    WdTextarea: typeof import('@wot-ui/ui/components/wd-textarea/wd-textarea.vue')['default']
-    WdToast: typeof import('@wot-ui/ui/components/wd-toast/wd-toast.vue')['default']
+    RouterLink: typeof import('vue-router')['RouterLink']
+    RouterView: typeof import('vue-router')['RouterView']
+    WdActionSheet: typeof import('@wot-ui/ui/components/wd-action-sheet/wd-action-sheet.vue')['WdActionSheet']
+    WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['WdButton']
+    WdCalendar: typeof import('@wot-ui/ui/components/wd-calendar/wd-calendar.vue')['WdCalendar']
+    WdCascader: typeof import('@wot-ui/ui/components/wd-cascader/wd-cascader.vue')['WdCascader']
+    WdCell: typeof import('@wot-ui/ui/components/wd-cell/wd-cell.vue')['WdCell']
+    WdCheckbox: typeof import('@wot-ui/ui/components/wd-checkbox/wd-checkbox.vue')['WdCheckbox']
+    WdConfigProvider: typeof import('@wot-ui/ui/components/wd-config-provider/wd-config-provider.vue')['WdConfigProvider']
+    WdDatetimePicker: typeof import('@wot-ui/ui/components/wd-datetime-picker/wd-datetime-picker.vue')['WdDatetimePicker']
+    WdDialog: typeof import('@wot-ui/ui/components/wd-dialog/wd-dialog.vue')['WdDialog']
+    WdEmpty: typeof import('@wot-ui/ui/components/wd-empty/wd-empty.vue')['WdEmpty']
+    WdForm: typeof import('@wot-ui/ui/components/wd-form/wd-form.vue')['WdForm']
+    WdFormItem: typeof import('@wot-ui/ui/components/wd-form-item/wd-form-item.vue')['WdFormItem']
+    WdIcon: typeof import('@wot-ui/ui/components/wd-icon/wd-icon.vue')['WdIcon']
+    WdImg: typeof import('@wot-ui/ui/components/wd-img/wd-img.vue')['WdImg']
+    WdInput: typeof import('@wot-ui/ui/components/wd-input/wd-input.vue')['WdInput']
+    WdLoading: typeof import('@wot-ui/ui/components/wd-loading/wd-loading.vue')['WdLoading']
+    WdNavbar: typeof import('@wot-ui/ui/components/wd-navbar/wd-navbar.vue')['WdNavbar']
+    WdNoticeBar: typeof import('@wot-ui/ui/components/wd-notice-bar/wd-notice-bar.vue')['WdNoticeBar']
+    WdPicker: typeof import('@wot-ui/ui/components/wd-picker/wd-picker.vue')['WdPicker']
+    WdPickerView: typeof import('@wot-ui/ui/components/wd-picker-view/wd-picker-view.vue')['WdPickerView']
+    WdPopup: typeof import('@wot-ui/ui/components/wd-popup/wd-popup.vue')['WdPopup']
+    WdProgress: typeof import('@wot-ui/ui/components/wd-progress/wd-progress.vue')['WdProgress']
+    WdQrCode: typeof import('@wot-ui/ui/components/wd-qr-code/wd-qr-code.vue')['WdQrCode']
+    WdRadio: typeof import('@wot-ui/ui/components/wd-radio/wd-radio.vue')['WdRadio']
+    WdRadioGroup: typeof import('@wot-ui/ui/components/wd-radio-group/wd-radio-group.vue')['WdRadioGroup']
+    WdSearch: typeof import('@wot-ui/ui/components/wd-search/wd-search.vue')['WdSearch']
+    WdSelectPicker: typeof import('@wot-ui/ui/components/wd-select-picker/wd-select-picker.vue')['WdSelectPicker']
+    WdSwiper: typeof import('@wot-ui/ui/components/wd-swiper/wd-swiper.vue')['WdSwiper']
+    WdTag: typeof import('@wot-ui/ui/components/wd-tag/wd-tag.vue')['WdTag']
+    WdTextarea: typeof import('@wot-ui/ui/components/wd-textarea/wd-textarea.vue')['WdTextarea']
+    WdToast: typeof import('@wot-ui/ui/components/wd-toast/wd-toast.vue')['WdToast']
   }
 }
+
+// For TSX support
+declare global {
+  const AuthInputItem: typeof import('./src/components/auth/AuthInputItem.vue')['default']
+  const BaseAuthDialog: typeof import('./src/components/BaseAuthDialog/index.vue')['default']
+  const FormField: typeof import('./src/components/FormField/index.vue')['default']
+  const RouterLink: typeof import('vue-router')['RouterLink']
+  const RouterView: typeof import('vue-router')['RouterView']
+  const WdActionSheet: typeof import('@wot-ui/ui/components/wd-action-sheet/wd-action-sheet.vue')['WdActionSheet']
+  const WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['WdButton']
+  const WdCalendar: typeof import('@wot-ui/ui/components/wd-calendar/wd-calendar.vue')['WdCalendar']
+  const WdCascader: typeof import('@wot-ui/ui/components/wd-cascader/wd-cascader.vue')['WdCascader']
+  const WdCell: typeof import('@wot-ui/ui/components/wd-cell/wd-cell.vue')['WdCell']
+  const WdCheckbox: typeof import('@wot-ui/ui/components/wd-checkbox/wd-checkbox.vue')['WdCheckbox']
+  const WdConfigProvider: typeof import('@wot-ui/ui/components/wd-config-provider/wd-config-provider.vue')['WdConfigProvider']
+  const WdDatetimePicker: typeof import('@wot-ui/ui/components/wd-datetime-picker/wd-datetime-picker.vue')['WdDatetimePicker']
+  const WdDialog: typeof import('@wot-ui/ui/components/wd-dialog/wd-dialog.vue')['WdDialog']
+  const WdEmpty: typeof import('@wot-ui/ui/components/wd-empty/wd-empty.vue')['WdEmpty']
+  const WdForm: typeof import('@wot-ui/ui/components/wd-form/wd-form.vue')['WdForm']
+  const WdFormItem: typeof import('@wot-ui/ui/components/wd-form-item/wd-form-item.vue')['WdFormItem']
+  const WdIcon: typeof import('@wot-ui/ui/components/wd-icon/wd-icon.vue')['WdIcon']
+  const WdImg: typeof import('@wot-ui/ui/components/wd-img/wd-img.vue')['WdImg']
+  const WdInput: typeof import('@wot-ui/ui/components/wd-input/wd-input.vue')['WdInput']
+  const WdLoading: typeof import('@wot-ui/ui/components/wd-loading/wd-loading.vue')['WdLoading']
+  const WdNavbar: typeof import('@wot-ui/ui/components/wd-navbar/wd-navbar.vue')['WdNavbar']
+  const WdNoticeBar: typeof import('@wot-ui/ui/components/wd-notice-bar/wd-notice-bar.vue')['WdNoticeBar']
+  const WdPicker: typeof import('@wot-ui/ui/components/wd-picker/wd-picker.vue')['WdPicker']
+  const WdPickerView: typeof import('@wot-ui/ui/components/wd-picker-view/wd-picker-view.vue')['WdPickerView']
+  const WdPopup: typeof import('@wot-ui/ui/components/wd-popup/wd-popup.vue')['WdPopup']
+  const WdProgress: typeof import('@wot-ui/ui/components/wd-progress/wd-progress.vue')['WdProgress']
+  const WdQrCode: typeof import('@wot-ui/ui/components/wd-qr-code/wd-qr-code.vue')['WdQrCode']
+  const WdRadio: typeof import('@wot-ui/ui/components/wd-radio/wd-radio.vue')['WdRadio']
+  const WdRadioGroup: typeof import('@wot-ui/ui/components/wd-radio-group/wd-radio-group.vue')['WdRadioGroup']
+  const WdSearch: typeof import('@wot-ui/ui/components/wd-search/wd-search.vue')['WdSearch']
+  const WdSelectPicker: typeof import('@wot-ui/ui/components/wd-select-picker/wd-select-picker.vue')['WdSelectPicker']
+  const WdSwiper: typeof import('@wot-ui/ui/components/wd-swiper/wd-swiper.vue')['WdSwiper']
+  const WdTag: typeof import('@wot-ui/ui/components/wd-tag/wd-tag.vue')['WdTag']
+  const WdTextarea: typeof import('@wot-ui/ui/components/wd-textarea/wd-textarea.vue')['WdTextarea']
+  const WdToast: typeof import('@wot-ui/ui/components/wd-toast/wd-toast.vue')['WdToast']
+}

+ 17 - 17
package.json

@@ -1,6 +1,7 @@
 {
   "name": "uni-preset-vue",
   "version": "0.0.0",
+  "packageManager": "pnpm@11.16.0",
   "scripts": {
     "dev:h5": "uni",
     "dev:mp-weixin": "uni -p mp-weixin",
@@ -31,23 +32,22 @@
     "@dcloudio/uni-mp-weixin": "3.0.0-4080420251103001",
     "@dcloudio/uni-mp-xhs": "3.0.0-4080420251103001",
     "@dcloudio/uni-quickapp-webview": "3.0.0-4080420251103001",
-    "@dcloudio/uni-ui": "^1.5.11",
+    "@dcloudio/uni-ui": "^1.5.12",
     "@vant/area-data": "^2.1.0",
-    "@wot-ui/ui": "^2.1.0",
+    "@wot-ui/ui": "^2.3.2",
     "async-validator": "^4.2.5",
-    "dayjs": "^1.11.19",
-    "lodash-es": "^4.17.22",
+    "dayjs": "^1.11.21",
+    "lodash-es": "^4.18.1",
     "mp-html": "^2.5.2",
     "nanoid": "^5.1.6",
     "pinia": "^3.0.4",
     "pinia-plugin-persistedstate": "^4.7.1",
-    "qs": "^6.14.1",
-    "uqrcodejs": "^4.0.7",
-    "vue": "^3.4.21",
+    "qs": "^6.15.3",
+    "vue": "3.5.26",
     "vue-i18n": "^9.1.9"
   },
   "devDependencies": {
-    "@dcloudio/types": "^3.4.8",
+    "@dcloudio/types": "3.4.28",
     "@dcloudio/uni-automator": "3.0.0-4080420251103001",
     "@dcloudio/uni-cli-shared": "3.0.0-4080420251103001",
     "@dcloudio/uni-stacktracey": "3.0.0-4080420251103001",
@@ -56,27 +56,27 @@
     "@types/node": "^25.0.3",
     "@typescript-eslint/eslint-plugin": "^6.21.0",
     "@typescript-eslint/parser": "^6.21.0",
-    "@uni-helper/uni-app-types": "1.0.0-alpha.7",
-    "@uni-helper/uni-ui-types": "1.0.0-alpha.7",
-    "@uni-helper/vite-plugin-uni-components": "^0.2.6",
-    "@uni-ku/root": "^1.4.1",
-    "@vue/runtime-core": "^3.4.21",
+    "@uni-helper/uni-app-types": "1.1.0",
+    "@uni-helper/uni-ui-types": "1.1.0",
+    "@uni-helper/vite-plugin-uni-components": "^0.3.2",
+    "@uni-ku/root": "^1.5.0",
+    "@vue/runtime-core": "3.5.26",
     "@vue/tsconfig": "^0.9.1",
-    "dotenv": "^17.2.3",
+    "dotenv": "^17.4.2",
     "eslint": "^8.57.1",
     "eslint-config-prettier": "^10.1.8",
     "eslint-plugin-simple-import-sort": "^12.1.1",
     "eslint-plugin-vue": "^9.25.0",
     "jsonc-parser": "^3.3.1",
     "miniprogram-api-typings": "^4.1.2",
-    "prettier": "^3.7.4",
+    "prettier": "^3.9.6",
     "sass": "1.78.0",
-    "tsx": "^4.21.0",
+    "tsx": "^4.23.12",
     "typescript": "^5.9.3",
     "unplugin-auto-import": "^20.3.0",
     "unplugin-vue-components": "^30.0.0",
     "vite": "5.2.8",
-    "vite-tsconfig-paths": "^6.0.3",
+    "vite-tsconfig-paths": "^6.1.1",
     "vue-eslint-parser": "9.4.3",
     "vue-tsc": "^3.3.9"
   }

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 214 - 204
pnpm-lock.yaml


+ 11 - 0
pnpm-workspace.yaml

@@ -0,0 +1,11 @@
+nodeLinker: hoisted
+onlyBuiltDependencies:
+  - '@parcel/watcher'
+  - core-js
+  - esbuild
+  - less
+allowBuilds:
+  '@parcel/watcher': true
+  core-js: true
+  esbuild: true
+  less: true

+ 1 - 4
src/components/auth/AuthInputItem.vue

@@ -104,10 +104,7 @@ watch(
 
 /* ---------- computed ---------- */
 const inputType = computed(() => {
-  if (props.passwordToggle) {
-    return isPassword.value ? 'password' : 'text'
-  }
-  return props.type
+  return props.type === 'number' ? 'number' : 'text'
 })
 
 const showClear = computed(() => !!modelValue.value)

+ 1 - 1
src/composables/useCodeCountdown.ts

@@ -1,4 +1,4 @@
-import { ref, computed, onBeforeUnmount } from 'vue'
+import { computed, onBeforeUnmount,ref } from 'vue'
 
 export function useCodeCountdown() {
   const countdown = ref(0)

+ 4 - 25
src/pages-auth/bank/index.vue

@@ -31,7 +31,7 @@
             v-model="bankInfo.cardNumber"
             class="form-input"
             type="number"
-            maxlength="19"
+            :maxlength="19"
             placeholder="请输入银行卡号"
             placeholder-class="form-placeholder"
             :disabled="isFormDisabled"
@@ -55,7 +55,7 @@
             v-model="bankInfo.phone"
             class="form-input"
             type="number"
-            maxlength="11"
+            :maxlength="11"
             placeholder="请输入银行预留手机号"
             placeholder-class="form-placeholder"
             :disabled="isFormDisabled"
@@ -251,7 +251,7 @@ const verifyBankPhoneIfNeeded = async () => {
 }
 
 const verifyBankCard = async () => {
-  const res = await personVerifyBank4Api({
+  await personVerifyBank4Api({
     mobile: bankInfo.phone,
     realName: realName.value,
     cardId: idCardNumber.value,
@@ -260,31 +260,15 @@ const verifyBankCard = async () => {
     bankName: bankInfo.bankName,
   })
 
-  if (res.code !== API_SUCCESS_CODE) {
-    uni.showToast({
-      title: res.msg || '认证失败',
-      icon: 'none',
-    })
-    return false
-  }
-
   return true
 }
 
 const bindBankInfo = async () => {
-  const res = await bindBankInfoApi({
+  await bindBankInfoApi({
     bankCardNumber: bankInfo.cardNumber,
     bankPhone: bankInfo.phone,
   })
 
-  if (res.code !== API_SUCCESS_CODE) {
-    uni.showToast({
-      title: res.msg || '银行卡绑定失败',
-      icon: 'none',
-    })
-    return false
-  }
-
   return true
 }
 
@@ -321,11 +305,6 @@ const handleSubmit = async () => {
     backTimer = setTimeout(() => {
       uni.navigateBack()
     }, BACK_DELAY)
-  } catch {
-    uni.showToast({
-      title: '请求失败,请检查网络',
-      icon: 'none',
-    })
   } finally {
     loading.value = false
   }

+ 0 - 4
src/pages-auth/certification-service/index.vue

@@ -179,10 +179,6 @@ const getAuthInfo = async () => {
 
     console.error('获取认证信息失败', error)
 
-    uni.showToast({
-      title: '获取认证信息失败',
-      icon: 'none',
-    })
   }
 }
 

+ 5 - 6
src/pages-auth/face/index.vue

@@ -24,7 +24,6 @@ import { ref } from 'vue'
 import { onLoad, onUnload } from '@dcloudio/uni-app'
 
 import { bindBaseInfoApi, personCheckFaceApi } from '@/services/modules/auth'
-
 const API_SUCCESS_CODE = 0
 const API_PENDING_CODE = 1
 const FACE_AUTH_SUCCESS_CODE = 100000
@@ -99,7 +98,11 @@ const checkFaceResult = async () => {
   return null
 }
 
-const handleMessage = async (event: UniApp.WebViewMessageEvent) => {
+interface WebViewMessageEvent {
+  detail: { data?: Array<Record<string, any>> }
+}
+
+const handleMessage = async (event: WebViewMessageEvent) => {
   const [message] = event.detail.data || []
 
   if (!message) {
@@ -162,10 +165,6 @@ const handleMessage = async (event: UniApp.WebViewMessageEvent) => {
 
     uni.hideLoading()
 
-    uni.showToast({
-      title: '结果验证失败',
-      icon: 'none',
-    })
   } finally {
     checking.value = false
   }

+ 2 - 8
src/pages-auth/fesco/index.vue

@@ -79,12 +79,6 @@ import { computed, ref } from 'vue'
 
 import { useUserStore } from '@/stores/modules/user'
 
-interface VideoErrorEvent {
-  detail: {
-    errMsg: string
-  }
-}
-
 const FESCO_STEP_IMAGE = 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/fesco-step.png'
 const FESCO_QRCODE_IMAGE = 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/img/fesco-qrcode.png'
 const FESCO_VIDEO_URL = 'https://yy-cloud-oss.oss-cn-beijing.aliyuncs.com/video/fesco-video.mp4'
@@ -114,8 +108,8 @@ const handleCloseTutorial = (): void => {
   adviseShow.value = false
 }
 
-const handleVideoError = (event: VideoErrorEvent): void => {
-  console.log('视频错误信息:', event.detail.errMsg)
+const handleVideoError = (event: any): void => {
+  console.log('视频错误信息:', event.detail?.errMsg || '未知错误')
 }
 </script>
 

+ 2 - 11
src/pages-auth/identity/index.vue

@@ -358,17 +358,12 @@ const handleOcrRecognize = async () => {
       backUrl: idCardImages.back,
     })
 
-    if (Number(res.code) !== API_SUCCESS_CODE) {
-      throw new Error(res.msg || 'OCR识别失败')
-    }
-
     clearOcrFormData()
     setOcrFormData(res.data)
     ocrFinished.value = true
   } catch (error) {
     ocrFinished.value = false
     clearOcrFormData()
-    showToast(getErrorMessage(error, 'OCR识别失败'))
   } finally {
     ocrLoading.value = false
   }
@@ -422,10 +417,6 @@ const handleSubmit = async () => {
       realName: formData.realName,
     })
 
-    if (Number(faceRes.code) !== API_SUCCESS_CODE) {
-      throw new Error(faceRes.msg || '人脸认证初始化失败')
-    }
-
     const faceData = faceRes.data
 
     if (!faceData?.serialNo || !faceData.faceUrl) {
@@ -441,7 +432,7 @@ const handleSubmit = async () => {
       }),
     })
   } catch (error) {
-    showToast(getErrorMessage(error, '认证失败,请重试'))
+    console.error('认证失败:', error)
   } finally {
     submitLoading.value = false
   }
@@ -470,7 +461,7 @@ const handleBack = () => {
 }
 
 const getAuthorization = () => {
-  const token = userStore.accessToken || ''
+  const token = userStore.access_token || ''
 
   if (!token) return ''
 

+ 6 - 27
src/pages-auth/settlement-channel-sign/index.vue

@@ -269,7 +269,6 @@ const handleSubmit = async (): Promise<void> => {
     await signCert()
   } catch (error) {
     console.error('handleSubmit error:', error)
-    showToast('提交失败,请稍后重试')
   } finally {
     isSubmitting.value = false
   }
@@ -285,13 +284,7 @@ const saveBaseInfo = async (): Promise<boolean> => {
     subjectLocation: selectedSubjectLocation.value,
   }
 
-  const res = await saveBaseInfoApi(data)
-
-  if (res.code !== 0) {
-    showToast(res.msg || '保存信息失败')
-    return false
-  }
-
+  await saveBaseInfoApi(data)
   return true
 }
 
@@ -301,13 +294,7 @@ const signCert = async (): Promise<void> => {
     subjectLocation: selectedSubjectLocation.value,
   }
 
-  const res = await signCertApi(data)
-
-  if (res.code !== 0) {
-    showToast(res.msg || '签约失败')
-    return
-  }
-
+  await signCertApi(data)
   await afterSignCert()
 }
 
@@ -413,10 +400,8 @@ const handleSignatureSuccess = async (payload: SignatureSuccessPayload) => {
     agreementUrl: signatureUrl,
   }
 
-  const res = await signAgreementApi(obj)
-  if (res.code === 0) {
-    certFn()
-  }
+  await signAgreementApi(obj)
+  certFn()
 }
 
 const handlePreviewAgreementPdf = (agreement: AgreementPdfItem): void => {
@@ -450,7 +435,7 @@ const certFn = async () => {
 
   const res = await batchChannelCertApi(data)
 
-  if (res.code !== 0 || res.data.commonCertStatus === 'FAIL_SIGN') {
+  if (res.data.commonCertStatus === 'FAIL_SIGN') {
     showToast(res.msg || res.data.errMsg || '签约失败')
     return
   }
@@ -488,14 +473,9 @@ const signRedirect = async (subjectLocation: string): Promise<boolean> => {
     return false
   }
   try {
-    const res = await signRedirectApi({
+    await signRedirectApi({
       subjectLocation,
     })
-    if (res.code !== 0) {
-      showToast(res.msg || '签约失败')
-      return false
-    }
-
     settlementSignStore.clearContext()
     uni.showToast({
       title: '签约成功',
@@ -505,7 +485,6 @@ const signRedirect = async (subjectLocation: string): Promise<boolean> => {
     return true
   } catch (error) {
     console.error('signRedirect error:', error)
-    showToast('网络异常,请稍后重试')
     return false
   }
 }

+ 1 - 1
src/pages-auth/web-view/index.vue

@@ -123,7 +123,7 @@ const handleMessage = (event: WebViewMessageEvent) => {
   redirectToSettlementSignPage(subjectLocation, signTraceId)
 }
 
-onLoad((query: WebViewPageQuery) => {
+onLoad((query?: WebViewPageQuery) => {
   if (!query?.url) {
     showToast('签约地址不存在')
 

+ 4 - 19
src/pages-common/entity-selector/index.vue

@@ -132,7 +132,6 @@ import type {
   ScorePackageDrugItem,
   SurveyTargetItem,
 } from '@/services/modules/task/entitySelector/type'
-
 const entityAliasConfig = {
   pharmacyName: {
     entityName: '药店',
@@ -181,8 +180,6 @@ const showAddDialog = ref(false)
 const addName = ref('')
 const addError = ref('')
 
-let requestVersion = 0
-
 const isSurveyTarget = computed(() => pageAlias.value === 'surveyTarget')
 const isSurveyCategory = computed(() => pageAlias.value === 'surveyCategory')
 const canAddEntity = computed(() => !isSurveyTarget.value && !isSurveyCategory.value)
@@ -249,13 +246,11 @@ const loadList = async (reset = false) => {
   }
 
   if (reset) {
-    requestVersion += 1
     pagination.current = 1
     list.value = []
     finished.value = false
   }
 
-  const version = requestVersion
   const currentPage = pagination.current
 
   loading.value = true
@@ -269,10 +264,6 @@ const loadList = async (reset = false) => {
 
     const { records = [], pages } = await requestEntityPage(params)
 
-    if (version !== requestVersion) {
-      return
-    }
-
     list.value = reset ? records : [...list.value, ...records]
 
     finished.value = currentPage >= pages || records.length < pagination.size
@@ -280,17 +271,10 @@ const loadList = async (reset = false) => {
     if (!finished.value) {
       pagination.current = currentPage + 1
     }
-  } catch {
-    if (version === requestVersion) {
-      uni.showToast({
-        title: '列表加载失败,请稍后重试',
-        icon: 'none',
-      })
-    }
+  } catch (error) {
+    console.error('列表加载失败:', error)
   } finally {
-    if (version === requestVersion) {
-      loading.value = false
-    }
+    loading.value = false
   }
 }
 
@@ -466,6 +450,7 @@ onPullDownRefresh(async () => {
     uni.stopPullDownRefresh()
   }
 })
+
 </script>
 
 <style lang="scss" scoped>

+ 30 - 21
src/pages-common/location-select/index.vue

@@ -7,20 +7,20 @@
         :longitude="state.longitude"
         :markers="markers"
         :circles="isCurrentPositionMode ? circles : []"
-        scale="16"
+        :scale="16"
         show-location
         show-compass
         @poitap="handlePoiTap"
-      >
-        <cover-view class="location-btn" @click="resetLocation">
-          <cover-view class="location-btn-text">⌖</cover-view>
-        </cover-view>
-
-        <cover-view v-if="isCurrentPositionMode" class="range-tip">
-          <cover-view class="range-tip-icon">!</cover-view>
-          <cover-view class="range-tip-text"> 可以在当前范围内点击重新选择打卡地点 </cover-view>
-        </cover-view>
-      </map>
+      />
+
+      <view class="location-btn" @click="resetLocation">
+        <view class="location-btn-text">⌖</view>
+      </view>
+
+      <view v-if="isCurrentPositionMode" class="range-tip">
+        <view class="range-tip-icon">!</view>
+        <view class="range-tip-text"> 可以在当前范围内点击重新选择打卡地点 </view>
+      </view>
     </view>
 
     <view class="content-panel">
@@ -128,6 +128,8 @@ import {
 } from '@/lib/location'
 import { debounce } from '@/plugins/debounce'
 
+import { createLatestTaskController } from '@/utils/latestTask'
+
 type LocationSelectType = 'currPosi' | string
 
 interface PageOptions {
@@ -190,13 +192,14 @@ const state = reactive({
 
 const results = ref<PoiSearchItem[]>([])
 
-let searchToken = 0
+const searchTask = createLatestTaskController()
 
 const isCurrentPositionMode = computed(() => type.value === 'currPosi')
 
 const markers = computed(() => [
   {
     id: 1,
+    iconPath: '/static/images/common/map-marker.png',
     latitude: state.latitude,
     longitude: state.longitude,
     width: 32,
@@ -206,9 +209,12 @@ const markers = computed(() => [
       color: '#1f2937',
       fontSize: 13,
       borderRadius: 6,
+      borderWidth: 0,
+      borderColor: '#ffffff',
       bgColor: '#ffffff',
       padding: 8,
-      display: 'BYCLICK',
+      textAlign: 'center' as const,
+      display: 'BYCLICK' as const,
     },
   },
 ])
@@ -236,9 +242,12 @@ onLoad((options?: PageOptions) => {
   void resetLocation()
 })
 
-const debouncedSearch = debounce(() => {
-  void searchPlaces()
-}, 300)
+const debouncedSearch = debounce(
+  () => {
+    void searchPlaces()
+  },
+  { wait: 300 }
+)
 
 const onKeywordInput = () => {
   if (isCurrentPositionMode.value) return
@@ -272,7 +281,7 @@ const searchPlaces = async () => {
     return
   }
 
-  const currentToken = ++searchToken
+  const currentToken = searchTask.next()
 
   try {
     state.searching = true
@@ -282,11 +291,11 @@ const searchPlaces = async () => {
       longitude: state.longitude,
     })
 
-    if (currentToken !== searchToken) return
+    if (!searchTask.isLatest(currentToken)) return
 
     results.value = res.list
   } catch {
-    if (currentToken !== searchToken) return
+    if (!searchTask.isLatest(currentToken)) return
 
     results.value = []
 
@@ -295,7 +304,7 @@ const searchPlaces = async () => {
       icon: 'none',
     })
   } finally {
-    if (currentToken === searchToken) {
+    if (searchTask.isLatest(currentToken)) {
       state.searching = false
     }
   }
@@ -360,7 +369,7 @@ const clearSearch = () => {
   state.keyword = ''
   results.value = []
   state.searching = false
-  searchToken += 1
+  searchTask.invalidate()
 }
 
 const confirmLocation = () => {

+ 5 - 5
src/pages-common/signature/components/SignaturePad.vue

@@ -54,9 +54,9 @@ const lastPoint = ref<{ x: number; y: number } | null>(null)
 onMounted(async () => {
   await nextTick()
 
-  const sys = uni.getSystemInfoSync()
-  canvasWidth.value = sys.windowWidth
-  canvasHeight.value = sys.windowHeight
+  const windowInfo = uni.getWindowInfo()
+  canvasWidth.value = windowInfo.windowWidth
+  canvasHeight.value = windowInfo.windowHeight
 
   ctx.value = uni.createCanvasContext(CANVAS_ID, instance?.proxy as any)
 
@@ -81,7 +81,7 @@ const drawBackground = () => {
   ctx.value.draw()
 }
 
-const onTouchStart = (e: TouchEvent) => {
+const onTouchStart = (e: any) => {
   const touch = (e as any).touches?.[0]
   if (!touch || !ctx.value) return
 
@@ -89,7 +89,7 @@ const onTouchStart = (e: TouchEvent) => {
   lastPoint.value = { x: touch.x, y: touch.y }
 }
 
-const onTouchMove = (e: TouchEvent) => {
+const onTouchMove = (e: any) => {
   if (!drawing.value || !ctx.value || !lastPoint.value) return
 
   const touch = (e as any).touches?.[0]

+ 8 - 19
src/pages-common/signature/index.vue

@@ -152,7 +152,7 @@ const handleSignatureDone = async (filePath: string) => {
     await handleUploadedSignature(signatureUrl)
   } catch (error) {
     console.error('[Signature] handle signature done failed:', error)
-
+    if ((error as { handled?: boolean })?.handled) return
     showToast(error instanceof Error ? error.message : '上传失败')
   } finally {
     uni.hideLoading()
@@ -180,25 +180,14 @@ const handleUploadedSignature = async (signatureUrl: string) => {
 
 // 廉洁承诺书签署:上传后调用后端签署接口
 const saveSignature = async (signatureUrl: string) => {
-  try {
-    const res = await signAgreementApi({
-      agreementType: type.value,
-      signatureUrl,
-    })
-
-    if (res.code !== 0) {
-      showToast(res.msg || '签署失败')
-      return
-    }
-
-    uni.navigateBack({
-      delta: 1,
-    })
-  } catch (error) {
-    console.error('[Signature] save signature failed:', error)
+  await signAgreementApi({
+    agreementType: type.value,
+    signatureUrl,
+  })
 
-    showToast('签署失败')
-  }
+  uni.navigateBack({
+    delta: 1,
+  })
 }
 
 // 结算渠道签署:上传后回传 payload

+ 1 - 1
src/pages-common/web-view/index.vue

@@ -35,7 +35,7 @@ const setPageTitle = (title?: string): void => {
   })
 }
 
-onLoad((query: PageQuery) => {
+onLoad((query?: PageQuery) => {
   setPageTitle(query?.title)
 
   if (!query?.url) {

+ 0 - 14
src/pages-mine/agreement/index.vue

@@ -57,13 +57,6 @@ const currentUserInfo = computed(() => userStore.currentUserInfo)
 
 const certSuccessList = ref<AgreementPdfItem[]>([])
 
-const showToast = (title: string): void => {
-  uni.showToast({
-    title,
-    icon: 'none',
-  })
-}
-
 const hasSignedAgreement = (agreementCode: string): boolean => {
   const signedAgreement = currentUserInfo.value?.signedAgreement ?? []
 
@@ -158,8 +151,6 @@ const getCertSuccessList = async (): Promise<void> => {
     mask: true,
   })
 
-  let hasError = false
-
   try {
     const agreementInfoGroups = await Promise.all(
       subjectLocations.map(async (subjectLocation) => {
@@ -170,7 +161,6 @@ const getCertSuccessList = async (): Promise<void> => {
 
           return normalizeAgreementInfos(res.data?.agreementInfos)
         } catch (error) {
-          hasError = true
           console.log('getSubjectLocationAgreementApi error:', error)
           return []
         }
@@ -178,10 +168,6 @@ const getCertSuccessList = async (): Promise<void> => {
     )
 
     certSuccessList.value = dedupeAgreementInfos(agreementInfoGroups.flat())
-
-    if (hasError) {
-      showToast('部分协议加载失败')
-    }
   } finally {
     uni.hideLoading()
   }

+ 0 - 11
src/pages-mine/compliance-education/detail.vue

@@ -70,7 +70,6 @@ import type {
   ComplianceEducationFile,
   DeptRegulationsItem,
 } from '@/services/modules/mine/common/type'
-
 type PreviewType = 'document' | 'image'
 
 const DOCUMENT_EXTENSIONS = ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'pdf'] as const
@@ -111,12 +110,6 @@ const getDeptRulesItem = async (): Promise<void> => {
 
   try {
     const res = await getDeptRegulationsListApi()
-
-    if (res.code !== 0) {
-      rulesItem.value = null
-      return
-    }
-
     const list = Array.isArray(res.data) ? res.data : []
 
     rulesItem.value =
@@ -128,10 +121,6 @@ const getDeptRulesItem = async (): Promise<void> => {
 
     console.error('[compliance-education-detail] getDeptRulesItem failed:', error)
 
-    uni.showToast({
-      title: '加载失败,请稍后重试',
-      icon: 'none',
-    })
   } finally {
     loading.value = false
   }

+ 0 - 11
src/pages-mine/compliance-education/index.vue

@@ -53,7 +53,6 @@ import { onLoad } from '@dcloudio/uni-app'
 
 import { getDeptRegulationsListApi } from '@/services/modules/mine/common'
 import type { DeptRegulationsItem } from '@/services/modules/mine/common/type'
-
 const loading = ref(false)
 const deptRulesList = ref<DeptRegulationsItem[]>([])
 
@@ -62,12 +61,6 @@ const getDeptRulesList = async (): Promise<void> => {
 
   try {
     const res = await getDeptRegulationsListApi()
-
-    if (res.code !== 0) {
-      deptRulesList.value = []
-      return
-    }
-
     const list = Array.isArray(res.data) ? res.data : []
 
     deptRulesList.value = list.filter((item: DeptRegulationsItem) => {
@@ -78,10 +71,6 @@ const getDeptRulesList = async (): Promise<void> => {
 
     console.error('[compliance-education] getDeptRulesList failed:', error)
 
-    uni.showToast({
-      title: '加载失败,请稍后重试',
-      icon: 'none',
-    })
   } finally {
     loading.value = false
   }

+ 9 - 35
src/pages-mine/compliance-evaluation/ability-test.vue

@@ -73,17 +73,17 @@ import { ref } from 'vue'
 
 import { onLoad } from '@dcloudio/uni-app'
 
-import type {
-  QuizInfo,
-  QuizItem,
-  QuizPltDetailResponse,
-  QuizPltSubmitParams,
-} from '@/services/modules/mine/complianceEvaluation'
 import {
   getQuizPltAvailApi,
   getQuizPltDetailsApi,
   quizTestResultCreateApi,
 } from '@/services/modules/mine/complianceEvaluation'
+import type {
+  QuizInfo,
+  QuizItem,
+  QuizPltDetailResponse,
+  QuizPltSubmitParams,
+} from '@/services/modules/mine/complianceEvaluation/type'
 
 interface PageQuery {
   fromHome?: string
@@ -179,7 +179,7 @@ const buildSubmitParams = (): QuizPltSubmitParams => {
       itemQty: quiz.expand.itemQty,
       totalMark: quiz.expand.totalMark,
       passingMark: quiz.expand.passingMark,
-      finalMark: passedQuizItem.finalMark,
+      finalMark: passedQuizItem?.finalMark ?? 0,
       items: buildSubmitItems(passedQuizItem),
     }
   })
@@ -266,15 +266,7 @@ const submitFn = async () => {
     submitting.value = true
 
     const params = buildSubmitParams()
-    const res = await quizTestResultCreateApi(params)
-
-    if (res.code !== 0) {
-      uni.showToast({
-        title: '提交失败',
-        icon: 'none',
-      })
-      return
-    }
+    await quizTestResultCreateApi(params)
 
     uni.showToast({
       title: '测验通过',
@@ -293,10 +285,6 @@ const submitFn = async () => {
     })
   } catch (error) {
     console.error('quizTestResultCreateApi error:', error)
-    uni.showToast({
-      title: '提交失败',
-      icon: 'none',
-    })
   } finally {
     submitting.value = false
   }
@@ -307,22 +295,13 @@ const getPltAvailQuizListFn = async () => {
     loading.value = true
 
     const res = await getQuizPltAvailApi()
-
-    if (res.code !== 0) {
-      uni.showToast({
-        title: '获取测试列表失败',
-        icon: 'none',
-      })
-      return
-    }
-
     const quizList: QuizInfo[] = res.data || []
 
     const detailList = await Promise.all(
       quizList.map(async (quizItem) => {
         const detailRes = await getQuizPltDetailsApi(quizItem.quizId)
 
-        if (detailRes.code !== 0 || !detailRes.data) {
+        if (!detailRes.data) {
           return null
         }
 
@@ -337,11 +316,6 @@ const getPltAvailQuizListFn = async () => {
     resetCurrentQuizAnswers()
   } catch (error) {
     console.error('getPltAvailQuizListFn error:', error)
-
-    uni.showToast({
-      title: '获取测试列表失败',
-      icon: 'none',
-    })
   } finally {
     loading.value = false
   }

+ 0 - 14
src/pages-mine/compliance-evaluation/index.vue

@@ -94,24 +94,10 @@ const handleToQuiz = () => {
 const getPltQuizResult = async () => {
   try {
     const res = await getQuizPltTestResultApi()
-
-    if (res.code !== 0) {
-      uni.showToast({
-        title: '获取测评结果失败',
-        icon: 'none',
-      })
-      return
-    }
-
     required.value = res.data.required
     testResults.value = res.data.testResults || []
   } catch (error) {
     console.error('getQuizPltTestResultApi error:', error)
-
-    uni.showToast({
-      title: '获取测评结果失败',
-      icon: 'none',
-    })
   }
 }
 

+ 1 - 5
src/pages-mine/feedback/index.vue

@@ -62,7 +62,7 @@ const handleSubmit = async () => {
     return
   }
 
-  const userId = currentUserInfo.value.userId
+  const userId = currentUserInfo.value?.userId
 
   if (!userId) {
     uni.showToast({
@@ -91,10 +91,6 @@ const handleSubmit = async () => {
   } catch (error) {
     console.error('提交意见反馈失败:', error)
 
-    uni.showToast({
-      title: '提交失败,请稍后重试',
-      icon: 'none',
-    })
   } finally {
     submitting.value = false
   }

+ 1 - 14
src/pages-mine/notification/detail.vue

@@ -45,8 +45,8 @@ import { computed, ref } from 'vue'
 
 import { onLoad } from '@dcloudio/uni-app'
 
-import type { NotificationRecord } from '@/services/modules/mine/notification'
 import { internalMsgReadApi } from '@/services/modules/mine/notification'
+import type { NotificationRecord } from '@/services/modules/mine/notification/type'
 
 const loading = ref(false)
 const msgDetail = ref<NotificationRecord | null>(null)
@@ -108,20 +108,7 @@ const getMsgDetail = async (id: number): Promise<void> => {
       sendType: 2,
     })
 
-    if (res.code !== 0) {
-      uni.showToast({
-        title: res.msg || '获取消息详情失败',
-        icon: 'none',
-      })
-      return
-    }
-
     msgDetail.value = res.data
-  } catch {
-    uni.showToast({
-      title: '获取消息详情失败',
-      icon: 'none',
-    })
   } finally {
     loading.value = false
   }

+ 1 - 14
src/pages-mine/notification/index.vue

@@ -44,8 +44,8 @@ import { computed, ref } from 'vue'
 
 import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
 
-import type { NotificationRecord } from '@/services/modules/mine/notification'
 import { getInternalMsgApi } from '@/services/modules/mine/notification'
+import type { NotificationRecord } from '@/services/modules/mine/notification/type'
 
 type NotificationViewRecord = NotificationRecord & {
   text: string
@@ -145,14 +145,6 @@ const getNotificationList = async (): Promise<void> => {
       sendType: 2,
     })
 
-    if (res.code !== 0) {
-      uni.showToast({
-        title: res.msg || '获取消息列表失败',
-        icon: 'none',
-      })
-      return
-    }
-
     const records = res.data.records.map(normalizeRecord)
 
     notificationList.value =
@@ -162,11 +154,6 @@ const getNotificationList = async (): Promise<void> => {
 
     finished.value =
       notificationList.value.length >= res.data.total || records.length < page.value.size
-  } catch {
-    uni.showToast({
-      title: '获取消息列表失败',
-      icon: 'none',
-    })
   } finally {
     loading.value = false
     initialized.value = true

+ 4 - 10
src/pages-mine/personal-card/components/ProfessionalSkillCard.vue

@@ -72,16 +72,10 @@ interface ProfessionalSkillFields {
 
 type Model = PersonalBusinessCardResponse & ProfessionalSkillFields
 
-const props = withDefaults(
-  defineProps<{
-    title?: string
-    maxCount?: number
-  }>(),
-  {
-    title: '专业技能',
-    maxCount: 9,
-  }
-)
+withDefaults(defineProps<{ title?: string; maxCount?: number }>(), {
+  title: '专业技能',
+  maxCount: 9,
+})
 
 const model = defineModel<Model>({ required: true })
 

+ 1 - 13
src/pages-mine/personal-card/index.vue

@@ -119,25 +119,13 @@ const handleSave = async () => {
   }
   try {
     saving.value = true
-    const res = await savePersonalBusinessCardApi(payload)
-    if (res.code !== 0) {
-      uni.showToast({
-        title: res.msg || '提交失败',
-        icon: 'none',
-      })
-      return
-    }
+    await savePersonalBusinessCardApi(payload)
     uni.showToast({
       title: '提交成功',
       icon: 'success',
     })
   } catch (error) {
     console.error('[PersonalBusinessCard] save failed:', error)
-
-    uni.showToast({
-      title: '提交失败,请稍后重试',
-      icon: 'none',
-    })
   } finally {
     saving.value = false
   }

+ 5 - 14
src/pages-mine/points-rule/index.vue

@@ -152,10 +152,7 @@ const initPage = async () => {
 const loadEnterpriseList = async () => {
   try {
     const res = await getTaskTypeDeptListApi()
-
-    if (res.code === 0) {
-      enterpriseList.value = res.data
-    }
+    enterpriseList.value = res.data
   } catch (error) {
     console.log(error)
     enterpriseList.value = []
@@ -165,10 +162,7 @@ const loadEnterpriseList = async () => {
 const loadDictList = async () => {
   try {
     const res = await getDictTypeApi('task_type_desc')
-
-    if (res.code === 0) {
-      dictList.value = res.data
-    }
+    dictList.value = res.data
   } catch (error) {
     console.log(error)
     dictList.value = []
@@ -185,12 +179,9 @@ const loadRules = async (ruleId: string) => {
 
   try {
     const res = await getTaskTypeListByRuleIdApi(ruleId)
-
-    if (res.code === 0) {
-      sections.value = Object.entries(res.data)
-        .map(([title, items]) => ({ title, items }))
-        .filter((section) => section.items.length > 0)
-    }
+    sections.value = Object.entries(res.data)
+      .map(([title, items]) => ({ title, items }))
+      .filter((section) => section.items.length > 0)
   } catch (error) {
     console.log(error)
     sections.value = []

+ 1 - 14
src/pages-task/completed-record/index.vue

@@ -143,7 +143,6 @@ import type {
   TaskHistoryRecordListResponse,
   TaskRecordItem,
 } from '@/services/modules/task/completedRecord/type'
-
 const tabs = [
   { label: '全部', value: 0 },
   { label: '通过', value: 1 },
@@ -182,8 +181,6 @@ const taskRulePickerValue = ref(dayjs().startOf('month').valueOf())
 const rejectReasonPopupVisible = ref(false)
 const rejectReason = ref('')
 
-let requestId = 0
-
 const taskRuleId = computed(() => {
   const { selectedYear, selectedMonth } = params
   return selectedYear && selectedMonth ? `${selectedYear}-${selectedMonth}` : ''
@@ -223,14 +220,11 @@ const fetchRecordList = async (options: { force?: boolean } = {}) => {
 
   loading.value = true
 
-  const currentRequestId = ++requestId
   const requestParams = buildRequestParams()
 
   try {
     const res = await getHistoryTaskListApi(requestParams)
 
-    if (currentRequestId !== requestId) return
-
     const data = (res?.data ?? {}) as Partial<TaskHistoryRecordListResponse>
     const records = data.records ?? []
     const total = Number(data.total ?? 0)
@@ -240,15 +234,8 @@ const fetchRecordList = async (options: { force?: boolean } = {}) => {
     finished.value = records.length < params.size || (total > 0 && recordList.value.length >= total)
   } catch (error) {
     console.error('获取任务记录失败:', error)
-
-    uni.showToast({
-      title: '获取列表失败',
-      icon: 'none',
-    })
   } finally {
-    if (currentRequestId === requestId) {
-      loading.value = false
-    }
+    loading.value = false
   }
 }
 

+ 3 - 19
src/pages-task/points-package-record/index.vue

@@ -179,7 +179,6 @@ import {
   pkgWithdrawApi,
 } from '@/services/modules/pointsPackageRecord/index'
 import type { ScorePackageRecordItem } from '@/services/modules/pointsPackageRecord/type'
-
 const toast = useToast()
 
 /**
@@ -253,11 +252,6 @@ const finished = ref(false)
 const recordList = ref<ScorePackageRecordItem[]>([])
 const packageStatusMap = ref<DictItem[]>([])
 
-/**
- * 请求标识,用于规避快速切换筛选时旧请求覆盖新数据
- */
-let requestId = 0
-
 /**
  * 当前选中的月份,格式:YYYY-MM
  */
@@ -326,8 +320,6 @@ const fetchRecordList = async () => {
   if (loading.value || finished.value) return
 
   loading.value = true
-  const currentRequestId = ++requestId
-
   try {
     const res = await getUserScorePackageListApi({
       current: params.current,
@@ -338,8 +330,6 @@ const fetchRecordList = async () => {
       activeId: params.activeTab,
     })
 
-    if (currentRequestId !== requestId) return
-
     const data = (res?.data ?? {}) as ListResponse
     const records = data.records ?? []
     const total = data.total ?? 0
@@ -348,11 +338,8 @@ const fetchRecordList = async () => {
     finished.value = recordList.value.length >= total || records.length < params.size
   } catch (error) {
     console.error('获取积分包记录失败:', error)
-    toast.error('获取列表失败')
   } finally {
-    if (currentRequestId === requestId) {
-      loading.value = false
-    }
+    loading.value = false
   }
 }
 
@@ -436,11 +423,8 @@ const revokeApply = async (item: ScorePackageRecordItem) => {
       packageStatusId: item.statusId,
     })
     console.log('res', res)
-
-    if (res.code === 0) {
-      toast.success('撤回成功')
-      refreshRecordList()
-    }
+    toast.success('撤回成功')
+    refreshRecordList()
   } catch (error) {
     console.error('撤回申领失败:', error)
     toast.error(getErrorMessage(error, '撤回失败'))

+ 1 - 1
src/pages-task/task-entry-page/index.vue

@@ -102,7 +102,7 @@ const getListTaskTypeIds = async () => {
   try {
     const res = await getListTaskTypeIdsApi(deptId)
 
-    if (res.code === 0 && Array.isArray(res.data)) {
+    if (Array.isArray(res.data)) {
       taskTypeList.value = res.data
       return
     }

+ 0 - 5
src/pages-task/task-form/components/InputAutoSelect.vue

@@ -16,7 +16,6 @@
 import { computed, ref } from 'vue'
 
 import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
-
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
   disabled?: boolean
@@ -56,10 +55,6 @@ const pickerClick = async () => {
   } catch (error) {
     console.error(`打开单选字段失败:${props.taskFieldConfig.taskFiledValue}`, error)
 
-    uni.showToast({
-      title: '选项加载失败,请稍后重试',
-      icon: 'none',
-    })
   } finally {
     opening.value = false
   }

+ 0 - 4
src/pages-task/task-form/components/MultipleSelect.vue

@@ -164,10 +164,6 @@ const pickerClick = async () => {
   } catch (error) {
     console.error(`打开多选字段失败:${props.taskFieldConfig.taskFiledValue}`, error)
 
-    uni.showToast({
-      title: '选项加载失败,请稍后重试',
-      icon: 'none',
-    })
   } finally {
     opening.value = false
   }

+ 37 - 72
src/pages-task/task-form/components/QuestionnaireTask.vue

@@ -74,11 +74,16 @@
       </view>
     </template>
 
-    <canvas
-      canvas-id="questionnaireQrCanvas"
-      class="questionnaire-task__canvas"
-      :style="canvasStyle"
-    />
+    <view class="questionnaire-task__qr-generator">
+      <wd-qr-code
+        ref="qrCodeRef"
+        :text="qrCodeContent"
+        :size="QR_CODE_SIZE"
+        correct-level="M"
+        :margin="12"
+        @error="handleQrCodeError"
+      />
+    </view>
 
     <wd-picker
       v-model="selectedPackagePickerValue"
@@ -97,9 +102,9 @@
 </template>
 
 <script setup lang="ts">
-import { computed, getCurrentInstance, nextTick, onMounted, ref } from 'vue'
+import { computed, nextTick, onMounted, ref } from 'vue'
 
-import UQRCode from 'uqrcodejs'
+import type { QrCodeInstance } from '@wot-ui/ui/components/wd-qr-code/types'
 
 import {
   getQuestionnaireListApi,
@@ -112,6 +117,7 @@ import type {
 
 import { useUserStore } from '@/stores/modules/user'
 
+import { createLatestTaskController } from '@/utils/latestTask'
 import { hideGlobalLoading, showGlobalLoading } from '@/utils/loading'
 
 import {
@@ -133,11 +139,9 @@ interface QrCodeGenerationRequest {
   usesQuestionnaireList: boolean
 }
 
-const QR_CODE_CANVAS_ID = 'questionnaireQrCanvas'
 const QR_CODE_SIZE = 280
 const TASK_TYPE_ID = '801'
 
-const instance = getCurrentInstance()
 const userStore = useUserStore()
 
 const configLoading = ref(false)
@@ -157,10 +161,12 @@ const selectedQuestionnaireLabel = ref('')
 const manualQuestionnaireUrl = ref('')
 
 const qrCodeImageUrl = ref('')
+const qrCodeContent = ref('')
 const qrCodeLoading = ref(false)
+const qrCodeRef = ref<QrCodeInstance | null>(null)
+const qrCodeError = ref<unknown>(null)
 
-let questionnaireRequestVersion = 0
-let qrCodeGenerationVersion = 0
+const qrCodeGenerationTask = createLatestTaskController()
 let lastQrContent = ''
 let pendingQrCodeGeneration: QrCodeGenerationRequest | null = null
 
@@ -173,11 +179,6 @@ const questionnaireOptions = computed<PickerOption[]>(() => {
   }))
 })
 
-const canvasStyle = {
-  width: `${QR_CODE_SIZE}px`,
-  height: `${QR_CODE_SIZE}px`,
-}
-
 onMounted(() => {
   void loadTaskConfig()
 })
@@ -248,7 +249,6 @@ const handlePackageConfirm = (event: PickerConfirmEvent) => {
 }
 
 const resetQuestionnaire = () => {
-  questionnaireRequestVersion += 1
   questionnaireList.value = []
   questionnaireLoading.value = false
   questionnairePickerVisible.value = false
@@ -259,35 +259,19 @@ const resetQuestionnaire = () => {
 }
 
 const loadQuestionnaireList = async (packageId: string) => {
-  const requestVersion = ++questionnaireRequestVersion
-
   questionnaireLoading.value = true
 
   try {
     const response = await getQuestionnaireListApi(packageId)
 
-    if (requestVersion !== questionnaireRequestVersion) {
-      return
-    }
-
     questionnaireList.value = Array.isArray(response.data)
       ? response.data.filter((item) => item.label?.trim() && item.url?.trim())
       : []
   } catch (error) {
-    if (requestVersion !== questionnaireRequestVersion) {
-      return
-    }
-
     questionnaireList.value = []
     console.error('[QuestionnaireTask] 获取问卷列表失败', error)
-    uni.showToast({
-      title: '问卷列表加载失败,可手动输入问卷地址',
-      icon: 'none',
-    })
   } finally {
-    if (requestVersion === questionnaireRequestVersion) {
-      questionnaireLoading.value = false
-    }
+    questionnaireLoading.value = false
   }
 }
 
@@ -334,9 +318,10 @@ const generateManualQrCode = () => {
 }
 
 const clearQrCode = () => {
-  qrCodeGenerationVersion += 1
+  qrCodeGenerationTask.invalidate()
   pendingQrCodeGeneration = null
   qrCodeImageUrl.value = ''
+  qrCodeContent.value = ''
   lastQrContent = ''
 }
 
@@ -349,7 +334,7 @@ const generateQrCode = async (questionnaireUrl: string, usesQuestionnaireList: b
 
   if (qrCodeLoading.value) {
     pendingQrCodeGeneration = { questionnaireUrl, usesQuestionnaireList }
-    qrCodeGenerationVersion += 1
+    qrCodeGenerationTask.invalidate()
     qrCodeImageUrl.value = ''
     lastQrContent = ''
     return
@@ -368,9 +353,11 @@ const generateQrCode = async (questionnaireUrl: string, usesQuestionnaireList: b
     return
   }
 
-  const generationVersion = ++qrCodeGenerationVersion
+  const generationVersion = qrCodeGenerationTask.next()
 
   qrCodeImageUrl.value = ''
+  qrCodeError.value = null
+  qrCodeContent.value = qrContent
   qrCodeLoading.value = true
   showGlobalLoading('二维码生成中')
 
@@ -378,31 +365,24 @@ const generateQrCode = async (questionnaireUrl: string, usesQuestionnaireList: b
     await nextTick()
     await waitForLoadingPaint()
 
-    const canvasContext = uni.createCanvasContext(
-      QR_CODE_CANVAS_ID,
-      instance?.proxy as unknown as WechatMiniprogram.Component.TrivialInstance
-    )
-    const qrCode = new UQRCode()
+    if (!qrCodeRef.value) {
+      throw new Error('二维码组件尚未就绪')
+    }
 
-    qrCode.data = qrContent
-    qrCode.size = QR_CODE_SIZE
-    qrCode.margin = 12
-    qrCode.errorCorrectLevel = UQRCode.errorCorrectLevel.M
-    qrCode.backgroundColor = '#ffffff'
-    qrCode.foregroundColor = '#000000'
-    qrCode.canvasContext = canvasContext
-    qrCode.make()
+    const imageUrl = await qrCodeRef.value.exportImage()
 
-    await qrCode.drawCanvas()
+    if (qrCodeError.value) {
+      throw qrCodeError.value
+    }
 
-    if (generationVersion !== qrCodeGenerationVersion) {
+    if (!qrCodeGenerationTask.isLatest(generationVersion)) {
       return
     }
 
-    qrCodeImageUrl.value = await exportQrCodeImage()
+    qrCodeImageUrl.value = imageUrl
     lastQrContent = qrContent
   } catch (error) {
-    if (generationVersion !== qrCodeGenerationVersion) {
+    if (!qrCodeGenerationTask.isLatest(generationVersion)) {
       return
     }
 
@@ -427,23 +407,8 @@ const waitForLoadingPaint = (): Promise<void> => {
   })
 }
 
-const exportQrCodeImage = (): Promise<string> => {
-  return new Promise((resolve, reject) => {
-    uni.canvasToTempFilePath(
-      {
-        canvasId: QR_CODE_CANVAS_ID,
-        width: QR_CODE_SIZE,
-        height: QR_CODE_SIZE,
-        destWidth: QR_CODE_SIZE,
-        destHeight: QR_CODE_SIZE,
-        fileType: 'png',
-        quality: 1,
-        success: (result) => resolve(result.tempFilePath),
-        fail: reject,
-      },
-      instance?.proxy as unknown as WechatMiniprogram.Component.TrivialInstance
-    )
-  })
+const handleQrCodeError = (error: unknown) => {
+  qrCodeError.value = error
 }
 </script>
 
@@ -497,7 +462,7 @@ const exportQrCodeImage = (): Promise<string> => {
   height: 360rpx;
 }
 
-.questionnaire-task__canvas {
+.questionnaire-task__qr-generator {
   position: fixed;
   top: -9999px;
   left: -9999px;

+ 0 - 4
src/pages-task/task-form/components/SingleSelect.vue

@@ -208,10 +208,6 @@ const pickerClick = async () => {
   } catch (error) {
     console.error(`打开单选字段失败:${props.taskFieldConfig.taskFiledValue}`, error)
 
-    uni.showToast({
-      title: '选项加载失败,请稍后重试',
-      icon: 'none',
-    })
   } finally {
     opening.value = false
   }

+ 0 - 13
src/pages-task/task-form/composables/useTaskForm.ts

@@ -374,8 +374,6 @@ export const useTaskForm = () => {
   const taskFormConfigMap = new Map<string, TaskFieldConfigViewItem[]>()
 
   let deriveFrom: TaskContentConfigDeriveFrom | null = null
-  let loadRequestVersion = 0
-
   const createFormState = (config: TaskFieldConfigViewItem[]): DynamicFormState => {
     const formValue: DynamicFormState['value'] = {}
 
@@ -404,7 +402,6 @@ export const useTaskForm = () => {
   }
 
   const resetTaskForm = (): void => {
-    loadRequestVersion += 1
     clearTaskFormState()
   }
 
@@ -559,15 +556,9 @@ export const useTaskForm = () => {
       throw new Error('缺少 taskTypeId')
     }
 
-    const requestVersion = ++loadRequestVersion
-
     try {
       const res = await getTaskContentConfigByTaskTypeIdApi(normalizedTaskTypeId)
 
-      if (requestVersion !== loadRequestVersion) {
-        return
-      }
-
       const responseData = res.data
       const singleTaskContentConfig = serializeTaskContentConfigResponse(responseData)
 
@@ -582,10 +573,6 @@ export const useTaskForm = () => {
         throw new Error('动态表单配置格式无效')
       }
     } catch (error) {
-      if (requestVersion !== loadRequestVersion) {
-        return
-      }
-
       clearTaskFormState()
       throw error
     }

+ 8 - 21
src/pages-task/task-form/composables/useTaskFormAction.ts

@@ -30,11 +30,6 @@ const ACTION_API_MAP = {
   submit: saveTaskContentApi,
 } satisfies Record<FormAction, typeof saveTaskContentApi>
 
-const ACTION_FAILURE_TEXT = {
-  save: '保存失败',
-  submit: '提交失败',
-} satisfies Record<FormAction, string>
-
 const isEmptyValue = (value: unknown): boolean => {
   return value === undefined || value === null || String(value).trim() === ''
 }
@@ -344,34 +339,26 @@ export const useTaskFormAction = ({
 
       const response = await requestApi(query, payload)
 
-      /**
-       * 提交时:
-       * code === 1 且 data 为非空图片数组,
-       * 视为图片重复,不能进入成功逻辑。
-       */
-      if (action === 'submit' && response.code === 1) {
-        const duplicateImages = extractImageUrls(response.data)
+      // 该接口在业务错误白名单中:重复图片使用定制弹窗,其余错误在此兜底。
+      if (response.code === 1) {
+        const duplicateImages = action === 'submit' ? extractImageUrls(response.data) : []
 
         if (duplicateImages.length > 0) {
           showDuplicateImages(duplicateImages)
           return
         }
-      }
-
-      /**
-       * 所有非 0 状态码均不能视为成功。
-       */
-      if (response.code !== 0) {
-        showMessage(response.msg || ACTION_FAILURE_TEXT[action])
 
+        uni.showModal({
+          title: '提示',
+          content: response.msg || '操作失败,请稍后重试',
+          showCancel: false,
+        })
         return
       }
 
       navigationScheduled = handleActionSuccess(action, response.data)
     } catch (error: unknown) {
       console.error(`[useTaskFormAction] ${action} 失败`, error)
-
-      showMessage(ACTION_FAILURE_TEXT[action])
     } finally {
       if (!navigationScheduled) {
         isActionPending.value = false

+ 8 - 8
src/pages-task/task-form/composables/useTaskFormSafeArea.ts

@@ -13,7 +13,7 @@ type MiniProgramKeyboardApi = {
   offKeyboardHeightChange?: (callback: KeyboardHeightChangeCallback) => void
 }
 
-type MiniProgramSystemInfo = UniApp.GetSystemInfoResult & {
+type MiniProgramWindowInfo = UniApp.GetWindowInfoResult & {
   safeAreaInsets?: {
     bottom?: number
   }
@@ -66,20 +66,20 @@ export const useTaskFormSafeArea = () => {
   })
 
   const initSystemLayout = () => {
-    const systemInfo = uni.getSystemInfoSync() as MiniProgramSystemInfo
+    const windowInfo = uni.getWindowInfo() as MiniProgramWindowInfo
 
-    windowWidth.value = systemInfo.windowWidth || 375
+    windowWidth.value = windowInfo.windowWidth || 375
 
-    if (typeof systemInfo.safeAreaInsets?.bottom === 'number') {
-      safeAreaBottom.value = systemInfo.safeAreaInsets.bottom
+    if (typeof windowInfo.safeAreaInsets?.bottom === 'number') {
+      safeAreaBottom.value = windowInfo.safeAreaInsets.bottom
       return
     }
 
     if (
-      typeof systemInfo.safeArea?.bottom === 'number' &&
-      typeof systemInfo.screenHeight === 'number'
+      typeof windowInfo.safeArea?.bottom === 'number' &&
+      typeof windowInfo.screenHeight === 'number'
     ) {
-      safeAreaBottom.value = Math.max(systemInfo.screenHeight - systemInfo.safeArea.bottom, 0)
+      safeAreaBottom.value = Math.max(windowInfo.screenHeight - windowInfo.safeArea.bottom, 0)
       return
     }
 

+ 6 - 31
src/pages-task/task-form/composables/useTaskSelectHandlers.ts

@@ -134,8 +134,6 @@ export const useTaskSelectHandlers = ({
     setFieldOptionsByAlias,
   } = useTaskFormFields({ form, taskFieldConfigList })
 
-  const fieldOptionsRequestVersions = new Map<string, number>()
-
   const openEntitySelector = <T>(
     alias: string,
     eventName: string,
@@ -173,24 +171,15 @@ export const useTaskSelectHandlers = ({
 
     if (!field) return
 
-    fieldOptionsRequestVersions.set(field.id, (fieldOptionsRequestVersions.get(field.id) ?? 0) + 1)
     setFieldOptions(field, [])
   }
 
-  const loadLatestFieldOptions = async (
+  const loadFieldOptions = async (
     field: TaskFieldConfigViewItem,
     loader: () => Promise<TaskFieldOption[]>
   ): Promise<boolean> => {
-    const requestVersion = (fieldOptionsRequestVersions.get(field.id) ?? 0) + 1
-
-    fieldOptionsRequestVersions.set(field.id, requestVersion)
-
     const options = await loader()
-    const isCurrentField = taskFieldConfigList.value.includes(field)
-
-    if (fieldOptionsRequestVersions.get(field.id) !== requestVersion || !isCurrentField) {
-      return false
-    }
+    if (!taskFieldConfigList.value.includes(field)) return false
 
     setFieldOptions(field, options)
     return true
@@ -232,7 +221,6 @@ export const useTaskSelectHandlers = ({
   }
 
   const otcScoreList = ref<PackageRuleTaskTypeItem[]>([])
-  let otcScoreRequestVersion = 0
 
   const normalizeScore = (value: unknown): number => {
     const score = Number(value)
@@ -308,7 +296,7 @@ export const useTaskSelectHandlers = ({
         return false
       }
 
-      const loaded = await loadLatestFieldOptions(field, async () => {
+      const loaded = await loadFieldOptions(field, async () => {
         const res = await getPackageListByTaskTypeApi(trainingTypeId)
 
         return serializeTaskFieldOptions(res.data)
@@ -394,7 +382,7 @@ export const useTaskSelectHandlers = ({
           return false
         }
 
-        return loadLatestFieldOptions(field, () => getListDrugTable(packageId))
+        return loadFieldOptions(field, () => getListDrugTable(packageId))
       }
       if (field.alias === 'realTaskTypeId') {
         const drugsValue = getFieldFormValueByAlias('drugs')
@@ -477,25 +465,12 @@ export const useTaskSelectHandlers = ({
     if (taskTypeId.value === '47' && field.alias === 'scorePackage') {
       otcScoreList.value = []
 
-      const requestVersion = ++otcScoreRequestVersion
-
       try {
         const res = await getPackageRuleTaskTypeApi(event.value)
-
-        if (requestVersion === otcScoreRequestVersion) {
-          otcScoreList.value = Array.isArray(res.data) ? res.data : []
-        }
+        otcScoreList.value = Array.isArray(res.data) ? res.data : []
       } catch (error) {
-        if (requestVersion !== otcScoreRequestVersion) {
-          return
-        }
-
         otcScoreList.value = []
         console.error('获取OTC服务积分规则失败:', error)
-        uni.showToast({
-          title: '积分规则加载失败,请重新选择积分包',
-          icon: 'none',
-        })
       }
     }
   }
@@ -513,7 +488,7 @@ export const useTaskSelectHandlers = ({
         return false
       }
 
-      return loadLatestFieldOptions(field, () => getListDrugTable(packageId))
+      return loadFieldOptions(field, () => getListDrugTable(packageId))
     }
 
     return true

+ 0 - 4
src/pages-task/task-form/index.vue

@@ -312,10 +312,6 @@ onLoad(async (options: PageLoadOptions = {}) => {
     await loadTaskForm(taskTypeId.value)
   } catch (error) {
     console.error('[task-form] 表单配置加载失败', error)
-    uni.showToast({
-      title: '表单加载失败,请稍后重试',
-      icon: 'none',
-    })
   }
 })
 </script>

+ 6 - 15
src/pages-task/task-package/list.vue

@@ -131,7 +131,6 @@ import type {
   getUserScorePackageListRequest,
   TaskPackageItem,
 } from '@/services/modules/task/package/type'
-
 const dialog = useDialog()
 const toast = useToast()
 
@@ -159,8 +158,6 @@ const loading = ref(false)
 const finished = ref(false)
 const packageListArr = ref<TaskPackageItem[]>([])
 
-let requestId = 0
-
 const selectedDate = computed(() => {
   const { selectedYear, selectedMonth } = params
   return selectedYear && selectedMonth ? `${selectedYear}-${selectedMonth}` : ''
@@ -195,12 +192,8 @@ const fetchTaskPackageList = async () => {
   if (loading.value || finished.value) return
 
   loading.value = true
-  const currentRequestId = ++requestId
-
   try {
     const res = await getUserScorePackageListApi(requestParams.value)
-    if (currentRequestId !== requestId) return
-    if (res.code !== 0) return
 
     const records = res.data?.records ?? []
     const total = res.data?.total ?? 0
@@ -212,10 +205,10 @@ const fetchTaskPackageList = async () => {
     packageListArr.value = params.current === 1 ? records : packageListArr.value.concat(records)
 
     finished.value = packageListArr.value.length >= total || records.length < params.size
+  } catch (error) {
+    console.error('获取任务包列表失败:', error)
   } finally {
-    if (currentRequestId === requestId) {
-      loading.value = false
-    }
+    loading.value = false
   }
 }
 
@@ -334,14 +327,14 @@ const submitReceiveAfterTestPassed = async (item: TaskPackageItem) => {
   const res = await pickupPackageApi({
     pkgId: item.id,
   })
-  if (res.code === 0 && res.data === 0) {
+  if (res.data === 0) {
     toast.success('领取成功')
     removePackageFromList(item.id)
     return
   }
   if (res.data === 1) {
     uni.showModal({
-      content: res.msg,
+      content: res.msg || '请先完成认证',
       confirmText: '去认证',
       success: (modalRes) => {
         if (modalRes.confirm) {
@@ -382,9 +375,7 @@ const getPackageTypeTag = (type: string | null) => {
 const packageStatusMap = ref<DictItem[]>([])
 const getPackageStatus = async () => {
   const res = await getDictTypeApi('package_new_status')
-  if (res.code === 0) {
-    packageStatusMap.value = res.data ?? []
-  }
+  packageStatusMap.value = res.data ?? []
 }
 const getPackageStatusText = (status: string | null) => {
   const statusItem = packageStatusMap.value.find((item) => item.value === status)

+ 161 - 0
src/pages-task/task-visit/components/visit-map/index.vue

@@ -0,0 +1,161 @@
+<template>
+  <view class="visit-map-panel">
+    <map
+      class="visit-map-panel__map"
+      :latitude="mapLatitude"
+      :longitude="mapLongitude"
+      :circles="rangeCircles"
+      :markers="targetMarkers"
+      :scale="15"
+      show-location
+      show-compass
+    />
+
+    <view class="visit-map-panel__locate" @click="emit('refresh-location')">⌖</view>
+
+    <view class="visit-map-panel__add-card">
+      <view class="visit-map-panel__add-title">
+        附近没有合适的{{ targetTypeLabel }}?
+      </view>
+      <view class="visit-map-panel__add-action">+ 新增地点</view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { LocationResult } from '@/lib/location'
+
+import type { VisitTarget } from '../../types'
+
+const props = defineProps<{
+  target?: VisitTarget
+  targetTypeLabel: string
+  currentLocation: LocationResult | null
+  rangeRadius: number
+}>()
+
+const emit = defineEmits<{
+  (event: 'refresh-location'): void
+}>()
+
+const rangeCircles = computed(() => {
+  if (!props.currentLocation) return []
+
+  return [
+    {
+      latitude: props.currentLocation.latitude,
+      longitude: props.currentLocation.longitude,
+      radius: props.rangeRadius,
+      color: '#42bde9aa',
+      fillColor: '#42bde926',
+      strokeWidth: 2,
+    },
+  ]
+})
+
+const targetMarkers = computed(() => {
+  if (!props.target) return []
+
+  return [
+    {
+      id: 1,
+      latitude: props.target.latitude,
+      longitude: props.target.longitude,
+      iconPath: '/static/images/common/map-marker.png',
+      width: 30,
+      height: 30,
+      callout: {
+        content: props.target.name,
+        color: '#1f2937',
+        fontSize: 12,
+        borderRadius: 6,
+        borderWidth: 1,
+        borderColor: '#dbeafe',
+        bgColor: '#ffffff',
+        padding: 7,
+        textAlign: 'center' as const,
+        display: 'ALWAYS' as const,
+      },
+    },
+  ]
+})
+
+const mapLatitude = computed(
+  () => props.currentLocation?.latitude ?? props.target?.latitude ?? 22.543096
+)
+const mapLongitude = computed(
+  () => props.currentLocation?.longitude ?? props.target?.longitude ?? 114.057865
+)
+</script>
+
+<style lang="scss" scoped>
+.visit-map-panel {
+  position: relative;
+  height: 480rpx;
+  overflow: hidden;
+  background: #dce8f3;
+}
+
+.visit-map-panel__map {
+  width: 100%;
+  height: 100%;
+}
+
+.visit-map-panel__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-map-panel__add-card {
+  position: absolute;
+  right: 28rpx;
+  bottom: 44rpx;
+  left: 28rpx;
+  display: flex;
+  align-items: center;
+  min-height: 80rpx;
+  padding: 12rpx 14rpx 12rpx 22rpx;
+  background: rgba(255, 255, 255, 0.97);
+  border-radius: 20rpx;
+  box-shadow: 0 16rpx 40rpx rgba(24, 39, 75, 0.16);
+  box-sizing: border-box;
+}
+
+.visit-map-panel__add-title {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  color: #1e293b;
+  font-size: 24rpx;
+  font-weight: 650;
+  line-height: 34rpx;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.visit-map-panel__add-action {
+  flex-shrink: 0;
+  height: 54rpx;
+  margin-left: 14rpx;
+  padding: 0 18rpx;
+  color: #2f8cf7;
+  font-size: 22rpx;
+  font-weight: 650;
+  line-height: 54rpx;
+  background: #edf6ff;
+  border-radius: 16rpx;
+}
+</style>

+ 297 - 0
src/pages-task/task-visit/components/visit-record/index.vue

@@ -0,0 +1,297 @@
+<template>
+  <view class="visit-record-panel">
+    <view class="visit-record-panel__heading">
+      <view class="visit-record-panel__title">拜访记录</view>
+
+      <view
+        class="visit-record-panel__summary"
+        :class="{ 'visit-record-panel__summary--error': errorMessage }"
+        @click="handleRetry"
+      >
+        <text v-if="loading">更新中...</text>
+        <text v-else-if="errorMessage">刷新失败 · 重试</text>
+        <text v-else>{{ records.length }} 条记录</text>
+      </view>
+    </view>
+
+    <scroll-view
+      v-if="records.length"
+      class="visit-record-panel__scroll"
+      scroll-y
+      :show-scrollbar="false"
+    >
+      <view class="visit-record-panel__list">
+        <view v-for="record in records" :key="record.id" class="visit-record-item">
+          <view class="visit-record-item__icon">
+            <wd-icon name="location" size="32rpx" color="#2f8cf7" />
+          </view>
+
+          <view class="visit-record-item__content">
+            <view class="visit-record-item__name">
+              {{ record.signEntName || '未命名打卡地点' }}
+            </view>
+
+            <view class="visit-record-item__time">
+              <wd-icon name="time-line" size="24rpx" color="#98a6b6" />
+              <text class="visit-record-item__time-text">{{ record.signDate || '-' }}</text>
+            </view>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+
+    <view v-else-if="loading" class="visit-record-panel__state">
+      <view class="visit-record-panel__loading" />
+      <view class="visit-record-panel__state-title">正在加载拜访记录</view>
+      <view class="visit-record-panel__state-desc">请稍候...</view>
+    </view>
+
+    <view v-else-if="errorMessage" class="visit-record-panel__state">
+      <view class="visit-record-panel__state-icon visit-record-panel__state-icon--error">
+        <wd-icon name="warning" size="40rpx" color="#d66a55" />
+      </view>
+      <view class="visit-record-panel__state-title">记录加载失败</view>
+      <view class="visit-record-panel__state-desc">{{ errorMessage }}</view>
+      <view class="visit-record-panel__retry" @click="loadRecords">重新加载</view>
+    </view>
+
+    <view v-else class="visit-record-panel__state">
+      <view class="visit-record-panel__state-icon">
+        <wd-icon name="time-line" size="42rpx" color="#7db8ef" />
+      </view>
+      <view class="visit-record-panel__state-title">暂无拜访记录</view>
+      <view class="visit-record-panel__state-desc">完成打卡后,拜访记录会展示在这里</view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+
+import { getSignListByUserIdApi } from '@/services/modules/task/taskVisit'
+import type { VisitSignRecordItem } from '@/services/modules/task/taskVisit/type'
+
+import { useUserStore } from '@/stores/modules/user'
+
+const userStore = useUserStore()
+
+const records = ref<VisitSignRecordItem[]>([])
+const loading = ref(false)
+const errorMessage = ref('')
+const userId = computed(() => userStore.currentUserInfo?.userId || '')
+
+const loadRecords = async () => {
+  const signUserid = userId.value
+
+  if (!signUserid) {
+    records.value = []
+    loading.value = false
+    errorMessage.value = ''
+    return
+  }
+
+  loading.value = true
+  errorMessage.value = ''
+
+  try {
+    const res = await getSignListByUserIdApi({ signUserid })
+
+    records.value = Array.isArray(res.data) ? res.data : []
+  } catch (error) {
+    errorMessage.value = error instanceof Error ? error.message : '获取拜访记录失败'
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleRetry = () => {
+  if (!errorMessage.value || loading.value) return
+
+  void loadRecords()
+}
+
+watch(
+  userId,
+  (currentUserId, previousUserId) => {
+    if (currentUserId !== previousUserId) {
+      records.value = []
+    }
+
+    void loadRecords()
+  },
+  { immediate: true }
+)
+
+defineExpose({ refresh: loadRecords })
+</script>
+
+<style lang="scss" scoped>
+.visit-record-panel {
+  padding: 32rpx 28rpx;
+  background: #ffffff;
+  border-radius: 30rpx;
+  box-shadow: 0 16rpx 48rpx rgba(31, 70, 112, 0.06);
+}
+
+.visit-record-panel__heading {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.visit-record-panel__title {
+  color: #172033;
+  font-size: 34rpx;
+  font-weight: 700;
+  line-height: 48rpx;
+}
+
+.visit-record-panel__summary {
+  color: #94a3b8;
+  font-size: 22rpx;
+  line-height: 34rpx;
+}
+
+.visit-record-panel__summary--error {
+  color: #d66a55;
+}
+
+.visit-record-panel__scroll {
+  width: 100%;
+  height: 440rpx;
+  margin-top: 24rpx;
+}
+
+.visit-record-panel__list {
+  padding-bottom: 4rpx;
+}
+
+.visit-record-item {
+  display: flex;
+  align-items: center;
+  margin-bottom: 18rpx;
+  padding: 22rpx 24rpx;
+  background: linear-gradient(145deg, #f7faff 0%, #fbfdff 100%);
+  border: 2rpx solid #edf3f9;
+  border-radius: 22rpx;
+}
+
+.visit-record-item:last-child {
+  margin-bottom: 0;
+}
+
+.visit-record-item__icon {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 58rpx;
+  height: 58rpx;
+  margin-right: 18rpx;
+  background: #eaf4ff;
+  border-radius: 18rpx;
+}
+
+.visit-record-item__content {
+  flex: 1;
+  min-width: 0;
+}
+
+.visit-record-item__name {
+  overflow: hidden;
+  color: #1e293b;
+  font-size: 28rpx;
+  font-weight: 650;
+  line-height: 40rpx;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.visit-record-item__time {
+  display: flex;
+  align-items: center;
+  margin-top: 8rpx;
+  color: #8b98a8;
+  font-size: 21rpx;
+  line-height: 32rpx;
+}
+
+.visit-record-item__time-text {
+  margin-left: 8rpx;
+}
+
+.visit-record-panel__state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 270rpx;
+  margin-top: 22rpx;
+  padding: 28rpx 20rpx;
+  background: linear-gradient(180deg, #f7faff 0%, #fbfdff 100%);
+  border: 2rpx dashed #dceafa;
+  border-radius: 24rpx;
+  box-sizing: border-box;
+}
+
+.visit-record-panel__state-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 76rpx;
+  height: 76rpx;
+  background: #ffffff;
+  border-radius: 50%;
+  box-shadow: 0 8rpx 20rpx rgba(71, 142, 207, 0.12);
+}
+
+.visit-record-panel__state-icon--error {
+  background: #fff4f1;
+  box-shadow: none;
+}
+
+.visit-record-panel__loading {
+  width: 52rpx;
+  height: 52rpx;
+  border: 6rpx solid #d9ebfb;
+  border-top-color: #3fa3f1;
+  border-radius: 50%;
+  animation: visit-record-loading 0.8s linear infinite;
+}
+
+.visit-record-panel__state-title {
+  margin-top: 18rpx;
+  color: #526175;
+  font-size: 26rpx;
+  font-weight: 650;
+  line-height: 38rpx;
+}
+
+.visit-record-panel__state-desc {
+  max-width: 540rpx;
+  margin-top: 6rpx;
+  color: #a1adbb;
+  font-size: 21rpx;
+  line-height: 32rpx;
+  text-align: center;
+  word-break: break-all;
+}
+
+.visit-record-panel__retry {
+  height: 52rpx;
+  margin-top: 20rpx;
+  padding: 0 24rpx;
+  color: #2f8cf7;
+  font-size: 22rpx;
+  font-weight: 650;
+  line-height: 52rpx;
+  background: #eaf4ff;
+  border-radius: 999rpx;
+}
+
+@keyframes visit-record-loading {
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 1179 - 0
src/pages-task/task-visit/components/visit-status/index.vue

@@ -0,0 +1,1179 @@
+<template>
+  <view class="visit-status-card">
+    <view class="visit-status-card__main">
+      <view class="visit-status-card__copy">
+        <view class="visit-status-card__meta">
+          <view
+            class="visit-status-card__meta-dot"
+            :class="{
+              'visit-status-card__meta-dot--success': canCheckIn,
+              'visit-status-card__meta-dot--error': hasStatusError,
+            }"
+          />
+          <text>定位状态</text>
+          <view
+            class="visit-status-card__badge"
+            :class="{
+              'visit-status-card__badge--success': canCheckIn,
+              'visit-status-card__badge--error': hasStatusError,
+            }"
+          >
+            {{ statusView.badge }}
+          </view>
+        </view>
+
+        <view class="visit-status-card__title">{{ statusView.title }}</view>
+
+        <view v-if="targetName" class="visit-status-card__place">
+          <wd-icon name="location" size="28rpx" color="#2f8cf7" />
+          <view class="visit-status-card__place-copy">
+            <text class="visit-status-card__place-name">{{ targetName }}</text>
+            <text class="visit-status-card__place-address">{{ targetAddress }}</text>
+          </view>
+        </view>
+
+        <view v-if="statusView.description" class="visit-status-card__desc">
+          {{ statusView.description }}
+        </view>
+      </view>
+
+      <view
+        class="visit-status-card__action-wrap"
+        :class="{ 'visit-status-card__action-wrap--disabled': !canCheckIn }"
+        @tap="handleCheckInClick"
+      >
+        <view
+          class="visit-status-card__action-ring"
+          :class="{ 'visit-status-card__action-ring--active': canCheckIn }"
+        >
+          <view
+            class="visit-status-card__action"
+            :class="{
+              'visit-status-card__action--active': canCheckIn,
+              'visit-status-card__action--countdown': remainingSeconds > 0,
+            }"
+          >
+            <text>{{ actionText.primary }}</text>
+            <text v-if="actionText.secondary">{{ actionText.secondary }}</text>
+          </view>
+        </view>
+      </view>
+    </view>
+
+    <view class="visit-status-card__footer" @tap="emit('open-visit-time')">
+      <view class="visit-status-card__footer-icon">
+        <wd-icon name="time-line" size="30rpx" color="#2f8cf7" />
+      </view>
+      <text class="visit-status-card__footer-title">查看打卡时间</text>
+      <wd-icon name="right" size="28rpx" color="#9aa8b9" />
+    </view>
+  </view>
+
+  <wd-picker
+    v-model="selectedPackageValues"
+    v-model:visible="packagePickerVisible"
+    title="选择积分包"
+    :columns="packageList"
+    root-portal
+    @confirm="confirmPackageSelection"
+  />
+
+  <wd-popup
+    v-model="templateVisible"
+    position="bottom"
+    root-portal
+    lock-scroll
+    :close-on-click-modal="false"
+    custom-style="border-radius: 32rpx 32rpx 0 0; overflow: hidden;"
+    @close="resetTemplateForm"
+  >
+    <view class="visit-template">
+      <view class="visit-template__header">
+        <view>
+          <view class="visit-template__title">拜访明细</view>
+          <view class="visit-template__subtitle">{{ templateTarget?.name }}</view>
+        </view>
+        <view class="visit-template__close" @click="templateVisible = false">
+          <wd-icon name="close" size="32rpx" color="#8b98a8" />
+        </view>
+      </view>
+
+      <view v-if="templateLoading" class="visit-template__loading">正在加载拜访模板</view>
+
+      <scroll-view v-else class="visit-template__scroll" scroll-y>
+        <wd-cell title="拜访时间" :value="templateSignDate" />
+
+        <wd-cell
+          v-if="templateTarget?.type === 'pharmacy'"
+          title="拜访人员"
+          required
+        >
+          <wd-input
+            v-model="templateForm.visitor"
+            no-border
+            align-right
+            placeholder="请输入拜访人员"
+          />
+        </wd-cell>
+
+        <wd-cell title="推广药品" required is-link :value="templateDrugText" @click="openDrugPicker" />
+        <wd-cell
+          title="拜访目的"
+          required
+          is-link
+          :value="templatePurposeText"
+          @click="purposePickerVisible = true"
+        />
+        <wd-cell
+          title="拜访结果"
+          required
+          is-link
+          :value="templateResultText"
+          @click="resultPickerVisible = true"
+        />
+
+        <wd-cell v-if="requiresDepartment" title="拜访科室" required>
+          <wd-input
+            v-model="templateForm.department"
+            no-border
+            align-right
+            placeholder="请输入拜访科室"
+          />
+        </wd-cell>
+
+        <ImgUpload
+          v-if="requiresTemplatePhoto"
+          v-model:value="templateForm.photoUrls"
+          :task-field-config="templatePhotoFieldConfig"
+        />
+
+        <template v-if="templateTarget?.type === 'hospital' && templateType === 'TEMPLATE2'">
+          <view
+            v-for="item in templateEvaluationItems"
+            :key="item.key"
+            class="visit-template__field"
+          >
+            <view class="visit-template__field-label">
+              <text class="visit-template__required">*</text>{{ item.label }}
+            </view>
+            <wd-radio-group
+              v-model="templateForm[item.key]"
+              direction="horizontal"
+              type="button"
+            >
+              <wd-radio value="true">是</wd-radio>
+              <wd-radio value="false">否</wd-radio>
+            </wd-radio-group>
+          </view>
+        </template>
+
+        <view class="visit-template__field visit-template__field--vertical">
+          <view class="visit-template__field-label">备注</view>
+          <wd-textarea
+            v-model="templateForm.remark"
+            placeholder="请输入备注"
+            clearable
+            auto-height
+            custom-style="min-height: 180rpx;"
+          />
+        </view>
+      </scroll-view>
+
+      <view class="visit-template__actions">
+        <button class="visit-template__button visit-template__button--cancel" @click="templateVisible = false">
+          取消
+        </button>
+        <button
+          class="visit-template__button visit-template__button--submit"
+          :disabled="signSubmitting"
+          @click="submitTemplateSign"
+        >
+          {{ signSubmitting ? '提交中' : '提交打卡' }}
+        </button>
+      </view>
+    </view>
+  </wd-popup>
+
+  <wd-select-picker
+    v-model="templateForm.drug"
+    v-model:visible="drugPickerVisible"
+    title="选择推广药品"
+    :columns="drugOptions"
+    root-portal
+  />
+  <wd-select-picker
+    v-model="templateForm.purpose"
+    v-model:visible="purposePickerVisible"
+    title="选择拜访目的"
+    :columns="purposeOptions"
+    root-portal
+  />
+  <wd-picker
+    v-model="templateForm.result"
+    v-model:visible="resultPickerVisible"
+    title="选择拜访结果"
+    :columns="resultOptions"
+    root-portal
+  />
+</template>
+
+<script setup lang="ts">
+import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
+
+import dayjs from 'dayjs'
+
+import type { LocationResult } from '@/lib/location'
+
+import { getDictTypeApi } from '@/services/modules/common'
+import type { DictItem } from '@/services/modules/common/type'
+import { getPackageListByTaskTypeApi } from '@/services/modules/information'
+import type { SharePackageItem } from '@/services/modules/information/type'
+import { getListDrugTableApi } from '@/services/modules/task/taskFrom'
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+import {
+  getSignListByUserIdApi,
+  getVisitTemplateApi,
+  isVisitTimeApi,
+  saveSignInfoApi,
+} from '@/services/modules/task/taskVisit'
+import type {
+  SaveSignInfoRequest,
+  VisitSignRecordItem,
+  VisitToType,
+} from '@/services/modules/task/taskVisit/type'
+
+import { useUserStore } from '@/stores/modules/user'
+
+import ImgUpload from '../../../task-form/components/ImgUpload.vue'
+import type { VisitLocationStatus, VisitTarget, VisitTargetType } from '../../types'
+
+type RequestStatus = 'idle' | 'loading' | 'ready' | 'error'
+
+interface StatusView {
+  badge: string
+  title: string
+  description: string
+}
+
+interface PackageSelection {
+  target: VisitTarget
+  packageItem: SharePackageItem
+}
+
+interface PackagePickerConfirmEvent {
+  selectedItems: SharePackageItem[]
+}
+
+interface TemplateFormState {
+  drug: string[]
+  purpose: string[]
+  result: Array<string | number>
+  visitor: string
+  department: string
+  photoUrls: string
+  rightHcp: string
+  rightInfo: string
+  rightActivity: string
+  remark: string
+}
+
+type TemplateEvaluationKey = 'rightHcp' | 'rightInfo' | 'rightActivity'
+
+const SAME_TARGET_COOLDOWN_SECONDS = 4 * 60 * 60
+const GLOBAL_COOLDOWN_SECONDS =
+  import.meta.env.VITE_MODE === 'production_luoxin' ? 60 * 60 : 30 * 60
+
+const TASK_TYPE_MAP: Record<VisitTargetType, string> = {
+  hospital: '5',
+  company: '6',
+  pharmacy: '33',
+}
+
+const props = defineProps<{
+  target?: VisitTarget
+  currentLocation: LocationResult | null
+  locationStatus: VisitLocationStatus
+  visitTo?: VisitToType
+}>()
+
+const emit = defineEmits<{
+  (event: 'open-visit-time'): void
+  (event: 'package-selected', payload: PackageSelection): void
+  (event: 'sign-success'): void
+}>()
+
+const userStore = useUserStore()
+
+const visitTimeStatus = ref<RequestStatus>('idle')
+const visitTimeAllowed = ref(false)
+const visitTimeMessage = ref('')
+const signRecordsStatus = ref<RequestStatus>('idle')
+const signRecords = ref<VisitSignRecordItem[]>([])
+const now = ref(Date.now())
+
+const packagePickerVisible = ref(false)
+const packageLoading = ref(false)
+const signSubmitting = ref(false)
+const packageList = ref<SharePackageItem[]>([])
+const selectedPackageValues = ref<Array<string | number>>([])
+
+const templateVisible = ref(false)
+const templateLoading = ref(false)
+const templateType = ref('')
+const templateTarget = ref<VisitTarget>()
+const templatePackageItem = ref<SharePackageItem>()
+const templateSignDate = ref('')
+const drugPickerVisible = ref(false)
+const purposePickerVisible = ref(false)
+const resultPickerVisible = ref(false)
+const drugOptions = ref<Array<{ label: string; value: string }>>([])
+const purposeOptions = ref<Array<{ label: string; value: string }>>([])
+const resultOptions = ref<Array<{ label: string; value: string }>>([])
+
+const createTemplateForm = (): TemplateFormState => ({
+  drug: [],
+  purpose: [],
+  result: [],
+  visitor: '',
+  department: '',
+  photoUrls: '',
+  rightHcp: '',
+  rightInfo: '',
+  rightActivity: '',
+  remark: '',
+})
+
+const templateForm = ref<TemplateFormState>(createTemplateForm())
+
+const templateEvaluationItems: Array<{ key: TemplateEvaluationKey; label: string }> = [
+  { key: 'rightHcp', label: '是否针对了正确的 HCP' },
+  { key: 'rightInfo', label: '是否传递了正确的信息' },
+  { key: 'rightActivity', label: '是否采取了正确的活动形式和频率' },
+]
+
+const templatePhotoFieldConfig = {
+  taskFiledValue: '现场照片',
+  isMustfill: '1',
+  taskFiledMinsize: 1,
+  taskFiledMaxsize: 5,
+} as TaskFieldConfigItem
+
+const deptId = computed(() => userStore.currentUserInfo?.deptId || '')
+const userId = computed(() => userStore.currentUserInfo?.userId || '')
+const targetName = computed(() => props.target?.name || '')
+const targetAddress = computed(() => props.target?.address || '暂无详细地址')
+
+const selectedPackage = computed(() => {
+  const selectedValue = selectedPackageValues.value[0]
+  return packageList.value.find((item) => String(item.value) === String(selectedValue))
+})
+
+const templateDrugText = computed(() => templateForm.value.drug.join('、') || '请选择')
+const templatePurposeText = computed(() => {
+  const selectedValues = new Set(templateForm.value.purpose.map(String))
+  return purposeOptions.value
+    .filter((item) => selectedValues.has(item.value))
+    .map((item) => item.label)
+    .join('、') || '请选择'
+})
+const templateResultText = computed(() => {
+  const selectedValue = templateForm.value.result[0]
+  return resultOptions.value.find((item) => item.value === String(selectedValue))?.label || '请选择'
+})
+const requiresDepartment = computed(() => {
+  return templateTarget.value?.type === 'hospital' && templateType.value !== 'TEMPLATE2'
+})
+const requiresTemplatePhoto = computed(() => {
+  const targetType = templateTarget.value?.type
+  return (
+    requiresDepartment.value ||
+    (targetType === 'company' && templateType.value === 'TEMPLATE2') ||
+    (targetType === 'pharmacy' && templateType.value === 'TEMPLATE3')
+  )
+})
+
+const cooldownDeadline = computed(() => {
+  const targetId = props.target?.signEntId
+  let latestSignTime = 0
+  let latestTargetSignTime = 0
+
+  for (const record of signRecords.value) {
+    const signTime = parseSignTime(record.signDate)
+    if (!signTime) continue
+
+    latestSignTime = Math.max(latestSignTime, signTime)
+    if (targetId && String(record.signEntId) === String(targetId)) {
+      latestTargetSignTime = Math.max(latestTargetSignTime, signTime)
+    }
+  }
+
+  return Math.max(
+    latestSignTime + GLOBAL_COOLDOWN_SECONDS * 1000,
+    latestTargetSignTime + SAME_TARGET_COOLDOWN_SECONDS * 1000
+  )
+})
+
+const remainingSeconds = computed(() => {
+  return Math.max(0, Math.ceil((cooldownDeadline.value - now.value) / 1000))
+})
+
+const countdownText = computed(() => formatCountdown(remainingSeconds.value))
+
+const canCheckIn = computed(() => {
+  return (
+    props.locationStatus === 'ready' &&
+    Boolean(props.currentLocation) &&
+    Boolean(props.target) &&
+    visitTimeStatus.value === 'ready' &&
+    visitTimeAllowed.value &&
+    signRecordsStatus.value === 'ready' &&
+    remainingSeconds.value === 0 &&
+    !packageLoading.value &&
+    !signSubmitting.value
+  )
+})
+
+const hasStatusError = computed(() => {
+  return (
+    props.locationStatus === 'error' ||
+    visitTimeStatus.value === 'error' ||
+    signRecordsStatus.value === 'error'
+  )
+})
+
+const isChecking = computed(() => {
+  return (
+    props.locationStatus === 'idle' ||
+    props.locationStatus === 'locating' ||
+    visitTimeStatus.value === 'idle' ||
+    visitTimeStatus.value === 'loading' ||
+    signRecordsStatus.value === 'idle' ||
+    signRecordsStatus.value === 'loading'
+  )
+})
+
+const statusView = computed<StatusView>(() => {
+  if (props.locationStatus !== 'ready') {
+    return {
+      badge: props.locationStatus === 'error' ? '不可打卡' : '校验中',
+      title: props.locationStatus === 'error' ? '定位失败' : '正在获取当前位置',
+      description: props.locationStatus === 'error' ? '请检查系统定位权限后重新定位。' : '',
+    }
+  }
+
+  if (!props.target) {
+    return {
+      badge: '无拜访对象',
+      title: '暂无可用拜访对象',
+      description: '附近暂无该类型拜访对象',
+    }
+  }
+
+  if (isChecking.value) {
+    return {
+      badge: '校验中',
+      title: '正在校验打卡条件',
+      description: '',
+    }
+  }
+
+  if (hasStatusError.value || !visitTimeAllowed.value) {
+    return {
+      badge: '不可打卡',
+      title: '当前不可打卡',
+      description: hasStatusError.value
+        ? '打卡条件校验失败,请稍后重试。'
+        : visitTimeMessage.value || '当前不在允许的打卡时间内。',
+    }
+  }
+
+  if (remainingSeconds.value > 0) {
+    return {
+      badge: '冷却中',
+      title: '暂未到再次打卡时间',
+      description: `${countdownText.value} 后可再次打卡。`,
+    }
+  }
+
+  return {
+    badge: '可打卡',
+    title: '当前打卡地点',
+    description: '',
+  }
+})
+
+const actionText = computed(() => {
+  if (remainingSeconds.value > 0) {
+    return { primary: countdownText.value, secondary: '后可打卡' }
+  }
+  if (packageLoading.value || signSubmitting.value) return { primary: '加载中', secondary: '' }
+  if (!canCheckIn.value) return { primary: '不可打卡', secondary: '' }
+  return { primary: '打卡', secondary: '' }
+})
+
+let clockTimer: ReturnType<typeof setInterval> | undefined
+
+const loadVisitTimeStatus = async () => {
+  const currentDeptId = deptId.value
+  const currentVisitTo = props.visitTo
+
+  if (!currentDeptId || !currentVisitTo) {
+    visitTimeStatus.value = 'idle'
+    visitTimeAllowed.value = false
+    visitTimeMessage.value = ''
+    return
+  }
+
+  visitTimeStatus.value = 'loading'
+  visitTimeAllowed.value = false
+  visitTimeMessage.value = ''
+
+  try {
+    const res = await isVisitTimeApi({
+      deptId: currentDeptId,
+      visitTo: currentVisitTo,
+    })
+
+    visitTimeAllowed.value = res.data === true
+    visitTimeStatus.value = 'ready'
+    visitTimeMessage.value = res.msg || ''
+  } catch {
+    visitTimeAllowed.value = false
+    visitTimeStatus.value = 'error'
+  }
+}
+
+const loadSignRecords = async () => {
+  const signUserid = userId.value
+
+  if (!signUserid) {
+    signRecords.value = []
+    signRecordsStatus.value = 'idle'
+    return
+  }
+
+  signRecordsStatus.value = 'loading'
+
+  try {
+    const res = await getSignListByUserIdApi({ signUserid })
+
+    signRecords.value = Array.isArray(res.data) ? res.data : []
+    signRecordsStatus.value = 'ready'
+    now.value = Date.now()
+  } catch {
+    signRecords.value = []
+    signRecordsStatus.value = 'error'
+  }
+}
+
+const handleCheckInClick = async () => {
+  const target = props.target
+  if (!canCheckIn.value || !target) return
+  await openPackagePicker(target)
+}
+
+const openPackagePicker = async (target: VisitTarget) => {
+  packageLoading.value = true
+  packageList.value = []
+  selectedPackageValues.value = []
+
+  try {
+    const res = await getPackageListByTaskTypeApi(TASK_TYPE_MAP[target.type])
+
+    if (props.target?.id !== target.id) return
+
+    const list = Array.isArray(res.data) ? res.data : []
+    if (list.length === 0) {
+      showToast('暂无可用积分包')
+      return
+    }
+
+    packageList.value = list
+    selectedPackageValues.value = [list[0].value]
+    packagePickerVisible.value = true
+  } finally {
+    packageLoading.value = false
+  }
+}
+
+const confirmPackageSelection = async (event: PackagePickerConfirmEvent) => {
+  const target = props.target
+  const packageItem = event.selectedItems[0] || selectedPackage.value
+  if (!target || !packageItem) return
+
+  packagePickerVisible.value = false
+  emit('package-selected', { target, packageItem })
+
+  const taskTypeId = TASK_TYPE_MAP[target.type]
+  const hasTaskTypeTemplate = userStore.currentUserInfo?.hasTaskTypeTemplate || []
+
+  if (hasTaskTypeTemplate.includes(taskTypeId)) {
+    await openTemplateForm(target, packageItem, taskTypeId)
+    return
+  }
+
+  await submitSign(target, packageItem)
+}
+
+const DICT_TYPE_MAP: Record<VisitTargetType, string> = {
+  hospital: 'user_sign_detail_purpose',
+  company: 'user_sign_detail_distribution_purpose',
+  pharmacy: 'user_sign_detail_pharmacy_purpose',
+}
+
+const toDictOptions = (list: DictItem[]) => {
+  return list.map((item) => ({ label: item.label, value: item.value }))
+}
+
+const openTemplateForm = async (
+  target: VisitTarget,
+  packageItem: SharePackageItem,
+  taskTypeId: string
+) => {
+  const currentDeptId = deptId.value
+  if (!currentDeptId) return
+
+  templateTarget.value = target
+  templatePackageItem.value = packageItem
+  templateSignDate.value = dayjs().format('YYYY-MM-DD HH:mm:ss')
+  templateForm.value = createTemplateForm()
+  templateLoading.value = true
+  templateVisible.value = true
+
+  try {
+    const templateRes = await getVisitTemplateApi({ deptId: currentDeptId, taskTypeId })
+    templateType.value = templateRes.data?.template || ''
+
+    const purposeDictType =
+      target.type === 'hospital' && templateType.value === 'TEMPLATE2'
+        ? 'user_sign_detail_template2_purpose'
+        : DICT_TYPE_MAP[target.type]
+
+    const [drugRes, purposeRes, resultRes] = await Promise.all([
+      getListDrugTableApi(String(packageItem.value)),
+      getDictTypeApi(purposeDictType),
+      getDictTypeApi('user_sign_detail_result'),
+    ])
+
+    if (templateTarget.value?.id !== target.id) return
+
+    drugOptions.value = (Array.isArray(drugRes.data) ? drugRes.data : []).map((item) => ({
+      label: String(item),
+      value: String(item),
+    }))
+    purposeOptions.value = toDictOptions(Array.isArray(purposeRes.data) ? purposeRes.data : [])
+    resultOptions.value = toDictOptions(Array.isArray(resultRes.data) ? resultRes.data : [])
+  } catch {
+    templateVisible.value = false
+  } finally {
+    templateLoading.value = false
+  }
+}
+
+const openDrugPicker = () => {
+  if (drugOptions.value.length === 0) {
+    showToast('暂无可选推广药品')
+    return
+  }
+  drugPickerVisible.value = true
+}
+
+const submitTemplateSign = async () => {
+  const target = templateTarget.value
+  const packageItem = templatePackageItem.value
+  if (!target || !packageItem) return
+
+  const form = templateForm.value
+  const result = String(form.result[0] ?? '')
+  const needsEvaluation = target.type === 'hospital' && templateType.value === 'TEMPLATE2'
+  const photoUrls = form.photoUrls
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
+
+  if (
+    form.drug.length === 0 ||
+    form.purpose.length === 0 ||
+    !result ||
+    (target.type === 'pharmacy' && !form.visitor.trim()) ||
+    (requiresDepartment.value && !form.department.trim()) ||
+    (requiresTemplatePhoto.value && photoUrls.length === 0) ||
+    (needsEvaluation && (!form.rightHcp || !form.rightInfo || !form.rightActivity))
+  ) {
+    showToast('请完善拜访信息')
+    return
+  }
+
+  const userSignDetail: Record<string, unknown> = {
+    templateType: templateType.value,
+    drug: form.drug,
+    purpose: form.purpose,
+    result,
+    remark: form.remark.trim(),
+    visitor: form.visitor.trim(),
+    detail: needsEvaluation
+      ? [
+          {
+            rightHcp: form.rightHcp === 'true',
+            rightInfo: form.rightInfo === 'true',
+            rightActivity: form.rightActivity === 'true',
+          },
+        ]
+      : requiresDepartment.value
+        ? [{ departmentName: form.department.trim(), fileUrl: photoUrls }]
+        : [],
+    scenePhotoJson:
+      requiresTemplatePhoto.value && !requiresDepartment.value
+        ? [{ name: '', fileUrl: photoUrls }]
+        : [],
+  }
+
+  const submitted = await submitSign(target, packageItem, userSignDetail, templateSignDate.value)
+
+  if (submitted) {
+    templateVisible.value = false
+  }
+}
+
+const resetTemplateForm = () => {
+  templateTarget.value = undefined
+  templatePackageItem.value = undefined
+  templateType.value = ''
+  templateSignDate.value = ''
+  templateForm.value = createTemplateForm()
+  drugOptions.value = []
+  purposeOptions.value = []
+  resultOptions.value = []
+}
+
+const submitSign = async (
+  target: VisitTarget,
+  packageItem: SharePackageItem,
+  userSignDetail: Record<string, unknown> = {},
+  signDate = dayjs().format('YYYY-MM-DD HH:mm:ss')
+) => {
+  const location = props.currentLocation
+  const signUserid = userId.value
+  if (!location || !signUserid || signSubmitting.value) return false
+
+  const data: SaveSignInfoRequest = {
+    address: target.address,
+    latitude: location.latitude,
+    longitude: location.longitude,
+    packageId: String(packageItem.value),
+    signDate,
+    signEntId: target.signEntId,
+    signEntName: target.name,
+    signEntType: target.signEntType,
+    signUserid,
+    userSignDetail,
+  }
+
+  signSubmitting.value = true
+
+  try {
+    const res = await saveSignInfoApi(data)
+
+    const message =
+      res.data?.type === '1' ? `提交成功,获得${res.data.score ?? 0}积分` : '完成任务数+1'
+
+    showToast(message, 3000)
+    packageList.value = []
+    selectedPackageValues.value = []
+    await loadSignRecords()
+    emit('sign-success')
+    return true
+  } catch {
+    return false
+  } finally {
+    signSubmitting.value = false
+  }
+}
+
+watch(
+  () => [deptId.value, props.visitTo] as const,
+  () => void loadVisitTimeStatus(),
+  { immediate: true }
+)
+
+watch(userId, () => void loadSignRecords(), { immediate: true })
+
+watch(
+  () => props.target?.id,
+  () => {
+    packagePickerVisible.value = false
+    templateVisible.value = false
+    packageLoading.value = false
+    packageList.value = []
+    selectedPackageValues.value = []
+    now.value = Date.now()
+  }
+)
+
+onMounted(() => {
+  clockTimer = setInterval(() => {
+    now.value = Date.now()
+  }, 1000)
+})
+
+onUnmounted(() => {
+  if (clockTimer) clearInterval(clockTimer)
+})
+
+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 showToast = (title: string, duration?: number) => {
+  uni.showToast({
+    title,
+    icon: 'none',
+    ...(duration ? { duration } : {}),
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+.visit-status-card {
+  overflow: hidden;
+  background: linear-gradient(145deg, #ffffff 0%, #f7fbff 100%);
+  border: 2rpx solid rgba(47, 140, 247, 0.08);
+  border-radius: 30rpx;
+  box-shadow: 0 16rpx 48rpx rgba(31, 70, 112, 0.07);
+}
+
+.visit-status-card__main {
+  display: flex;
+  align-items: center;
+  min-height: 280rpx;
+  padding: 30rpx 26rpx 28rpx 30rpx;
+  box-sizing: border-box;
+}
+
+.visit-status-card__copy {
+  flex: 1;
+  min-width: 0;
+  padding-right: 4rpx;
+}
+
+.visit-status-card__meta {
+  display: flex;
+  align-items: center;
+  color: #718096;
+  font-size: 22rpx;
+  font-weight: 600;
+  line-height: 34rpx;
+}
+
+.visit-status-card__meta-dot {
+  width: 12rpx;
+  height: 12rpx;
+  margin-right: 10rpx;
+  background: #f4b94f;
+  border-radius: 50%;
+  box-shadow: 0 0 0 8rpx rgba(244, 185, 79, 0.12);
+}
+
+.visit-status-card__meta-dot--success {
+  background: #31b87b;
+  box-shadow: 0 0 0 8rpx rgba(49, 184, 123, 0.12);
+}
+
+.visit-status-card__meta-dot--error {
+  background: #e96868;
+  box-shadow: 0 0 0 8rpx rgba(233, 104, 104, 0.12);
+}
+
+.visit-status-card__badge {
+  height: 40rpx;
+  margin-left: 14rpx;
+  padding: 0 14rpx;
+  color: #b7791f;
+  font-size: 20rpx;
+  line-height: 40rpx;
+  background: #fff7e7;
+  border-radius: 999rpx;
+}
+
+.visit-status-card__badge--success {
+  color: #168456;
+  background: #e9f8f1;
+}
+
+.visit-status-card__badge--error {
+  color: #c24f4f;
+  background: #fff0f0;
+}
+
+.visit-status-card__title {
+  margin-top: 18rpx;
+  color: #172033;
+  font-size: 34rpx;
+  font-weight: 750;
+  line-height: 46rpx;
+}
+
+.visit-status-card__desc {
+  max-width: 390rpx;
+  margin-top: 12rpx;
+  color: #7b8899;
+  font-size: 23rpx;
+  line-height: 36rpx;
+}
+
+.visit-status-card__place {
+  display: flex;
+  align-items: flex-start;
+  margin-top: 12rpx;
+}
+
+.visit-status-card__place-copy {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  min-width: 0;
+  margin-left: 10rpx;
+}
+
+.visit-status-card__place-name {
+  overflow: hidden;
+  color: #2f4158;
+  font-size: 24rpx;
+  font-weight: 700;
+  line-height: 34rpx;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.visit-status-card__place-address {
+  display: -webkit-box;
+  margin-top: 4rpx;
+  overflow: hidden;
+  color: #8190a2;
+  font-size: 19rpx;
+  line-height: 28rpx;
+  word-break: break-all;
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.visit-status-card__action-wrap {
+  flex-shrink: 0;
+  margin-left: 18rpx;
+}
+
+.visit-status-card__action-wrap--disabled {
+  pointer-events: none;
+}
+
+.visit-status-card__action-ring {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 176rpx;
+  height: 176rpx;
+  background: rgba(148, 163, 184, 0.12);
+  border: 2rpx solid rgba(148, 163, 184, 0.16);
+  border-radius: 50%;
+}
+
+.visit-status-card__action-ring--active {
+  background: rgba(47, 140, 247, 0.08);
+  border-color: rgba(47, 140, 247, 0.1);
+}
+
+.visit-status-card__action {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 146rpx;
+  height: 146rpx;
+  color: #ffffff;
+  font-size: 26rpx;
+  font-weight: 700;
+  line-height: 34rpx;
+  background: linear-gradient(145deg, #c9d0d9 0%, #aeb8c4 100%);
+  border-radius: 50%;
+  box-shadow: 0 14rpx 28rpx rgba(100, 116, 139, 0.16);
+}
+
+.visit-status-card__action--active {
+  background: linear-gradient(145deg, #50b5ff 0%, #2f8cf7 100%);
+  box-shadow: 0 16rpx 32rpx rgba(47, 140, 247, 0.3);
+}
+
+.visit-status-card__action--countdown {
+  font-size: 21rpx;
+  line-height: 32rpx;
+}
+
+.visit-status-card__footer {
+  display: flex;
+  align-items: center;
+  min-height: 78rpx;
+  padding: 12rpx 28rpx;
+  background: rgba(239, 247, 255, 0.72);
+  border-top: 2rpx solid rgba(47, 140, 247, 0.06);
+  box-sizing: border-box;
+}
+
+.visit-status-card__footer-icon {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 50rpx;
+  height: 50rpx;
+  margin-right: 16rpx;
+  background: #ffffff;
+  border-radius: 16rpx;
+}
+
+.visit-status-card__footer-title {
+  flex: 1;
+  min-width: 0;
+  color: #2f8cf7;
+  font-size: 24rpx;
+  font-weight: 650;
+  line-height: 34rpx;
+}
+
+.visit-template {
+  display: flex;
+  flex-direction: column;
+  max-height: 88vh;
+  padding-bottom: env(safe-area-inset-bottom);
+  background: #f5f7fa;
+}
+
+.visit-template__header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 28rpx 30rpx;
+  background: #ffffff;
+  border-bottom: 2rpx solid #edf1f5;
+}
+
+.visit-template__title {
+  color: #172033;
+  font-size: 32rpx;
+  font-weight: 700;
+  line-height: 44rpx;
+}
+
+.visit-template__subtitle {
+  max-width: 560rpx;
+  margin-top: 4rpx;
+  overflow: hidden;
+  color: #8491a2;
+  font-size: 22rpx;
+  line-height: 32rpx;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.visit-template__close {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 60rpx;
+  height: 60rpx;
+  background: #f4f7fa;
+  border-radius: 50%;
+}
+
+.visit-template__loading {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  height: 420rpx;
+  color: #8491a2;
+  font-size: 26rpx;
+}
+
+.visit-template__scroll {
+  height: 860rpx;
+  padding-top: 18rpx;
+}
+
+.visit-template__field {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 104rpx;
+  padding: 20rpx 30rpx;
+  background: #ffffff;
+  border-top: 2rpx solid #f1f3f6;
+  box-sizing: border-box;
+}
+
+.visit-template__field--vertical {
+  align-items: stretch;
+  flex-direction: column;
+}
+
+.visit-template__field-label {
+  margin-right: 24rpx;
+  color: #303a49;
+  font-size: 26rpx;
+  line-height: 38rpx;
+}
+
+.visit-template__field--vertical .visit-template__field-label {
+  margin: 0 0 16rpx;
+}
+
+.visit-template__required {
+  margin-right: 6rpx;
+  color: #e65d5d;
+}
+
+.visit-template__actions {
+  display: flex;
+  gap: 18rpx;
+  padding: 20rpx 28rpx;
+  background: #ffffff;
+  box-shadow: 0 -8rpx 24rpx rgba(31, 51, 73, 0.06);
+}
+
+.visit-template__button {
+  flex: 1;
+  height: 82rpx;
+  margin: 0;
+  padding: 0;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  line-height: 82rpx;
+}
+
+.visit-template__button::after {
+  border: 0;
+}
+
+.visit-template__button--cancel {
+  color: #53657a;
+  background: #eef2f6;
+}
+
+.visit-template__button--submit {
+  color: #ffffff;
+  background: linear-gradient(135deg, #50b5ff 0%, #2f8cf7 100%);
+}
+
+.visit-template__button--submit[disabled] {
+  opacity: 0.6;
+}
+</style>

+ 306 - 0
src/pages-task/task-visit/components/visit-target/index.vue

@@ -0,0 +1,306 @@
+<template>
+  <view class="visit-target-panel">
+    <view class="visit-target-panel__heading">
+      <view class="visit-target-panel__title">选择拜访对象</view>
+      <view class="visit-target-panel__count">
+        {{ loading ? '更新中' : `附近 ${targets.length} 个` }}
+      </view>
+    </view>
+
+    <view class="visit-target-panel__tabs">
+      <view
+        v-for="tab in tabs"
+        :key="tab.value"
+        class="visit-target-panel__tab"
+        :class="{ 'visit-target-panel__tab--active': tab.value === activeType }"
+        @click="emit('type-change', tab.value)"
+      >
+        {{ tab.label }}
+      </view>
+    </view>
+
+    <scroll-view
+      v-if="targets.length"
+      class="visit-target-panel__scroll"
+      scroll-x
+      :show-scrollbar="false"
+    >
+      <view class="visit-target-panel__list">
+        <view
+          v-for="target in targets"
+          :key="target.id"
+          class="visit-target-card"
+          :class="{ 'visit-target-card--active': target.id === selectedId }"
+          @click="emit('target-change', target.id)"
+        >
+          <view class="visit-target-card__icon">
+            <wd-icon name="location" size="38rpx" color="#2f8cf7" />
+          </view>
+
+          <view class="visit-target-card__content">
+            <view class="visit-target-card__name">{{ target.name }}</view>
+            <view class="visit-target-card__address">{{ target.address }}</view>
+          </view>
+
+          <view v-if="target.id === selectedId" class="visit-target-card__selected">
+            <wd-icon name="check" size="20rpx" color="#ffffff" />
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+
+    <view v-else class="visit-target-panel__state">
+      <wd-icon
+        :name="errorMessage ? 'warning' : 'location'"
+        size="42rpx"
+        :color="errorMessage ? '#d66a55' : '#7db8ef'"
+      />
+      <text class="visit-target-panel__state-text">{{ stateText }}</text>
+      <view v-if="errorMessage" class="visit-target-panel__retry" @click="emit('retry')">
+        重新加载
+      </view>
+    </view>
+
+    <view v-if="targets.length > 1" class="visit-target-panel__hint">
+      <view class="visit-target-panel__hint-dot" />
+      <text class="visit-target-panel__hint-text">左右滑动可查看附近的其他拜访对象</text>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+import type { VisitTarget, VisitTargetTab, VisitTargetType } from '../../types'
+
+const props = withDefaults(
+  defineProps<{
+    tabs?: VisitTargetTab[]
+    targets?: VisitTarget[]
+    activeType?: VisitTargetType
+    selectedId?: string
+    loading?: boolean
+    errorMessage?: string
+  }>(),
+  {
+    tabs: () => [],
+    targets: () => [],
+    activeType: 'hospital',
+    selectedId: '',
+    loading: false,
+    errorMessage: '',
+  }
+)
+
+const emit = defineEmits<{
+  (event: 'type-change', value: VisitTargetType): void
+  (event: 'target-change', value: string): void
+  (event: 'retry'): void
+}>()
+
+const stateText = computed(() => {
+  if (props.loading) return '正在查找附近拜访对象…'
+  if (props.errorMessage) return props.errorMessage
+  return '附近暂无该类型的拜访对象'
+})
+</script>
+
+<style lang="scss" scoped>
+.visit-target-panel {
+  padding: 32rpx 0 28rpx;
+  background: #ffffff;
+  border-radius: 30rpx;
+  box-shadow: 0 16rpx 48rpx rgba(31, 70, 112, 0.07);
+}
+
+.visit-target-panel__heading {
+  display: flex;
+  align-items: flex-end;
+  justify-content: space-between;
+  padding: 0 28rpx;
+}
+
+.visit-target-panel__title {
+  color: #172033;
+  font-size: 34rpx;
+  font-weight: 700;
+  line-height: 48rpx;
+}
+
+.visit-target-panel__count {
+  height: 48rpx;
+  padding: 0 18rpx;
+  color: #64748b;
+  font-size: 22rpx;
+  line-height: 48rpx;
+  background: #f4f7fb;
+  border-radius: 999rpx;
+}
+
+.visit-target-panel__tabs {
+  display: flex;
+  gap: 10rpx;
+  margin: 28rpx 28rpx 0;
+  padding: 8rpx;
+  background: #f2f6fa;
+  border-radius: 22rpx;
+}
+
+.visit-target-panel__tab {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  justify-content: center;
+  min-width: 0;
+  height: 68rpx;
+  padding: 0 8rpx;
+  color: #64748b;
+  font-size: 24rpx;
+  font-weight: 550;
+  line-height: 32rpx;
+  text-align: center;
+  border-radius: 16rpx;
+  box-sizing: border-box;
+}
+
+.visit-target-panel__tab--active {
+  color: #207fdf;
+  font-weight: 700;
+  background: #ffffff;
+  box-shadow: 0 6rpx 18rpx rgba(31, 98, 162, 0.1);
+}
+
+.visit-target-panel__scroll {
+  width: 100%;
+  margin-top: 24rpx;
+  white-space: nowrap;
+}
+
+.visit-target-panel__list {
+  display: inline-flex;
+  gap: 18rpx;
+  padding: 0 28rpx;
+}
+
+.visit-target-card {
+  position: relative;
+  display: inline-flex;
+  align-items: center;
+  width: 600rpx;
+  min-height: 128rpx;
+  padding: 22rpx 24rpx;
+  vertical-align: top;
+  background: #f8fafc;
+  border: 2rpx solid transparent;
+  border-radius: 24rpx;
+  box-sizing: border-box;
+}
+
+.visit-target-card--active {
+  background: linear-gradient(135deg, #f2f8ff 0%, #fbfdff 100%);
+  border-color: rgba(47, 140, 247, 0.28);
+  box-shadow: 0 10rpx 24rpx rgba(47, 140, 247, 0.08);
+}
+
+.visit-target-card__icon {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 72rpx;
+  height: 72rpx;
+  margin-right: 20rpx;
+  background: #e9f4ff;
+  border-radius: 22rpx;
+}
+
+.visit-target-card__content {
+  flex: 1;
+  min-width: 0;
+}
+
+.visit-target-card__name {
+  flex: 1;
+  min-width: 0;
+  overflow: hidden;
+  color: #1e293b;
+  font-size: 28rpx;
+  font-weight: 650;
+  line-height: 44rpx;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.visit-target-card__address {
+  margin-top: 6rpx;
+  overflow: hidden;
+  color: #8a97a8;
+  font-size: 22rpx;
+  line-height: 32rpx;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.visit-target-card__selected {
+  position: absolute;
+  top: -2rpx;
+  right: -2rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 38rpx;
+  height: 38rpx;
+  background: #2f8cf7;
+  border-radius: 0 22rpx 0 18rpx;
+}
+
+.visit-target-panel__state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 180rpx;
+  margin: 24rpx 28rpx 0;
+  color: #96a3b3;
+  font-size: 22rpx;
+  line-height: 34rpx;
+  background: #f8fbff;
+  border: 2rpx dashed #dceafa;
+  border-radius: 22rpx;
+}
+
+.visit-target-panel__state-text {
+  max-width: 520rpx;
+  margin-top: 12rpx;
+  text-align: center;
+}
+
+.visit-target-panel__retry {
+  height: 48rpx;
+  margin-top: 14rpx;
+  padding: 0 22rpx;
+  color: #2f8cf7;
+  font-weight: 650;
+  line-height: 48rpx;
+  background: #eaf4ff;
+  border-radius: 999rpx;
+}
+
+.visit-target-panel__hint {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  margin-top: 18rpx;
+  color: #a0aaba;
+  font-size: 20rpx;
+  line-height: 30rpx;
+}
+
+.visit-target-panel__hint-dot {
+  width: 8rpx;
+  height: 8rpx;
+  margin-right: 10rpx;
+  background: #9cc9f3;
+  border-radius: 50%;
+}
+</style>

+ 363 - 0
src/pages-task/task-visit/components/visit-time/index.vue

@@ -0,0 +1,363 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="bottom"
+    custom-style="border-radius: 32rpx 32rpx 0 0; overflow: hidden;"
+  >
+    <view class="visit-time-panel">
+      <view class="visit-time-panel__header">
+        <view class="visit-time-panel__header-icon">
+          <wd-icon name="time-line" size="36rpx" color="#2f8cf7" />
+        </view>
+
+        <view class="visit-time-panel__header-copy">
+          <view class="visit-time-panel__title">{{ targetTypeLabel }}打卡时间</view>
+        </view>
+
+        <view class="visit-time-panel__close" @click="visible = false">
+          <wd-icon name="close" size="32rpx" color="#8b98a8" />
+        </view>
+      </view>
+
+      <view v-if="loading" class="visit-time-panel__state">
+        <view class="visit-time-panel__loading" />
+        <view class="visit-time-panel__state-title">正在获取打卡时间</view>
+      </view>
+
+      <view v-else-if="errorMessage" class="visit-time-panel__state">
+        <view class="visit-time-panel__state-icon visit-time-panel__state-icon--error">
+          <wd-icon name="warning" size="40rpx" color="#d66a55" />
+        </view>
+        <view class="visit-time-panel__state-title">打卡时间加载失败</view>
+        <view class="visit-time-panel__state-desc">{{ errorMessage }}</view>
+        <view class="visit-time-panel__retry" @click="loadVisitTimes">重新加载</view>
+      </view>
+
+      <scroll-view v-else-if="timeList.length" class="visit-time-panel__scroll" scroll-y>
+        <view class="visit-time-panel__list">
+          <view
+            v-for="item in timeList"
+            :key="item.date"
+            class="visit-time-item"
+            :class="{ 'visit-time-item--disabled': !item.choose }"
+          >
+            <view class="visit-time-item__date">{{ item.date }}</view>
+
+            <view class="visit-time-item__period">
+              <view class="visit-time-item__period-dot" />
+              <text>{{ getTimeRange(item) }}</text>
+            </view>
+
+            <view
+              class="visit-time-item__status"
+              :class="{ 'visit-time-item__status--disabled': !item.choose }"
+            >
+              {{ item.choose ? '可打卡' : '不可打卡' }}
+            </view>
+          </view>
+        </view>
+      </scroll-view>
+
+      <view v-else class="visit-time-panel__state">
+        <view class="visit-time-panel__state-icon">
+          <wd-icon name="time-line" size="42rpx" color="#7db8ef" />
+        </view>
+        <view class="visit-time-panel__state-title">暂无打卡时间配置</view>
+      </view>
+
+      <view class="visit-time-panel__footer"> 打卡时间由企业配置,请以页面实时状态为准 </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+
+import { getVisitTimeApi } from '@/services/modules/task/taskVisit'
+import type { VisitTimeItem, VisitToType } from '@/services/modules/task/taskVisit/type'
+
+import { useUserStore } from '@/stores/modules/user'
+
+const props = defineProps<{
+  modelValue: boolean
+  visitTo: VisitToType
+  targetTypeLabel: string
+}>()
+
+const emit = defineEmits<{
+  (event: 'update:modelValue', value: boolean): void
+}>()
+
+const userStore = useUserStore()
+
+const visible = computed({
+  get: () => props.modelValue,
+  set: (value: boolean) => emit('update:modelValue', value),
+})
+
+const timeList = ref<VisitTimeItem[]>([])
+const loading = ref(false)
+const errorMessage = ref('')
+const deptId = computed(() => userStore.currentUserInfo?.deptId || '')
+
+const loadVisitTimes = async () => {
+  if (!deptId.value) {
+    timeList.value = []
+    loading.value = false
+    errorMessage.value = '未获取到当前企业信息'
+    return
+  }
+
+  loading.value = true
+  errorMessage.value = ''
+
+  try {
+    const res = await getVisitTimeApi({
+      deptId: deptId.value,
+      visitTo: props.visitTo,
+    })
+
+    timeList.value = Array.isArray(res.data) ? res.data : []
+  } catch (error) {
+    timeList.value = []
+    errorMessage.value = error instanceof Error ? error.message : '获取打卡时间失败'
+  } finally {
+    loading.value = false
+  }
+}
+
+const getTimeRange = (item: VisitTimeItem) => {
+  if (!item.choose) return '当日不可打卡'
+  if (Array.isArray(item.time) && item.time.length >= 2) {
+    return `${item.time[0]} - ${item.time[1]}`
+  }
+
+  return '时间待配置'
+}
+
+watch(
+  () => [props.modelValue, deptId.value, props.visitTo] as const,
+  ([isVisible]) => {
+    if (isVisible) {
+      void loadVisitTimes()
+    }
+  }
+)
+
+</script>
+
+<style lang="scss" scoped>
+.visit-time-panel {
+  padding: 34rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
+  background: #ffffff;
+  box-sizing: border-box;
+}
+
+.visit-time-panel__header {
+  display: flex;
+  align-items: center;
+}
+
+.visit-time-panel__header-icon {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 68rpx;
+  height: 68rpx;
+  margin-right: 18rpx;
+  background: #eaf4ff;
+  border-radius: 20rpx;
+}
+
+.visit-time-panel__header-copy {
+  flex: 1;
+  min-width: 0;
+}
+
+.visit-time-panel__title {
+  color: #172033;
+  font-size: 32rpx;
+  font-weight: 700;
+  line-height: 44rpx;
+}
+
+.visit-time-panel__close {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 60rpx;
+  height: 60rpx;
+  margin-left: 18rpx;
+  background: #f4f7fa;
+  border-radius: 50%;
+}
+
+.visit-time-panel__scroll {
+  width: 100%;
+  height: 760rpx;
+  margin-top: 28rpx;
+}
+
+.visit-time-panel__list {
+  padding-bottom: 4rpx;
+}
+
+.visit-time-item {
+  display: flex;
+  align-items: center;
+  min-height: 86rpx;
+  margin-bottom: 14rpx;
+  padding: 14rpx 18rpx;
+  background: linear-gradient(135deg, #f4f9ff 0%, #fbfdff 100%);
+  border: 2rpx solid #e9f2fb;
+  border-radius: 20rpx;
+  box-sizing: border-box;
+}
+
+.visit-time-item:last-child {
+  margin-bottom: 0;
+}
+
+.visit-time-item--disabled {
+  background: #f7f8fa;
+  border-color: #edf0f3;
+}
+
+.visit-time-item__date {
+  flex-shrink: 0;
+  width: 84rpx;
+  color: #1e293b;
+  font-size: 25rpx;
+  font-weight: 650;
+  line-height: 36rpx;
+}
+
+.visit-time-item__period {
+  display: flex;
+  flex: 1;
+  align-items: center;
+  min-width: 0;
+  margin: 0 18rpx;
+  color: #53657a;
+  font-size: 23rpx;
+  line-height: 34rpx;
+}
+
+.visit-time-item__period-dot {
+  flex-shrink: 0;
+  width: 10rpx;
+  height: 10rpx;
+  margin-right: 12rpx;
+  background: #3fa3f1;
+  border-radius: 50%;
+}
+
+.visit-time-item--disabled .visit-time-item__date,
+.visit-time-item--disabled .visit-time-item__period {
+  color: #a5afbb;
+}
+
+.visit-time-item--disabled .visit-time-item__period-dot {
+  background: #c5ccd4;
+}
+
+.visit-time-item__status {
+  flex-shrink: 0;
+  height: 40rpx;
+  padding: 0 14rpx;
+  color: #168456;
+  font-size: 19rpx;
+  font-weight: 650;
+  line-height: 40rpx;
+  background: #e9f8f1;
+  border-radius: 999rpx;
+}
+
+.visit-time-item__status--disabled {
+  color: #8e99a6;
+  background: #eceff2;
+}
+
+.visit-time-panel__state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  min-height: 300rpx;
+  margin-top: 28rpx;
+  padding: 28rpx 20rpx;
+  background: #f8fbff;
+  border: 2rpx dashed #dceafa;
+  border-radius: 24rpx;
+  box-sizing: border-box;
+}
+
+.visit-time-panel__state-icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 76rpx;
+  height: 76rpx;
+  background: #ffffff;
+  border-radius: 50%;
+  box-shadow: 0 8rpx 20rpx rgba(71, 142, 207, 0.12);
+}
+
+.visit-time-panel__state-icon--error {
+  background: #fff4f1;
+  box-shadow: none;
+}
+
+.visit-time-panel__loading {
+  width: 52rpx;
+  height: 52rpx;
+  border: 6rpx solid #d9ebfb;
+  border-top-color: #3fa3f1;
+  border-radius: 50%;
+  animation: visit-time-loading 0.8s linear infinite;
+}
+
+.visit-time-panel__state-title {
+  margin-top: 18rpx;
+  color: #526175;
+  font-size: 26rpx;
+  font-weight: 650;
+  line-height: 38rpx;
+}
+
+.visit-time-panel__state-desc {
+  margin-top: 6rpx;
+  color: #a1adbb;
+  font-size: 21rpx;
+  line-height: 32rpx;
+  text-align: center;
+}
+
+.visit-time-panel__retry {
+  height: 52rpx;
+  margin-top: 20rpx;
+  padding: 0 24rpx;
+  color: #2f8cf7;
+  font-size: 22rpx;
+  font-weight: 650;
+  line-height: 52rpx;
+  background: #eaf4ff;
+  border-radius: 999rpx;
+}
+
+.visit-time-panel__footer {
+  margin-top: 22rpx;
+  color: #a1adbb;
+  font-size: 20rpx;
+  line-height: 30rpx;
+  text-align: center;
+}
+
+@keyframes visit-time-loading {
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 99 - 0
src/pages-task/task-visit/composables/useVisitLocation.ts

@@ -0,0 +1,99 @@
+import { ref } from 'vue'
+
+import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
+
+import { getLocation, type LocationResult } from '@/lib/location'
+
+import { getDictTypeApi } from '@/services/modules/common'
+
+import { DEFAULT_VISIT_RANGE_RADIUS } from '../taskVisit.config'
+import type { VisitLocationStatus } from '../types'
+
+const LOCATION_POLL_INTERVAL = 5 * 60_000
+
+export const useVisitLocation = () => {
+  const currentLocation = ref<LocationResult | null>(null)
+  const locationStatus = ref<VisitLocationStatus>('idle')
+  const rangeRadius = ref(DEFAULT_VISIT_RANGE_RADIUS)
+
+  let locationTimer: ReturnType<typeof setInterval> | undefined
+  let locating = false
+
+  const stopLocationPolling = () => {
+    if (!locationTimer) return
+
+    clearInterval(locationTimer)
+    locationTimer = undefined
+  }
+
+  const refreshCurrentLocation = async () => {
+    if (locating) return
+
+    locating = true
+    if (!currentLocation.value) {
+      locationStatus.value = 'locating'
+    }
+
+    try {
+      const location = await getLocation()
+
+      if (!isValidLocation(location)) {
+        throw new Error('Invalid location result')
+      }
+
+      currentLocation.value = location
+      locationStatus.value = 'ready'
+    } catch {
+      locationStatus.value = 'error'
+
+      // 授权失败后暂停自动轮询,避免反复唤起授权提示;用户可手动重试。
+      stopLocationPolling()
+    } finally {
+      locating = false
+    }
+  }
+
+  const startLocationPolling = () => {
+    stopLocationPolling()
+    void refreshCurrentLocation()
+
+    locationTimer = setInterval(() => {
+      void refreshCurrentLocation()
+    }, LOCATION_POLL_INTERVAL)
+  }
+
+  const loadRangeRadius = async () => {
+    try {
+      const res = await getDictTypeApi('point_jwd')
+      const radius = Number(res.data?.[0]?.value)
+
+      if (Number.isFinite(radius) && radius > 0) {
+        rangeRadius.value = radius
+      }
+    } catch {
+      rangeRadius.value = DEFAULT_VISIT_RANGE_RADIUS
+    }
+  }
+
+  onShow(() => {
+    startLocationPolling()
+    void loadRangeRadius()
+  })
+  onHide(stopLocationPolling)
+  onUnload(stopLocationPolling)
+
+  return {
+    currentLocation,
+    locationStatus,
+    rangeRadius,
+    refreshCurrentLocation: startLocationPolling,
+  }
+}
+
+const isValidLocation = (location: LocationResult) => {
+  return (
+    Number.isFinite(location.latitude) &&
+    Number.isFinite(location.longitude) &&
+    !(location.latitude === 0 && location.longitude === 0)
+  )
+}

+ 87 - 0
src/pages-task/task-visit/composables/useVisitTargets.ts

@@ -0,0 +1,87 @@
+import { type Ref, ref, watch } from 'vue'
+
+import type { LocationResult } from '@/lib/location'
+
+import { getPointSignInfoApi } from '@/services/modules/task/taskVisit'
+import type { PointSignInfoItem } from '@/services/modules/task/taskVisit/type'
+
+import type { VisitTarget, VisitTargetType } from '../types'
+import { getDistanceInMeters } from '../utils/distance'
+
+const TARGET_TYPE_MAP: Record<string, VisitTargetType> = {
+  '1': 'hospital',
+  '2': 'company',
+  '3': 'pharmacy',
+}
+
+export const useVisitTargets = (currentLocation: Ref<LocationResult | null>) => {
+  const targets = ref<VisitTarget[]>([])
+  const loading = ref(false)
+  const errorMessage = ref('')
+
+  const loadTargets = async () => {
+    const location = currentLocation.value
+
+    if (!location) {
+      targets.value = []
+      return
+    }
+
+    loading.value = true
+    errorMessage.value = ''
+
+    try {
+      const res = await getPointSignInfoApi({
+        latitude: location.latitude,
+        longitude: location.longitude,
+      })
+
+      targets.value = (Array.isArray(res.data) ? res.data : [])
+        .map((item) => normalizeTarget(item, location))
+        .filter((item): item is VisitTarget => item !== null)
+        .sort((left, right) => left.distanceMeters - right.distanceMeters)
+    } catch (error) {
+      errorMessage.value = error instanceof Error ? error.message : '获取附近拜访对象失败'
+    } finally {
+      loading.value = false
+    }
+  }
+
+  watch(
+    () => [currentLocation.value?.latitude, currentLocation.value?.longitude] as const,
+    () => void loadTargets(),
+    { immediate: true }
+  )
+
+  return { targets, loading, errorMessage, loadTargets }
+}
+
+const normalizeTarget = (
+  item: PointSignInfoItem,
+  currentLocation: LocationResult
+): VisitTarget | null => {
+  const type = TARGET_TYPE_MAP[String(item.signEntType)]
+  const latitude = Number(item.latitude)
+  const longitude = Number(item.longitude)
+
+  if (!type || !Number.isFinite(latitude) || !Number.isFinite(longitude)) return null
+
+  const distanceMeters = getDistanceInMeters(
+    currentLocation.longitude,
+    currentLocation.latitude,
+    longitude,
+    latitude
+  )
+
+  return {
+    id: `${type}-${item.signEntId}`,
+    signEntId: String(item.signEntId),
+    signEntType: String(item.signEntType),
+    type,
+    name: item.signEntName || '未命名拜访地点',
+    address: item.address || '暂无详细地址',
+    latitude,
+    longitude,
+    distanceMeters,
+  }
+}

+ 166 - 3
src/pages-task/task-visit/index.vue

@@ -1,7 +1,170 @@
 <template>
-  <view>客户拜访</view>
+  <view class="task-visit-page">
+    <VisitMapPanel
+      :target="currentTarget"
+      :target-type-label="activeTab.label"
+      :current-location="currentLocation"
+      :range-radius="rangeRadius"
+      @refresh-location="refreshCurrentLocation"
+    />
+
+    <view class="task-visit-page__content">
+      <view class="visit-notice">
+        <wd-notice-bar
+          :text="VISIT_NOTICE"
+          prefix="warn-bold"
+          color="#a96d1f"
+          background-color="#fff8ec"
+          custom-style="--wot-notice-bar-padding: 10rpx 18rpx; --wot-notice-bar-font-size: 21rpx; --wot-notice-bar-line-height: 30rpx; --wot-notice-bar-prefix-size: 26rpx;"
+        />
+      </view>
+
+      <VisitTargetPanel
+        :tabs="VISIT_TARGET_TABS"
+        :targets="currentTargets"
+        :active-type="activeType"
+        :selected-id="selectedTargetId"
+        :loading="visitTargetsLoading"
+        :error-message="visitTargetsError"
+        @type-change="handleTypeChange"
+        @target-change="handleTargetChange"
+        @retry="loadVisitTargets"
+      />
+
+      <VisitStatusCard
+        :target="currentTarget"
+        :current-location="currentLocation"
+        :location-status="locationStatus"
+        :visit-to="currentVisitTo"
+        @open-visit-time="visitTimeVisible = true"
+        @sign-success="handleSignSuccess"
+      />
+
+      <VisitRecordPanel ref="visitRecordPanelRef" />
+    </view>
+
+    <VisitTimePanel
+      v-model="visitTimeVisible"
+      :visit-to="currentVisitTo"
+      :target-type-label="activeTab.label"
+    />
+  </view>
 </template>
 
-<script setup lang="ts"></script>
+<script setup lang="ts">
+import { computed, ref, watch } from 'vue'
+
+import type { VisitToType } from '@/services/modules/task/taskVisit/type'
+
+import VisitMapPanel from './components/visit-map/index.vue'
+import VisitRecordPanel from './components/visit-record/index.vue'
+import VisitStatusCard from './components/visit-status/index.vue'
+import VisitTargetPanel from './components/visit-target/index.vue'
+import VisitTimePanel from './components/visit-time/index.vue'
+import { useVisitLocation } from './composables/useVisitLocation'
+import { useVisitTargets } from './composables/useVisitTargets'
+import { VISIT_NOTICE, VISIT_TARGET_TABS } from './taskVisit.config'
+import type { VisitTarget, VisitTargetType } from './types'
+
+const visitToMap: Record<VisitTargetType, VisitToType> = {
+  hospital: 'HOSPITAL_VISIT',
+  company: 'BUSINESS_COMPANY_VISIT',
+  pharmacy: 'PHARMACY_VISIT',
+}
+
+const activeType = ref<VisitTargetType>('hospital')
+const visitTimeVisible = ref(false)
+
+interface VisitRecordPanelExpose {
+  refresh: () => Promise<void>
+}
+
+const { currentLocation, locationStatus, rangeRadius, refreshCurrentLocation } = useVisitLocation()
+const {
+  targets: visitTargets,
+  loading: visitTargetsLoading,
+  errorMessage: visitTargetsError,
+  loadTargets: loadVisitTargets,
+} = useVisitTargets(currentLocation)
+
+const selectedTargetId = ref('')
+const visitRecordPanelRef = ref<VisitRecordPanelExpose | null>(null)
+
+const activeTab = computed(() => {
+  return VISIT_TARGET_TABS.find((tab) => tab.value === activeType.value) ?? VISIT_TARGET_TABS[0]
+})
+
+const currentTargets = computed(() => {
+  return visitTargets.value.filter((target) => target.type === activeType.value)
+})
+
+const currentVisitTo = computed(() => visitToMap[activeType.value])
+
+const currentTarget = computed<VisitTarget | undefined>(() => {
+  return (
+    currentTargets.value.find((target) => target.id === selectedTargetId.value) ??
+    currentTargets.value[0]
+  )
+})
+
+const handleTypeChange = (value: VisitTargetType) => {
+  activeType.value = value
+}
+
+const handleTargetChange = (id: string) => {
+  selectedTargetId.value = id
+}
+
+const handleSignSuccess = () => {
+  void visitRecordPanelRef.value?.refresh()
+}
+
+watch(
+  currentTargets,
+  (targets) => {
+    if (!targets.some((target) => target.id === selectedTargetId.value)) {
+      selectedTargetId.value = targets[0]?.id || ''
+    }
+  },
+  { immediate: true }
+)
+</script>
+
+<style lang="scss" scoped>
+.task-visit-page {
+  min-height: 100vh;
+  overflow: hidden;
+  background: linear-gradient(180deg, #eaf4fd 0%, #f3f7fb 500rpx, #f3f7fb 100%);
+}
+
+.task-visit-page__content {
+  position: relative;
+  z-index: 5;
+  display: flex;
+  flex-direction: column;
+  gap: 22rpx;
+  margin-top: -28rpx;
+  padding: 16rpx 24rpx calc(48rpx + env(safe-area-inset-bottom));
+  border-radius: 38rpx 38rpx 0 0;
+  box-sizing: border-box;
+}
+
+.task-visit-page__content::before {
+  position: absolute;
+  top: 0;
+  right: 0;
+  left: 0;
+  z-index: -1;
+  height: 150rpx;
+  background: linear-gradient(180deg, rgba(243, 247, 251, 0.98) 0%, #f3f7fb 100%);
+  border-radius: 38rpx 38rpx 0 0;
+  content: '';
+}
 
-<style lang="scss" scoped></style>
+.visit-notice {
+  overflow: hidden;
+  background: #fff8ec;
+  border: 2rpx solid rgba(232, 163, 62, 0.12);
+  border-radius: 16rpx;
+}
+</style>

+ 12 - 0
src/pages-task/task-visit/taskVisit.config.ts

@@ -0,0 +1,12 @@
+import type { VisitTargetTab } from './types'
+
+export const VISIT_TARGET_TABS: VisitTargetTab[] = [
+  { value: 'hospital', label: '医院' },
+  { value: 'company', label: '商业公司' },
+  { value: 'pharmacy', label: '药店(含连锁)' },
+]
+
+export const VISIT_NOTICE =
+  '请于拜访前了解该院的药代入院制度并严格遵守,否则可能被医院处罚并被相关企业追责'
+
+export const DEFAULT_VISIT_RANGE_RADIUS = 1000

+ 20 - 0
src/pages-task/task-visit/types.ts

@@ -0,0 +1,20 @@
+export type VisitTargetType = 'hospital' | 'company' | 'pharmacy'
+
+export type VisitLocationStatus = 'idle' | 'locating' | 'ready' | 'error'
+
+export interface VisitTargetTab {
+  value: VisitTargetType
+  label: string
+}
+
+export interface VisitTarget {
+  id: string
+  signEntId: string
+  signEntType: string
+  type: VisitTargetType
+  name: string
+  address: string
+  distanceMeters: number
+  latitude: number
+  longitude: number
+}

+ 36 - 0
src/pages-task/task-visit/utils/distance.ts

@@ -0,0 +1,36 @@
+const EARTH_RADIUS_KM = 6378.137
+
+const toRadians = (degrees: number) => (degrees * Math.PI) / 180
+
+/** 计算两个经纬度坐标间的距离,返回公里。 */
+export const getDistance = (
+  longitude1: number,
+  latitude1: number,
+  longitude2: number,
+  latitude2: number
+) => {
+  const latitude1Radians = toRadians(latitude1)
+  const latitude2Radians = toRadians(latitude2)
+  const latitudeDelta = latitude1Radians - latitude2Radians
+  const longitudeDelta = toRadians(longitude1) - toRadians(longitude2)
+  const distance =
+    2 *
+    Math.asin(
+      Math.sqrt(
+        Math.sin(latitudeDelta / 2) ** 2 +
+          Math.cos(latitude1Radians) *
+            Math.cos(latitude2Radians) *
+            Math.sin(longitudeDelta / 2) ** 2
+      )
+    ) *
+    EARTH_RADIUS_KM
+
+  return Math.round(distance * 10_000) / 10_000
+}
+
+export const getDistanceInMeters = (
+  longitude1: number,
+  latitude1: number,
+  longitude2: number,
+  latitude2: number
+) => getDistance(longitude1, latitude1, longitude2, latitude2) * 1000

+ 4 - 1
src/pages.json

@@ -231,7 +231,10 @@
           "path": "task-visit/index",
           "style": {
             "navigationBarTitleText": "客户拜访",
-            "navigationStyle": "default"
+            "navigationStyle": "default",
+            "navigationBarBackgroundColor": "#FFFFFF",
+            "navigationBarTextStyle": "black",
+            "backgroundColor": "#F3F7FB"
           }
         },
         {

+ 1 - 1
src/pages/index/components/LivenessReminderDialog.vue

@@ -128,7 +128,7 @@ const handleGoAuth = async () => {
     realName: realname,
   })
 
-  if (res.code !== 0 || !res.data?.serialNo || !res.data?.faceUrl) return
+  if (!res.data?.serialNo || !res.data?.faceUrl) return
 
   show.value = false
 

+ 0 - 1
src/pages/index/components/WorkbenchTaskSection.vue

@@ -15,7 +15,6 @@
         class="workbench-task-item"
         :style="{
           backgroundImage: 'url(' + item.backgroundImage + ')',
-          color: item.color,
         }"
         @click="onSelectTask(item)"
       />

+ 31 - 34
src/pages/index/index.vue

@@ -204,46 +204,43 @@ const notifyUpcomingAgeLimit = () => {
     content: userStore?.currentUserInfo?.ageReminderInfo || '',
     async success(res) {
       if (res.cancel) {
-        const res = await stopSixtyReminderApi()
-        if (res.code === 0 && res.data) {
-          uni.showToast({
-            title: '不再弹出年龄提醒',
-            icon: 'none',
-          })
-        }
+        const response = await stopSixtyReminderApi()
+        if (!response.data) return
+
+        uni.showToast({
+          title: '不再弹出年龄提醒',
+          icon: 'none',
+        })
       }
     },
   })
 }
 const getPltQuizResult = async (): Promise<boolean> => {
   const res = await getQuizPltTestResultApi()
-
-  if (res.code === 0) {
-    const { required, testResults } = res.data
-    const hasInvalidResult = testResults.some((item) => !item.valid)
-    const flag = required && hasInvalidResult
-    if (flag) {
-      uni.showModal({
-        title: '提示',
-        cancelText: '暂不处理',
-        confirmText: '去测试',
-        content: '因平台合规升级,现需要您完成风险评估测试,请您认真作答。感谢您的配合!',
-        async success(res) {
-          if (res.confirm) {
-            uni.navigateTo({
-              url: '/pages-mine/compliance-evaluation/index?fromHome=home',
-            })
-          }
-          if (res.cancel) {
-            userStore.logout()
-            uni.reLaunch({
-              url: '/pages/login/index',
-            })
-          }
-        },
-      })
-      return false
-    }
+  const { required, testResults } = res.data
+  const hasInvalidResult = testResults.some((item) => !item.valid)
+  const flag = required && hasInvalidResult
+  if (flag) {
+    uni.showModal({
+      title: '提示',
+      cancelText: '暂不处理',
+      confirmText: '去测试',
+      content: '因平台合规升级,现需要您完成风险评估测试,请您认真作答。感谢您的配合!',
+      async success(res) {
+        if (res.confirm) {
+          uni.navigateTo({
+            url: '/pages-mine/compliance-evaluation/index?fromHome=home',
+          })
+        }
+        if (res.cancel) {
+          userStore.logout()
+          uni.reLaunch({
+            url: '/pages/login/index',
+          })
+        }
+      },
+    })
+    return false
   }
   return true
 }

+ 1 - 14
src/pages/information/components/academic/AcademicInfoPanel.vue

@@ -56,8 +56,6 @@ const currentPage = ref(1)
 const searchTitle = ref('')
 const searchArea = ref('')
 
-let latestRequestId = 0
-
 const buildRequestParams = (current: number): ArticlePageRequest => {
   const title = searchTitle.value.trim()
   const area = searchArea.value.trim()
@@ -74,8 +72,6 @@ const buildRequestParams = (current: number): ArticlePageRequest => {
 const getArticleList = async (current = 1, append = false): Promise<void> => {
   if (append && (loading.value || finished.value)) return
 
-  const requestId = ++latestRequestId
-
   if (!append) {
     finished.value = false
   }
@@ -85,8 +81,6 @@ const getArticleList = async (current = 1, append = false): Promise<void> => {
   try {
     const { data } = await getArticlePageApi(buildRequestParams(current))
 
-    if (requestId !== latestRequestId) return
-
     const records = Array.isArray(data?.records) ? data.records : []
     const page = Number(data?.current) || current
     const pages = Number(data?.pages) || 0
@@ -101,15 +95,8 @@ const getArticleList = async (current = 1, append = false): Promise<void> => {
     finished.value = records.length < PAGE_SIZE || isLastPage || isTotalLoaded
   } catch (error) {
     console.error('获取文章分页失败:', error)
-
-    uni.showToast({
-      title: '内容加载失败',
-      icon: 'none',
-    })
   } finally {
-    if (requestId === latestRequestId) {
-      loading.value = false
-    }
+    loading.value = false
   }
 }
 

+ 4 - 15
src/pages/information/components/album/ProductAlbumPanel.vue

@@ -68,8 +68,6 @@ const currentPage = ref(1)
 const keyword = ref('')
 const searchName = ref('')
 
-let latestRequestId = 0
-
 const buildRequestParams = (current: number) => {
   const xcmc = searchName.value.trim()
 
@@ -84,8 +82,6 @@ const buildRequestParams = (current: number) => {
 const getAlbumList = async (current = 1, append = false) => {
   if (append && (loading.value || finished.value)) return
 
-  const requestId = ++latestRequestId
-
   if (!append) {
     finished.value = false
   }
@@ -95,8 +91,6 @@ const getAlbumList = async (current = 1, append = false) => {
   try {
     const { data } = await getAlbumPageApi(buildRequestParams(current))
 
-    if (requestId !== latestRequestId) return
-
     const records = Array.isArray(data?.records) ? data.records : []
     const page = Number(data?.current) || current
     const pages = Number(data?.pages) || 0
@@ -111,15 +105,8 @@ const getAlbumList = async (current = 1, append = false) => {
     finished.value = records.length < PAGE_SIZE || isLastPage || isTotalLoaded
   } catch (error) {
     console.error('获取产品相册分页失败:', error)
-
-    uni.showToast({
-      title: '相册加载失败',
-      icon: 'none',
-    })
   } finally {
-    if (requestId === latestRequestId) {
-      loading.value = false
-    }
+    loading.value = false
   }
 }
 
@@ -183,7 +170,9 @@ const handleAlbumClick = (item: AlbumPageNewItem) => {
 }
 
 onMounted(refresh)
-onBeforeUnmount(debouncedSearch.cancel)
+onBeforeUnmount(() => {
+  debouncedSearch.cancel()
+})
 
 defineExpose({
   refresh,

+ 0 - 11
src/pages/information/components/shared/LoginRequiredState.vue

@@ -30,17 +30,6 @@ const props = withDefaults(defineProps<Props>(), {
   enableRedirect: true,
 })
 
-const getCurrentPageUrl = (): string => {
-  const pages = getCurrentPages()
-  const currentPage = pages[pages.length - 1]
-
-  if (!currentPage?.route) {
-    return ''
-  }
-
-  return `/${currentPage.route}`
-}
-
 const handleGoLogin = (): void => {
   uni.navigateTo({
     url: props.loginUrl,

+ 0 - 5
src/pages/information/detail/index.vue

@@ -35,7 +35,6 @@ import {
   shareArticleByIdApi,
 } from '@/services/modules/information'
 import type { AlbumPageNewItem, ArticleItem } from '@/services/modules/information/type'
-
 type DetailType = 'academic' | 'album'
 type DetailItem = ArticleItem | AlbumPageNewItem
 
@@ -95,10 +94,6 @@ const getDetail = async (id: string, query: PageQuery): Promise<void> => {
   } catch (error) {
     console.error('get detail failed:', error)
 
-    uni.showToast({
-      title: '详情加载失败',
-      icon: 'none',
-    })
   }
 }
 

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

@@ -238,9 +238,9 @@ const openShare = async (target: ShareTarget): Promise<void> => {
         : await getPackageListByTaskTypeAndAlbumIdApi(ALBUM_TASK_TYPE, target.id)
     const packageList = Array.isArray(res.data) ? res.data : []
 
-    if (res.code !== 0 || packageList.length === 0) {
+    if (packageList.length === 0) {
       uni.showToast({
-        title: res.msg || '暂无可用积分包',
+        title: '暂无可用积分包',
         icon: 'none',
       })
       shareTarget.value = null

+ 3 - 8
src/pages/login/index.vue

@@ -243,14 +243,9 @@ const sendCodeImpl = async () => {
   })
 
   if (res.access_token) {
-    const codeRes = await getMobileCodeApi(form.username)
-
-    if (codeRes.code === 0) {
-      toast('验证码发送成功')
-      startCountdown(60)
-    } else {
-      toast(codeRes.msg || '验证码发送失败')
-    }
+    await getMobileCodeApi(form.username)
+    toast('验证码发送成功')
+    startCountdown(60)
   }
 }
 

+ 6 - 13
src/pages/mine/index.vue

@@ -211,9 +211,7 @@ onShow(() => {
 const getInternalMsgCountUnread = async () => {
   try {
     const res = await getInternalMsgCountUnreadApi()
-    if (res.code === 0) {
-      hasUnreadNotification.value = res.data !== 0
-    }
+    hasUnreadNotification.value = res.data !== 0
   } catch (error) {
     console.error('获取未读消息数量失败:', error)
   }
@@ -269,16 +267,11 @@ const goOSAIS = async () => {
   if (!curr) return
 
   const res = await taxLocationEntranceApi(curr.subjectLocation)
-  if (res.code === 0 && res.data) {
-    uni.navigateTo({
-      url: `/pages-common/web-view/index?url=${encodeURIComponent(res.data)}&title=奥塞斯`,
-    })
-  } else {
-    uni.showToast({
-      title: '获取奥塞斯入口失败',
-      icon: 'none',
-    })
-  }
+  if (!res.data) return
+
+  uni.navigateTo({
+    url: `/pages-common/web-view/index?url=${encodeURIComponent(res.data)}&title=奥塞斯`,
+  })
 }
 
 const getRolesName = (roles: number[]) => {

+ 2 - 1
src/services/modules/auth/index.ts

@@ -3,6 +3,7 @@ import type {
   BatchChannelCertRequest,
   BatchChannelCertResponse,
   BindBankInfoRequest,
+  BindBaseInfoRequest,
   GetAuthInfoResponse,
   GigCertOcrRequest,
   GigCertOcrResponse,
@@ -38,7 +39,7 @@ export const personCheckFaceApi = (data: PersonCheckFaceRequest) => {
   return http.post(`/gig/cert/person-check-face`, data)
 }
 
-export const bindBaseInfoApi = (data: GigCertPersonVerifyFaceRequest) => {
+export const bindBaseInfoApi = (data: BindBaseInfoRequest) => {
   return http.post(`/admin/user-sign-cert/bind-base-info`, data)
 }
 

+ 3 - 1
src/services/modules/task/taskFrom/index.ts

@@ -50,7 +50,9 @@ export const checkAreaTypeApi = (deptId: string) => {
 }
 
 export const getListDrugTableApi = (packageId: string) => {
-  return http.get<string[]>(`/admin/wmdadrugentdrugtable/list-drug-table?packageId=${packageId}`)
+  return http.get<string[]>(
+    `/admin/wmdadrugentdrugtable/list-drug-table?packageId=${packageId}`
+  )
 }
 
 export const getQuestionnaireListApi = (packageId: string) => {

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

@@ -0,0 +1,39 @@
+import http from '../../../index'
+import type {
+  GetPointSignInfoRequest,
+  GetSignListByUserIdRequest,
+  GetVisitTemplateRequest,
+  GetVisitTemplateResult,
+  GetVisitTimeRequest,
+  IsVisitTimeRequest,
+  IsVisitTimeResult,
+  PointSignInfoItem,
+  SaveSignInfoRequest,
+  SaveSignInfoResult,
+  VisitSignRecordItem,
+  VisitTimeItem,
+} from './type'
+
+export const getSignListByUserIdApi = (params: GetSignListByUserIdRequest) => {
+  return http.get<VisitSignRecordItem[]>('/admin/api/getSignListByUserId', params)
+}
+
+export const getVisitTimeApi = (params: GetVisitTimeRequest) => {
+  return http.get<VisitTimeItem[]>('/admin/api/getVisitTime', params)
+}
+
+export const isVisitTimeApi = (params: IsVisitTimeRequest) => {
+  return http.get<IsVisitTimeResult>('/admin/api/isVisitTime', params)
+}
+
+export const getPointSignInfoApi = (params: GetPointSignInfoRequest) => {
+  return http.get<PointSignInfoItem[]>('/admin/api/getPointSignInfo', params)
+}
+
+export const getVisitTemplateApi = (params: GetVisitTemplateRequest) => {
+  return http.get<GetVisitTemplateResult>('/admin/dept-task-type-template/get-template', params)
+}
+
+export const saveSignInfoApi = (data: SaveSignInfoRequest) => {
+  return http.post<SaveSignInfoResult>('/admin/api/saveSignInfo', data)
+}

+ 82 - 0
src/services/modules/task/taskVisit/type.d.ts

@@ -0,0 +1,82 @@
+export interface GetSignListByUserIdRequest {
+  signUserid: string
+}
+
+export type VisitToType = 'HOSPITAL_VISIT' | 'BUSINESS_COMPANY_VISIT' | 'PHARMACY_VISIT'
+
+export interface GetVisitTimeRequest {
+  deptId: string
+  visitTo: VisitToType
+}
+
+export interface IsVisitTimeRequest {
+  deptId: string
+  visitTo: VisitToType
+}
+
+export type IsVisitTimeResult = boolean
+
+export interface GetVisitTemplateRequest {
+  deptId: string
+  taskTypeId: string
+}
+
+export interface GetVisitTemplateResult {
+  template: string
+}
+
+export interface GetPointSignInfoRequest {
+  latitude: number
+  longitude: number
+}
+
+export interface PointSignInfoItem {
+  address: string
+  latitude: string | number
+  longitude: string | number
+  signEntId: string | number
+  signEntName: string
+  signEntType: string | number
+}
+
+export interface SaveSignInfoRequest {
+  address: string
+  latitude: number
+  longitude: number
+  packageId: string
+  signDate: string
+  signEntId: string
+  signEntName: string
+  signEntType: string
+  signUserid: string
+  userSignDetail: Record<string, unknown>
+}
+
+export interface SaveSignInfoResult {
+  score?: number | string
+  type?: string
+}
+
+export interface VisitTimeItem {
+  date: string
+  choose: boolean
+  time: string[]
+}
+
+export interface VisitSignRecordItem {
+  id: number
+  signUserid: string
+  signEntId: string
+  signEntName: string
+  signEntType: string
+  signDate: string
+  taskTypeId: string
+  address: string | null
+  latitude: string | null
+  longitude: string | null
+  packageId: string | null
+  score: number | null
+  userSignDetail: VisitSignDetail | null
+}
+
+export type VisitSignDetail = Record<string, unknown>

+ 54 - 121
src/services/request/index.ts

@@ -1,114 +1,49 @@
-import { hideGlobalLoading, showGlobalLoading } from '@/utils/loading'
+import { dismissGlobalLoading, hideGlobalLoading, showGlobalLoading } from '@/utils/loading'
 
 import { TIMEOUT } from './config'
 import {
   buildHeaders,
-  getErrorMessage,
+  createRequestError,
   handleUnauthorized,
   isWhiteList,
-  normalizeHttpError,
-  resetUnauthorizedHandled,
   resolveUrl,
   setAuthHandlers,
+  showErrorModal,
 } from './utils'
 
-/* -------------------------------------------------------------------------- */
-/*                                   类型                                     */
-/* -------------------------------------------------------------------------- */
-
 export interface ApiResponse<T = any> {
   code: number
-  msg: string
+  msg: string | null
   data: T
 }
 
-export interface BaseOptions {
+interface RequestOptions {
   header?: UniApp.RequestOptions['header']
   timeout?: number
-  silent?: boolean
-  loading?: boolean // 是否显示 loading(默认 false)
-  loadingText?: string //  loading 文字描述
+  loading?: boolean
+  loadingText?: string
 }
 
-export interface RequestConfig extends BaseOptions {
+interface RequestConfig extends RequestOptions {
   url: string
   method?: UniApp.RequestOptions['method']
   data?: any
 }
 
-/* -------------------------------------------------------------------------- */
-/*                               核心 request                                  */
-/* -------------------------------------------------------------------------- */
-
-export function request<T = any>(config: RequestConfig): Promise<ApiResponse<T>> {
-  const needLoading = config.loading === true
-  if (needLoading) {
-    showGlobalLoading(config.loadingText)
+async function finishLoading(loading: boolean, failed = false) {
+  if (failed) {
+    await dismissGlobalLoading()
+  } else if (loading) {
+    await hideGlobalLoading()
   }
-
-  return new Promise((resolve, reject) => {
-    uni.request({
-      url: resolveUrl(config.url),
-      method: config.method ?? 'GET',
-      data: config.data,
-      timeout: config.timeout ?? TIMEOUT,
-      header: buildHeaders(config.url, config.header),
-
-      success(res) {
-        const statusCode = res.statusCode ?? 0
-        const body = res.data as ApiResponse<T>
-
-        if (isWhiteList(config.url)) {
-          return resolve(body)
-        }
-
-        if (statusCode === 200) {
-          return resolve(body)
-        }
-
-        if (statusCode === 401) {
-          handleUnauthorized()
-          return reject(normalizeHttpError(res, config.url))
-        }
-
-        if (!config.silent) {
-          uni.showModal({
-            title: getErrorMessage(body),
-            showCancel: false,
-          })
-        }
-
-        reject(normalizeHttpError(res, config.url))
-      },
-
-      fail(err) {
-        if (!config.silent) {
-          uni.showModal({
-            title: (err as any)?.errMsg || '网络异常,请稍后再试',
-            showCancel: false,
-          })
-        }
-        reject(err)
-      },
-      complete() {
-        if (needLoading) {
-          hideGlobalLoading()
-        }
-      },
-    })
-  })
 }
 
-/* -------------------------------------------------------------------------- */
-/*                              Raw request                                   */
-/* -------------------------------------------------------------------------- */
+function send<T>(config: RequestConfig, raw: true): Promise<T>
+function send<T>(config: RequestConfig, raw?: false): Promise<ApiResponse<T>>
+function send<T>(config: RequestConfig, raw = false): Promise<T | ApiResponse<T>> {
+  const loading = config.loading === true
 
-export function requestRaw<T = any>(config: RequestConfig): Promise<T> {
-  const needLoading = config.loading === true
-
-  if (needLoading) {
-    showGlobalLoading(config.loadingText)
-  }
+  if (loading) showGlobalLoading(config.loadingText)
 
   return new Promise((resolve, reject) => {
     uni.request({
@@ -118,69 +53,67 @@ export function requestRaw<T = any>(config: RequestConfig): Promise<T> {
       timeout: config.timeout ?? TIMEOUT,
       header: buildHeaders(config.url, config.header),
 
-      success(res) {
+      async success(res) {
         const statusCode = res.statusCode ?? 0
 
-        if (isWhiteList(config.url) || statusCode === 200) {
-          return resolve(res.data as T)
-        }
-
         if (statusCode === 401) {
+          await finishLoading(loading, true)
           handleUnauthorized()
-          return reject(normalizeHttpError(res, config.url))
+          reject(createRequestError(res.data, config.url, statusCode))
+          return
         }
 
-        if (!config.silent) {
-          uni.showModal({
-            title: getErrorMessage(res.data as any),
-            showCancel: false,
-          })
+        if (statusCode !== 200) {
+          await finishLoading(loading, true)
+          showErrorModal(res.data)
+          reject(createRequestError(res.data, config.url, statusCode))
+          return
         }
 
-        reject(normalizeHttpError(res, config.url))
-      },
+        if (raw) {
+          await finishLoading(loading)
+          resolve(res.data as T)
+          return
+        }
 
-      fail(err) {
-        if (!config.silent) {
-          uni.showModal({
-            title: (err as any)?.errMsg || '网络异常,请稍后再试',
-            showCancel: false,
-          })
+        const body = res.data as ApiResponse<T>
+        if (body?.code === 1 && !isWhiteList(config.url)) {
+          await finishLoading(loading, true)
+          showErrorModal(body)
+          reject(createRequestError(body, config.url, statusCode))
+          return
         }
-        reject(err)
+
+        await finishLoading(loading)
+        resolve(body)
       },
 
-      complete() {
-        if (needLoading) {
-          hideGlobalLoading()
-        }
+      async fail(error) {
+        await finishLoading(loading, true)
+        showErrorModal(error)
+        reject(createRequestError(error, config.url))
       },
     })
   })
 }
 
-/* -------------------------------------------------------------------------- */
-/*                               http 语法糖                                  */
-/* -------------------------------------------------------------------------- */
-
 const http = {
-  get<T = any>(url: string, params?: any, options?: BaseOptions) {
-    return request<T>({ url, method: 'GET', data: params, ...options })
+  get<T = any>(url: string, params?: any, options?: RequestOptions) {
+    return send<T>({ url, method: 'GET', data: params, ...options })
   },
 
-  post<T = any>(url: string, data?: any, options?: BaseOptions) {
-    return request<T>({ url, method: 'POST', data, ...options })
+  post<T = any>(url: string, data?: any, options?: RequestOptions) {
+    return send<T>({ url, method: 'POST', data, ...options })
   },
 
-  getRaw<T = any>(url: string, params?: any, options?: BaseOptions) {
-    return requestRaw<T>({ url, method: 'GET', data: params, ...options })
+  getRaw<T = any>(url: string, params?: any, options?: RequestOptions) {
+    return send<T>({ url, method: 'GET', data: params, ...options }, true)
   },
 
-  postRaw<T = any>(url: string, data?: any, options?: BaseOptions) {
-    return requestRaw<T>({ url, method: 'POST', data, ...options })
+  postRaw<T = any>(url: string, data?: any, options?: RequestOptions) {
+    return send<T>({ url, method: 'POST', data, ...options }, true)
   },
 }
 
+export { setAuthHandlers }
 export default http
-
-export { resetUnauthorizedHandled, setAuthHandlers }

+ 45 - 25
src/services/request/utils.ts

@@ -6,20 +6,23 @@ type AuthHandlers = {
 }
 
 let authHandlers: AuthHandlers = {}
-let unauthorizedHandled = false
+let unauthorizedModalVisible = false
+let errorModalVisible = false
+
+const BUSINESS_ERROR_WHITE_LIST = [
+  '/admin/api/saveTaskContent',
+  '/gig/cert/person-check-face',
+  '/gig/cert/person-verify-mobile3',
+]
 
 export function setAuthHandlers(handlers: AuthHandlers) {
   authHandlers = handlers
 }
 
-export function resetUnauthorizedHandled() {
-  unauthorizedHandled = false
-}
-
 export function handleUnauthorized() {
-  if (unauthorizedHandled) return
-  unauthorizedHandled = true
+  if (unauthorizedModalVisible) return
 
+  unauthorizedModalVisible = true
   authHandlers.onUnauthorized?.()
 
   uni.showModal({
@@ -27,9 +30,10 @@ export function handleUnauthorized() {
     content: '当前登录失效,请重新登录',
     showCancel: false,
     success() {
-      uni.reLaunch({
-        url: '/pages/login/index',
-      })
+      uni.reLaunch({ url: '/pages/login/index' })
+    },
+    complete() {
+      unauthorizedModalVisible = false
     },
   })
 }
@@ -48,14 +52,25 @@ export function resolveUrl(url: string) {
 }
 
 export function isWhiteList(url: string) {
-  return [].some((item) => url.includes(item))
+  return BUSINESS_ERROR_WHITE_LIST.some((item) => url.includes(item))
 }
 
-export function buildHeaders(url: string, extraHeader?: UniApp.RequestOptions['header']) {
-  const headers: Record<string, any> = {
-    ...(extraHeader || {}),
-  }
+export function showErrorModal(message: unknown) {
+  if (errorModalVisible) return
+
+  errorModalVisible = true
+  uni.showModal({
+    title: '提示',
+    content: getErrorMessage(message),
+    showCancel: false,
+    complete() {
+      errorModalVisible = false
+    },
+  })
+}
 
+export function buildHeaders(url: string, extraHeader?: UniApp.RequestOptions['header']) {
+  const headers: Record<string, unknown> = { ...(extraHeader || {}) }
   const accessToken = authHandlers.getAccessToken?.()
 
   if (url.includes('auth/mobile/token/sms')) {
@@ -70,19 +85,24 @@ export function buildHeaders(url: string, extraHeader?: UniApp.RequestOptions['h
   return headers
 }
 
-export function getErrorMessage(body: unknown) {
+function getErrorMessage(body: unknown) {
+  if (typeof body === 'string' && body.trim()) return body
+
   if (body && typeof body === 'object') {
-    const msg = (body as any).msg
-    if (typeof msg === 'string' && msg.trim()) return msg
+    const errorBody = body as Record<string, unknown>
+    for (const key of ['msg', 'message', 'errMsg', 'error']) {
+      const message = errorBody[key]
+      if (typeof message === 'string' && message.trim()) return message
+    }
   }
-  return '网络异常,请稍后再试'
+
+  return '请求失败,请稍后重试'
 }
 
-export function normalizeHttpError(res: UniApp.RequestSuccessCallbackResult, url: string) {
-  return {
+export function createRequestError(body: unknown, url: string, statusCode?: number) {
+  return Object.assign(new Error(getErrorMessage(body)), {
     url,
-    statusCode: res.statusCode ?? 0,
-    data: res.data,
-    header: res.header,
-  }
+    statusCode,
+    data: body,
+  })
 }

BIN
src/static/images/common/map-marker.png


+ 3 - 9
src/stores/modules/user.ts

@@ -9,7 +9,6 @@ import type {
   LoginRequest,
 } from '@/services/modules/login/type'
 import type { UserInfoItem } from '@/services/modules/login/userInfo'
-import { resetUnauthorizedHandled } from '@/services/request'
 
 import { pinia } from '@/stores/index'
 
@@ -70,8 +69,6 @@ export const useUserStore = defineStore('user', {
       // 登录成功后立即重置,保证本次登录重新具备弹窗资格
       this.resetLivenessReminderShown()
 
-      resetUnauthorizedHandled()
-
       await this.getUserInfoByCode()
 
       return {
@@ -84,7 +81,7 @@ export const useUserStore = defineStore('user', {
       if (!wxRes?.code) return undefined
 
       const userInfoResult = await getUserInfoByCodeApi(wxRes.code)
-      if (userInfoResult.code !== 0 || !userInfoResult.data) {
+      if (!userInfoResult.data) {
         return undefined
       }
 
@@ -113,11 +110,8 @@ export const useUserStore = defineStore('user', {
 
     async fetchUserBlacklistStatus(payload: CheckBlacklistRequest) {
       const res = await checkBlacklistApi(payload)
-
-      if (res.code === 0) {
-        const { idCardNumberStatus, phoneNumberStatus } = res.data
-        this.isBlacklisted = idCardNumberStatus || phoneNumberStatus
-      }
+      const { idCardNumberStatus, phoneNumberStatus } = res.data
+      this.isBlacklisted = idCardNumberStatus || phoneNumberStatus
     },
 
     setCurrentUserInfoIndex(index: number) {

+ 0 - 2
src/themes/index.ts

@@ -1,6 +1,4 @@
 import { defaultTheme } from './default'
-import { luoxinTheme } from './luoxin'
-
 function resolveTheme() {
   // const mode = import.meta.env.VITE_MODE
 

+ 0 - 21
src/types/uqrcodejs.d.ts

@@ -1,21 +0,0 @@
-declare module 'uqrcodejs' {
-  export default class UQRCode {
-    static errorCorrectLevel: {
-      L: number
-      M: number
-      Q: number
-      H: number
-    }
-
-    data: string
-    size: number
-    margin: number
-    errorCorrectLevel: number
-    backgroundColor: string
-    foregroundColor: string
-    canvasContext: UniApp.CanvasContext
-
-    make(): void
-    drawCanvas(reserve?: boolean): Promise<void>
-  }
-}

+ 21 - 0
src/utils/latestTask.ts

@@ -0,0 +1,21 @@
+export interface LatestTaskController {
+  invalidate: () => void
+  isLatest: (token: number) => boolean
+  next: () => number
+}
+
+/**
+ * 适用于非 HTTP 异步任务(SDK、Canvas 等)的 latest-only 控制。
+ * 仅用于确实需要忽略过期结果的 SDK、Canvas 等非 HTTP 异步任务。
+ */
+export function createLatestTaskController(): LatestTaskController {
+  let version = 0
+
+  return {
+    next: () => ++version,
+    invalidate: () => {
+      version += 1
+    },
+    isLatest: (token) => token === version,
+  }
+}

+ 66 - 5
src/utils/loading.ts

@@ -1,20 +1,81 @@
 let loadingCount = 0
+let loadingVisible = false
+let loadingClosing = false
+let loadingTitle = ''
+let idleWaiters: Array<() => void> = []
+
+const resolveIdleWaiters = () => {
+  const waiters = idleWaiters
+  idleWaiters = []
+  waiters.forEach((resolve) => resolve())
+}
+
+const closeNativeLoading = () => {
+  if (!loadingVisible || loadingClosing) return
+
+  loadingClosing = true
+  uni.hideLoading()
+
+  // 给小程序原生 Loading 留出关闭时间,避免紧随其后的错误弹窗被遮挡。
+  setTimeout(() => {
+    loadingClosing = false
+
+    if (loadingCount > 0) {
+      uni.showLoading({ title: loadingTitle, mask: true })
+      return
+    }
+
+    loadingVisible = false
+    resolveIdleWaiters()
+  }, 50)
+}
 
 export function showGlobalLoading(title = '') {
-  if (loadingCount === 0) {
+  loadingTitle = title
+
+  if (!loadingVisible) {
+    loadingVisible = true
     uni.showLoading({
       title,
       mask: true,
     })
   }
+
   loadingCount++
 }
 
-export function hideGlobalLoading() {
-  if (loadingCount <= 0) return
+export function waitForGlobalLoading(): Promise<void> {
+  if (loadingCount === 0 && !loadingVisible) return Promise.resolve()
+
+  return new Promise((resolve) => {
+    idleWaiters.push(resolve)
+  })
+}
+
+export function hideGlobalLoading(): Promise<void> {
+  if (loadingCount > 0) {
+    loadingCount--
+  }
 
-  loadingCount--
   if (loadingCount === 0) {
-    uni.hideLoading()
+    closeNativeLoading()
+  }
+
+  return waitForGlobalLoading()
+}
+
+/**
+ * 错误提示优先于页面 Loading:无论 Loading 是否由请求层创建,都先关闭再展示弹窗。
+ */
+export function dismissGlobalLoading(): Promise<void> {
+  loadingCount = 0
+
+  if (loadingVisible) {
+    closeNativeLoading()
+    return waitForGlobalLoading()
   }
+
+  // 兼容页面中尚未迁移到全局工具的 uni.showLoading 调用。
+  uni.hideLoading()
+  return new Promise((resolve) => setTimeout(resolve, 50))
 }

+ 0 - 1
tsconfig.json

@@ -10,7 +10,6 @@
       "@dcloudio/types",
       "@uni-helper/uni-app-types",
       "@uni-helper/uni-ui-types",
-      "@wot-ui/ui/global",
       "miniprogram-api-typings"
     ]
   },

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.