Procházet zdrojové kódy

重构表单页面

yuanmingze před 3 týdny
rodič
revize
56e09d31ed
24 změnil soubory, kde provedl 530 přidání a 347 odebrání
  1. 32 14
      src/pages-task/task-form/components/DataTimeRange.vue
  2. 12 8
      src/pages-task/task-form/components/DateTimePicker.vue
  3. 33 16
      src/pages-task/task-form/components/DynamicTaskFormFields.vue
  4. 59 28
      src/pages-task/task-form/components/ImgUpload.vue
  5. 7 10
      src/pages-task/task-form/components/InputAutoSelect.vue
  6. 1 3
      src/pages-task/task-form/components/LongText.vue
  7. 6 3
      src/pages-task/task-form/components/MultipleSelect.vue
  8. 53 5
      src/pages-task/task-form/components/QuestionnaireTask.vue
  9. 1 3
      src/pages-task/task-form/components/Sign.vue
  10. 10 72
      src/pages-task/task-form/components/SingleSelect.vue
  11. 1 1
      src/pages-task/task-form/components/SingleText.vue
  12. 2 4
      src/pages-task/task-form/components/TaskLocation.vue
  13. 0 13
      src/pages-task/task-form/components/select-option.ts
  14. 18 74
      src/pages-task/task-form/composables/useTaskFieldType.ts
  15. 76 15
      src/pages-task/task-form/composables/useTaskForm.ts
  16. 36 17
      src/pages-task/task-form/composables/useTaskFormAction.ts
  17. 5 3
      src/pages-task/task-form/composables/useTaskFormFields.ts
  18. 19 1
      src/pages-task/task-form/composables/useTaskFormPageFeatures.ts
  19. 22 3
      src/pages-task/task-form/composables/useTaskFormSafeArea.ts
  20. 52 35
      src/pages-task/task-form/composables/useTaskSelectHandlers.ts
  21. 70 7
      src/pages-task/task-form/index.vue
  22. 9 1
      src/pages-task/task-form/utils/questionnaire.ts
  23. 4 7
      src/pages/login/index.vue
  24. 2 4
      src/services/modules/task/taskFrom/index.ts

+ 32 - 14
src/pages-task/task-form/components/DataTimeRange.vue

@@ -6,7 +6,7 @@
       :title="taskFieldConfig.taskFiledValue"
       :required="taskFieldConfig.isMustfill === '1'"
       :value="cellValue"
-      :is-link="!isDisabled"
+      :is-link="!props.disabled"
       @click="calendarClick"
     />
 
@@ -51,34 +51,52 @@ const label = defineModel<FieldModelValue>('label', { default: '' })
 const calendarShow = ref(false)
 const calendarValue = ref<number[]>([])
 
-const isDisabled = computed(() => props.disabled)
-
 const cellValue = computed(() => label.value || value.value || '')
 
+const clearInvalidRange = () => {
+  calendarValue.value = []
+  value.value = ''
+  label.value = ''
+}
+
 watch(
   value,
   (newValue) => {
-    if (!newValue) {
+    const rangeText = newValue || label.value
+
+    if (!rangeText) {
       calendarValue.value = []
       return
     }
 
-    const [startDate, endDate] = newValue.split(RANGE_SEPARATOR)
+    const dateParts = rangeText.split(RANGE_SEPARATOR).map((item) => item.trim())
+    const [startDate, endDate] = dateParts
 
-    if (!startDate || !endDate) {
-      calendarValue.value = []
+    if (dateParts.length !== 2 || !startDate || !endDate) {
+      clearInvalidRange()
       return
     }
 
-    const startTimestamp = dayjs(startDate, DATE_FORMAT).valueOf()
-    const endTimestamp = dayjs(endDate, DATE_FORMAT).valueOf()
-
-    if (!Number.isFinite(startTimestamp) || !Number.isFinite(endTimestamp)) {
-      calendarValue.value = []
+    const start = dayjs(startDate)
+    const end = dayjs(endDate)
+
+    if (
+      !start.isValid() ||
+      !end.isValid() ||
+      start.isAfter(end) ||
+      start.format(DATE_FORMAT) !== startDate ||
+      end.format(DATE_FORMAT) !== endDate
+    ) {
+      clearInvalidRange()
       return
     }
 
-    calendarValue.value = [startTimestamp, endTimestamp]
+    const normalizedRange = `${startDate}${RANGE_SEPARATOR}${endDate}`
+
+    calendarValue.value = [start.valueOf(), end.valueOf()]
+
+    if (value.value !== normalizedRange) value.value = normalizedRange
+    if (label.value !== normalizedRange) label.value = normalizedRange
   },
   {
     immediate: true,
@@ -86,7 +104,7 @@ watch(
 )
 
 const calendarClick = () => {
-  if (isDisabled.value) return
+  if (props.disabled) return
 
   calendarShow.value = true
 }

+ 12 - 8
src/pages-task/task-form/components/DateTimePicker.vue

@@ -6,7 +6,7 @@
       :title="taskFieldConfig.taskFiledValue"
       :required="taskFieldConfig.isMustfill === '1'"
       :value="cellValue"
-      :is-link="!isDisabled"
+      :is-link="!props.disabled"
       @click="handlePickerOpen"
     />
 
@@ -55,11 +55,7 @@ const label = defineModel<FieldValue>('label', { default: '' })
 const datetimePickerVisible = ref(false)
 const datetimePickerValue = ref(createCurrentTimestamp())
 
-const isDisabled = computed(() => props.disabled)
-
-const cellValue = computed(() => {
-  return typeof label.value === 'string' ? label.value : ''
-})
+const cellValue = computed(() => label.value)
 
 /**
  * 当前时间统一精确到分钟。
@@ -131,6 +127,10 @@ function parseDateTime(rawValue: unknown): Dayjs | null {
     return standardDate
   }
 
+  if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/.test(normalizedValue)) {
+    return null
+  }
+
   /**
    * 兼容旧数据中的其他日期格式。
    */
@@ -166,11 +166,15 @@ function updateDateTime(date: Dayjs): void {
 watch(
   value,
   (newValue) => {
-    const parsedDate = parseDateTime(newValue)
+    const parsedDate = parseDateTime(newValue) ?? (!newValue ? parseDateTime(label.value) : null)
 
     if (!parsedDate) {
       datetimePickerValue.value = createCurrentTimestamp()
 
+      if (value.value !== '') {
+        value.value = ''
+      }
+
       if (label.value !== '') {
         label.value = ''
       }
@@ -198,7 +202,7 @@ watch(
 )
 
 function handlePickerOpen(): void {
-  if (isDisabled.value) {
+  if (props.disabled) {
     return
   }
 

+ 33 - 16
src/pages-task/task-form/components/DynamicTaskFormFields.vue

@@ -1,13 +1,13 @@
 <template>
   <view class="task-form-fields">
-    <template v-for="item in taskFieldConfigList" :key="item.id">
+    <template v-for="item in taskFieldConfigList" :key="`${activeTaskTypeId}:${item.id}`">
       <view v-if="shouldRenderField(item) && form.value[item.id]" class="task-form-field">
         <SingleSelect
           v-if="isSingleSelect(item.taskFiledType)"
           v-model:label="form.value[item.id].label"
           v-model:value="form.value[item.id].value"
           :task-field-config="item"
-          :disabled="item.readonly"
+          :disabled="isFieldDisabled(item)"
           :before-open="() => handleSingleSelectBeforeOpen(item)"
           @change="handleTaskSingleSelectChange(item, $event)"
         />
@@ -17,9 +17,8 @@
           v-model:label="form.value[item.id].label"
           v-model:value="form.value[item.id].value"
           :task-field-config="item"
-          :disabled="item.readonly"
+          :disabled="isFieldDisabled(item)"
           :before-open="() => handleSingleSelectBeforeOpen(item)"
-          @change="handleSingleSelectChange(item, $event)"
         />
 
         <SingleText
@@ -27,6 +26,7 @@
           v-model:label="form.value[item.id].label"
           v-model:value="form.value[item.id].value"
           :task-field-config="item"
+          :disabled="props.disabled"
           :readonly="item.readonly || isReadonlyText(item.taskFiledType)"
         />
 
@@ -35,7 +35,7 @@
           v-model:label="form.value[item.id].label"
           v-model:value="form.value[item.id].value"
           :task-field-config="item"
-          :disabled="item.readonly"
+          :disabled="isFieldDisabled(item)"
         />
 
         <TaskLocation
@@ -43,7 +43,7 @@
           v-model:label="form.value[item.id].label"
           v-model:value="form.value[item.id].value"
           :task-field-config="item"
-          :disabled="item.readonly"
+          :disabled="isFieldDisabled(item)"
         />
 
         <template v-else-if="isImgUpload(item.taskFiledType)">
@@ -53,7 +53,7 @@
             v-model:label="form.value[item.id].label"
             v-model:value="form.value[item.id].value"
             :task-field-config="item"
-            :disabled="item.readonly"
+            :disabled="isFieldDisabled(item)"
           />
         </template>
 
@@ -64,7 +64,7 @@
             v-model:label="form.value[item.id].label"
             v-model:value="form.value[item.id].value"
             :task-field-config="item"
-            :disabled="item.readonly"
+            :disabled="isFieldDisabled(item)"
           />
         </template>
 
@@ -73,7 +73,7 @@
           v-model:label="form.value[item.id].label"
           v-model:value="form.value[item.id].value"
           :task-field-config="item"
-          :disabled="item.readonly"
+          :disabled="isFieldDisabled(item)"
           :before-open="() => handleMultipleSelectBeforeOpen(item)"
         />
 
@@ -82,7 +82,7 @@
           v-model:label="form.value[item.id].label"
           v-model:value="form.value[item.id].value"
           :task-field-config="item"
-          :disabled="item.readonly"
+          :disabled="isFieldDisabled(item)"
         />
 
         <template v-else-if="isLongText(item.taskFiledType)">
@@ -92,7 +92,7 @@
             v-model:label="form.value[item.id].label"
             v-model:value="form.value[item.id].value"
             :task-field-config="item"
-            :disabled="item.readonly"
+            :disabled="isFieldDisabled(item)"
           />
 
           <view class="line" />
@@ -103,9 +103,13 @@
           v-model:label="form.value[item.id].label"
           v-model:value="form.value[item.id].value"
           :task-field-config="item"
-          :disabled="item.readonly"
+          :disabled="isFieldDisabled(item)"
           :select-level="selectLevel"
         />
+
+        <view v-else-if="!isSupported(item.taskFiledType)" class="unsupported-field">
+          {{ item.taskFiledValue }}:暂不支持字段类型“{{ item.taskFiledType }}”
+        </view>
       </view>
     </template>
   </view>
@@ -127,13 +131,11 @@ import { useTaskFieldType } from '../composables/useTaskFieldType'
 import type {
   DerivedFieldChangeEvent,
   DynamicFormState,
+  SingleSelectChangeEvent,
   TaskFieldConfigViewItem,
 } from '../composables/useTaskForm'
 import type { AreaSelectLevel } from '../composables/useTaskFormPageFeatures'
-import {
-  type SingleSelectChangeEvent,
-  useTaskSelectHandlers,
-} from '../composables/useTaskSelectHandlers'
+import { useTaskSelectHandlers } from '../composables/useTaskSelectHandlers'
 import Area from './Area.vue'
 import DataTimeRange from './DataTimeRange.vue'
 import DateTimePicker from './DateTimePicker.vue'
@@ -152,11 +154,13 @@ const props = withDefaults(
     activeTaskTypeId: string
     taskTypes: TaskTypeItem[]
     selectLevel: AreaSelectLevel
+    disabled?: boolean
     hiddenFieldTypes?: string[]
     hiddenFieldKeys?: string[]
     hiddenFieldAliases?: string[]
   }>(),
   {
+    disabled: false,
     hiddenFieldTypes: () => [],
     hiddenFieldKeys: () => [],
     hiddenFieldAliases: () => [],
@@ -203,6 +207,7 @@ const {
   isDateTimeRange,
   isLongText,
   isArea,
+  isSupported,
 } = useTaskFieldType()
 
 const shouldRenderField = (field: TaskFieldConfigViewItem) => {
@@ -214,6 +219,10 @@ const shouldRenderField = (field: TaskFieldConfigViewItem) => {
   )
 }
 
+const isFieldDisabled = (field: TaskFieldConfigViewItem): boolean => {
+  return props.disabled || field.readonly
+}
+
 const handleTaskSingleSelectChange = (
   field: TaskFieldConfigViewItem,
   event: SingleSelectChangeEvent
@@ -251,4 +260,12 @@ const handleTaskSingleSelectChange = (
   height: 20rpx;
   background: #f4f4f4;
 }
+
+.unsupported-field {
+  padding: 28rpx;
+  color: #b45309;
+  font-size: 28rpx;
+  line-height: 40rpx;
+  background: #fff8ec;
+}
 </style>

+ 59 - 28
src/pages-task/task-form/components/ImgUpload.vue

@@ -94,6 +94,7 @@ const uploading = ref(false)
 const watermarkAddress = ref('')
 const watermarkCanvasWidth = ref(1)
 const watermarkCanvasHeight = ref(1)
+let watermarkAddressPromise: Promise<void> | null = null
 
 const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
 
@@ -111,15 +112,11 @@ const watermarkCanvasStyle = computed<CSSProperties>(() => {
 })
 
 const minCount = computed(() => {
-  return toSafeNumber(props.taskFieldConfig.taskFiledMinsize, 0)
+  return toNonNegativeInteger(props.taskFieldConfig.taskFiledMinsize, 0)
 })
 
 const maxCount = computed(() => {
-  const config = props.taskFieldConfig as TaskFieldConfigItem & {
-    taskFiledMaxsize?: string | number | null
-  }
-
-  return toSafeNumber(config.taskFiledMaxsize, 20)
+  return toNonNegativeInteger(props.taskFieldConfig.taskFiledMaxsize, 20)
 })
 
 const description = computed(() => {
@@ -151,18 +148,23 @@ const canUpload = computed(() => {
 })
 
 onMounted(() => {
-  initWatermarkAddress()
+  void initWatermarkAddress()
 })
 
-const initWatermarkAddress = async () => {
-  if (props.watermarkText) return
+const initWatermarkAddress = (): Promise<void> => {
+  if (props.watermarkText) return Promise.resolve()
 
-  try {
-    const result = (await getLocation()) as LocationResult
-    watermarkAddress.value = result.result?.address ?? ''
-  } catch (error) {
-    console.warn('[ImgUpload] get location failed:', error)
-  }
+  watermarkAddressPromise ??= (async () => {
+    try {
+      const result = (await getLocation()) as LocationResult
+      watermarkAddress.value = result.result?.address ?? ''
+    } catch (error) {
+      watermarkAddressPromise = null
+      console.warn('[ImgUpload] get location failed:', error)
+    }
+  })()
+
+  return watermarkAddressPromise
 }
 
 const chooseImage = async () => {
@@ -178,9 +180,20 @@ const chooseImage = async () => {
 
   uploading.value = true
   let loadingVisible = false
+  const hideUploadLoading = () => {
+    if (!loadingVisible) return
+
+    uni.hideLoading()
+    loadingVisible = false
+  }
 
   try {
     const source = await chooseUploadSource()
+
+    if (source === 'camera') {
+      await initWatermarkAddress()
+    }
+
     const count = source === 'camera' ? 1 : Math.min(remainingCount.value, 9)
     const filePaths = await chooseImages(count, source)
 
@@ -193,23 +206,40 @@ const chooseImage = async () => {
     loadingVisible = true
 
     const uploadedUrls: string[] = []
+    let failedCount = 0
 
     for (const filePath of filePaths) {
-      const uploadFilePath = source === 'camera' ? await addWatermark(filePath) : filePath
-      const uploadedUrl = await upload(uploadFilePath)
-      uploadedUrls.push(appendUploadSourceSuffix(uploadedUrl, source))
+      try {
+        const uploadFilePath = source === 'camera' ? await addWatermark(filePath) : filePath
+        const uploadedUrl = await upload(uploadFilePath)
+
+        uploadedUrls.push(appendUploadSourceSuffix(uploadedUrl, source))
+      } catch (error) {
+        failedCount += 1
+        console.error('[ImgUpload] 单张图片上传失败', error)
+      }
     }
 
-    imageList.value = [...imageList.value, ...uploadedUrls].slice(0, maxCount.value)
+    if (uploadedUrls.length) {
+      imageList.value = [...imageList.value, ...uploadedUrls].slice(0, maxCount.value)
+    }
 
-    uni.showToast({
-      title: '上传成功',
-      icon: 'success',
-    })
+    const allUploaded = failedCount === 0
+    let resultTitle = '上传成功'
+
+    if (!allUploaded) {
+      resultTitle = uploadedUrls.length
+        ? `${uploadedUrls.length}张成功,${failedCount}张失败`
+        : '上传失败'
+    }
+
+    hideUploadLoading()
+    uni.showToast({ title: resultTitle, icon: allUploaded ? 'success' : 'none' })
   } catch (error) {
     if (isCancelError(error)) return
 
     console.error('[ImgUpload] upload failed:', error)
+    hideUploadLoading()
 
     uni.showToast({
       title: '上传失败',
@@ -217,10 +247,7 @@ const chooseImage = async () => {
     })
   } finally {
     uploading.value = false
-
-    if (loadingVisible) {
-      uni.hideLoading()
-    }
+    hideUploadLoading()
   }
 }
 
@@ -533,7 +560,11 @@ const padNumber = (value: number) => {
   return String(value).padStart(2, '0')
 }
 
-const toSafeNumber = (value: unknown, fallback: number) => {
+const toNonNegativeInteger = (value: unknown, fallback: number) => {
+  if (value === undefined || value === null || (typeof value === 'string' && value.trim() === '')) {
+    return fallback
+  }
+
   const numberValue = Number(value)
 
   if (!Number.isFinite(numberValue) || numberValue < 0) {

+ 7 - 10
src/pages-task/task-form/components/InputAutoSelect.vue

@@ -5,17 +5,18 @@
       :placeholder="'请选择' + taskFieldConfig.taskFiledValue"
       :title="taskFieldConfig.taskFiledValue"
       :required="taskFieldConfig.isMustfill === '1'"
-      :value="cellValue"
-      :is-link="!isDisabled"
+      :value="label"
+      :is-link="!props.disabled"
       @click="pickerClick"
     />
   </view>
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { ref } from 'vue'
 
 import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
   disabled?: boolean
@@ -26,17 +27,11 @@ type FieldValue = string
 
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
-  beforeOpen: undefined,
 })
 
 const label = defineModel<FieldValue>('label', { default: '' })
 defineModel<FieldValue>('value', { default: '' })
 
-const cellValue = computed(() => {
-  return label.value === undefined ? '' : String(label.value)
-})
-
-const isDisabled = computed(() => props.disabled)
 const opening = ref(false)
 
 const pickerClick = async () => {
@@ -52,9 +47,11 @@ const pickerClick = async () => {
     if (canOpen === false) {
       return
     }
+
+    uni.showToast({ title: '该字段暂无可选内容', icon: 'none' })
   } catch (error) {
     console.error(`打开单选字段失败:${props.taskFieldConfig.taskFiledValue}`, error)
-
+    uni.showToast({ title: '选项加载失败,请重试', icon: 'none' })
   } finally {
     opening.value = false
   }

+ 1 - 3
src/pages-task/task-form/components/LongText.vue

@@ -31,12 +31,10 @@ type FieldValue = string
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
   disabled?: boolean
-  watermarkText?: string
 }
 
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
-  watermarkText: '',
 })
 
 const value = defineModel<FieldValue>('value', {
@@ -65,7 +63,7 @@ const textareaValue = computed({
   },
 })
 
-const currentLength = computed(() => textareaValue.value.length)
+const currentLength = computed(() => Array.from(textareaValue.value).length)
 </script>
 
 <style lang="scss" scoped>

+ 6 - 3
src/pages-task/task-form/components/MultipleSelect.vue

@@ -108,7 +108,11 @@ const syncPickerValueByModelValue = () => {
 
   pickerValue.value = matchedValues
 
-  syncLabelByValue(matchedValues)
+  if (!normalizedValues.length) {
+    label.value = ''
+  } else if (matchedValues.length === normalizedValues.length) {
+    syncLabelByValue(matchedValues)
+  }
 }
 
 /**
@@ -124,7 +128,6 @@ watch(
   },
   {
     immediate: true,
-    deep: true,
   }
 )
 
@@ -163,7 +166,7 @@ const pickerClick = async () => {
     pickerShow.value = true
   } catch (error) {
     console.error(`打开多选字段失败:${props.taskFieldConfig.taskFiledValue}`, error)
-
+    uni.showToast({ title: '选项加载失败,请重试', icon: 'none' })
   } finally {
     opening.value = false
   }

+ 53 - 5
src/pages-task/task-form/components/QuestionnaireTask.vue

@@ -102,7 +102,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, nextTick, onMounted, ref } from 'vue'
+import { computed, nextTick, onMounted, onScopeDispose, ref } from 'vue'
 
 import type { QrCodeInstance } from '@wot-ui/ui/components/wd-qr-code/types'
 
@@ -167,8 +167,26 @@ const qrCodeRef = ref<QrCodeInstance | null>(null)
 const qrCodeError = ref<unknown>(null)
 
 const qrCodeGenerationTask = createLatestTaskController()
+let questionnaireRequestVersion = 0
 let lastQrContent = ''
 let pendingQrCodeGeneration: QrCodeGenerationRequest | null = null
+let disposed = false
+let qrGlobalLoadingActive = false
+
+const releaseQrGlobalLoading = () => {
+  if (!qrGlobalLoadingActive) return
+
+  qrGlobalLoadingActive = false
+  void hideGlobalLoading()
+}
+
+onScopeDispose(() => {
+  disposed = true
+  questionnaireRequestVersion += 1
+  qrCodeGenerationTask.invalidate()
+  pendingQrCodeGeneration = null
+  releaseQrGlobalLoading()
+})
 
 const packageFieldLabel = computed(() => packageField.value?.taskFiledValue || '关联积分包')
 
@@ -193,6 +211,9 @@ const loadTaskConfig = async () => {
 
   try {
     const response = await getQuestionnaireTaskContentConfigApi()
+
+    if (disposed) return
+
     const taskConfig = response.data[TASK_TYPE_ID]
     const scorePackageField = taskConfig.config.find(
       (field) => field.alias === 'scorePackage' || field.taskFiledKey === 'temp32'
@@ -217,10 +238,14 @@ const loadTaskConfig = async () => {
       throw new Error('暂无可用的关联积分包')
     }
   } catch (error) {
+    if (disposed) return
+
     console.error('[QuestionnaireTask] 加载 801 配置失败', error)
     configError.value = error instanceof Error ? error.message : '任务配置加载失败'
   } finally {
-    configLoading.value = false
+    if (!disposed) {
+      configLoading.value = false
+    }
   }
 }
 
@@ -249,6 +274,7 @@ const handlePackageConfirm = (event: PickerConfirmEvent) => {
 }
 
 const resetQuestionnaire = () => {
+  questionnaireRequestVersion += 1
   questionnaireList.value = []
   questionnaireLoading.value = false
   questionnairePickerVisible.value = false
@@ -259,19 +285,36 @@ 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 {
-    questionnaireLoading.value = false
+    if (requestVersion === questionnaireRequestVersion) {
+      questionnaireLoading.value = false
+    }
   }
 }
 
@@ -326,6 +369,8 @@ const clearQrCode = () => {
 }
 
 const generateQrCode = async (questionnaireUrl: string, usesQuestionnaireList: boolean) => {
+  if (disposed) return
+
   const packageId = selectedPackageId.value
 
   if (!packageId) {
@@ -360,6 +405,7 @@ const generateQrCode = async (questionnaireUrl: string, usesQuestionnaireList: b
   qrCodeContent.value = qrContent
   qrCodeLoading.value = true
   showGlobalLoading('二维码生成中')
+  qrGlobalLoadingActive = true
 
   try {
     await nextTick()
@@ -390,12 +436,12 @@ const generateQrCode = async (questionnaireUrl: string, usesQuestionnaireList: b
     uni.showToast({ title: '二维码生成失败,请重试', icon: 'none' })
   } finally {
     qrCodeLoading.value = false
-    hideGlobalLoading()
+    releaseQrGlobalLoading()
 
     const pendingRequest = pendingQrCodeGeneration
     pendingQrCodeGeneration = null
 
-    if (pendingRequest) {
+    if (pendingRequest && !disposed) {
       void generateQrCode(pendingRequest.questionnaireUrl, pendingRequest.usesQuestionnaireList)
     }
   }
@@ -408,6 +454,8 @@ const waitForLoadingPaint = (): Promise<void> => {
 }
 
 const handleQrCodeError = (error: unknown) => {
+  if (disposed) return
+
   qrCodeError.value = error
 }
 </script>

+ 1 - 3
src/pages-task/task-form/components/Sign.vue

@@ -29,12 +29,10 @@ type FieldValue = string
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
   disabled?: boolean
-  watermarkText?: string
 }
 
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
-  watermarkText: '',
 })
 
 const value = defineModel<FieldValue>('value', {
@@ -49,7 +47,7 @@ const handleSignClick = () => {
   if (props.disabled) return
 
   uni.navigateTo({
-    url: `/pages-common/signature/index?type=TASK_FORM_SIGN`,
+    url: '/pages-common/signature/index?type=TASK_FORM_SIGN',
     events: {
       taskFormSign: (imgUrl: string) => {
         label.value = imgUrl

+ 10 - 72
src/pages-task/task-form/components/SingleSelect.vue

@@ -22,7 +22,11 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 
-import type { TaskFieldConfigViewItem, TaskFieldOption } from '../composables/useTaskForm'
+import type {
+  SingleSelectChangeEvent,
+  TaskFieldConfigViewItem,
+  TaskFieldOption,
+} from '../composables/useTaskForm'
 
 type FieldValue = string
 
@@ -30,28 +34,6 @@ interface PickerConfirmEvent {
   selectedItems: TaskFieldOption[]
 }
 
-export interface SingleSelectChangeEvent {
-  /**
-   * 修改前的值。
-   */
-  previousValue: FieldValue
-
-  /**
-   * 修改后的值。
-   */
-  value: string
-
-  /**
-   * 修改后的显示文本。
-   */
-  label: string
-
-  /**
-   * 当前选中的完整选项。
-   */
-  selectedItem: TaskFieldOption
-}
-
 interface Props {
   taskFieldConfig: TaskFieldConfigViewItem
   disabled?: boolean
@@ -67,7 +49,6 @@ interface Props {
 
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
-  beforeOpen: undefined,
 })
 
 const emit = defineEmits<{
@@ -84,29 +65,17 @@ const pickerShow = ref(false)
 const opening = ref(false)
 const pickerValue = ref<string[]>([])
 
-/**
- * 直接读取字段配置上挂载的选项。
- *
- * 普通字段:
- * useTaskForm 初始化时挂载字典数据。
- *
- * 特殊字段:
- * 页面 beforeOpen 请求接口后重新赋值 options。
- */
 const pickerOptions = computed<TaskFieldOption[]>(() => {
   return props.taskFieldConfig.options ?? []
 })
 
 const cellValue = computed(() => {
-  return label.value === undefined ? '' : String(label.value)
+  return label.value
 })
 
 watch(
   [value, label, pickerOptions],
   ([currentValue, currentLabel, currentOptions]) => {
-    /**
-     * value 存在,以 value 为准。
-     */
     if (currentValue !== undefined && currentValue !== '') {
       const valueString = String(currentValue)
 
@@ -121,9 +90,6 @@ watch(
       return
     }
 
-    /**
-     * 只有 label 时,通过 label 反查 value。
-     */
     if (currentLabel !== undefined && currentLabel !== '') {
       const selectedOption = currentOptions.find(
         (item) => String(item.label) === String(currentLabel)
@@ -134,6 +100,8 @@ watch(
 
         pickerValue.value = [selectedValue]
         value.value = selectedOption.value
+      } else {
+        pickerValue.value = []
       }
 
       return
@@ -144,25 +112,8 @@ watch(
     immediate: true,
   }
 )
-/**
- * 比较新旧字段值。
- *
- * 将 number 和 string 转为字符串比较,
- * 因此数字 1 和字符串 "1" 会被视为同一个值。
- */
-const isSameValue = (previousValue: FieldValue, currentValue: string): boolean => {
-  if (previousValue === '') {
-    return currentValue === ''
-  }
-
-  return String(previousValue) === currentValue
-}
-
-/**
- * 仅当值真正发生变化时触发 change。
- */
 const emitChangeIfNeeded = (previousValue: FieldValue, selectedItem: TaskFieldOption) => {
-  if (isSameValue(previousValue, selectedItem.value)) {
+  if (previousValue === selectedItem.value) {
     return
   }
 
@@ -182,19 +133,12 @@ const pickerClick = async () => {
   opening.value = true
 
   try {
-    /**
-     * beforeOpen 中可以请求特殊接口,
-     * 并重新给 taskFieldConfig.options 赋值。
-     */
     const canOpen = await props.beforeOpen?.()
 
     if (canOpen === false) {
       return
     }
 
-    /**
-     * beforeOpen 执行完成后再读取最新选项。
-     */
     if (!pickerOptions.value.length) {
       uni.showToast({
         title: '暂无可选内容',
@@ -207,7 +151,7 @@ const pickerClick = async () => {
     pickerShow.value = true
   } catch (error) {
     console.error(`打开单选字段失败:${props.taskFieldConfig.taskFiledValue}`, error)
-
+    uni.showToast({ title: '选项加载失败,请重试', icon: 'none' })
   } finally {
     opening.value = false
   }
@@ -220,9 +164,6 @@ const handleSelect = (event: PickerConfirmEvent) => {
     return
   }
 
-  /**
-   * 必须在更新 v-model 之前保存原值。
-   */
   const previousValue = value.value
 
   pickerValue.value = [selected.value]
@@ -230,9 +171,6 @@ const handleSelect = (event: PickerConfirmEvent) => {
   label.value = selected.label
   value.value = selected.value
 
-  /**
-   * 重复确认同一个选项时不会触发 change。
-   */
   emitChangeIfNeeded(previousValue, selected)
 }
 </script>

+ 1 - 1
src/pages-task/task-form/components/SingleText.vue

@@ -55,7 +55,7 @@ const inputType = computed<InputType>(() => {
 
 const inputValue = computed<string>({
   get() {
-    return String(value.value ?? label.value ?? '')
+    return value.value || label.value || ''
   },
   set(newValue) {
     const sanitizedValue = sanitizeInputValue(newValue, props.taskFieldConfig.taskFiledType)

+ 2 - 4
src/pages-task/task-form/components/TaskLocation.vue

@@ -6,7 +6,7 @@
       :title="taskFieldConfig.taskFiledValue"
       :required="taskFieldConfig.isMustfill === '1'"
       :value="cellValue"
-      :is-link="!isDisabled"
+      :is-link="!props.disabled"
       @click="taskLocationClick"
     />
   </view>
@@ -31,12 +31,10 @@ const props = withDefaults(defineProps<Props>(), {
 const value = defineModel<FieldValue>('value', { default: '' })
 const label = defineModel<FieldValue>('label', { default: '' })
 
-const isDisabled = computed(() => props.disabled)
-
 const cellValue = computed(() => label.value || value.value || '')
 
 const taskLocationClick = () => {
-  if (isDisabled.value) return
+  if (props.disabled) return
   uni.navigateTo({
     url: `/pages-common/location-select/index?type=${props.taskFieldConfig.taskFiledType}`,
     events: {

+ 0 - 13
src/pages-task/task-form/components/select-option.ts

@@ -1,13 +0,0 @@
-export interface TaskSelectOption {
-  label: string
-  value: string
-
-  /**
-   * 允许接口返回其他业务字段,但字段值统一转换为 string。
-   */
-  [key: string]: string
-}
-
-export type TaskSelectOptionsLoader = () => Promise<TaskSelectOption[]>
-
-export type TaskSelectBeforeOpen = () => boolean | Promise<boolean>

+ 18 - 74
src/pages-task/task-form/composables/useTaskFieldType.ts

@@ -1,8 +1,3 @@
-/**
- * 动态表单字段类型集合。
- *
- * 后端增加或调整字段类型时,只需要修改当前文件。
- */
 const FIELD_TYPES = {
   singleSelect: new Set<string>(['domain', 'select']),
   textInput: new Set<string>(['text', 'readonlytext', 'money', 'number']),
@@ -17,73 +12,22 @@ const FIELD_TYPES = {
   area: new Set<string>(['area']),
 } as const
 
-/**
- * 动态表单字段类型判断。
- *
- * 当前 composable 不维护响应式状态,
- * 只负责集中管理字段类型规则。
- */
-export const useTaskFieldType = () => {
-  const isSingleSelect = (type: string) => {
-    return FIELD_TYPES.singleSelect.has(type)
-  }
-
-  const isTextInput = (type: string) => {
-    return FIELD_TYPES.textInput.has(type)
-  }
-
-  const isReadonlyText = (type: string) => {
-    return type === 'readonlytext'
-  }
-
-  const isDateTime = (type: string) => {
-    return FIELD_TYPES.dateTime.has(type)
-  }
-
-  const isLocation = (type: string) => {
-    return FIELD_TYPES.location.has(type)
-  }
-
-  const isImgUpload = (type: string) => {
-    return FIELD_TYPES.imageUpload.has(type)
-  }
-
-  const isSign = (type: string) => {
-    return FIELD_TYPES.sign.has(type)
-  }
-
-  const isMultipleSelect = (type: string) => {
-    return FIELD_TYPES.multipleSelect.has(type)
-  }
-
-  const isInputAutoSelect = (type: string) => {
-    return FIELD_TYPES.inputAutoSelect.has(type)
-  }
-
-  const isDateTimeRange = (type: string) => {
-    return FIELD_TYPES.dateTimeRange.has(type)
-  }
-
-  const isLongText = (type: string) => {
-    return FIELD_TYPES.longText.has(type)
-  }
-
-  const isArea = (type: string) => {
-    return FIELD_TYPES.area.has(type)
-  }
-
-  return {
-    isSingleSelect,
-    isTextInput,
-    isReadonlyText,
-    isDateTime,
-    isLocation,
-    isImgUpload,
-    isSign,
-    isMultipleSelect,
-    isInputAutoSelect,
-    isDateTimeRange,
-    isLongText,
-    isArea,
-  }
+const SUPPORTED_FIELD_TYPES = new Set(Object.values(FIELD_TYPES).flatMap((types) => [...types]))
+
+const TASK_FIELD_TYPE_CHECKS = {
+  isSingleSelect: (type: string) => FIELD_TYPES.singleSelect.has(type),
+  isTextInput: (type: string) => FIELD_TYPES.textInput.has(type),
+  isReadonlyText: (type: string) => type === 'readonlytext',
+  isDateTime: (type: string) => FIELD_TYPES.dateTime.has(type),
+  isLocation: (type: string) => FIELD_TYPES.location.has(type),
+  isImgUpload: (type: string) => FIELD_TYPES.imageUpload.has(type),
+  isSign: (type: string) => FIELD_TYPES.sign.has(type),
+  isMultipleSelect: (type: string) => FIELD_TYPES.multipleSelect.has(type),
+  isInputAutoSelect: (type: string) => FIELD_TYPES.inputAutoSelect.has(type),
+  isDateTimeRange: (type: string) => FIELD_TYPES.dateTimeRange.has(type),
+  isLongText: (type: string) => FIELD_TYPES.longText.has(type),
+  isArea: (type: string) => FIELD_TYPES.area.has(type),
+  isSupported: (type: string) => SUPPORTED_FIELD_TYPES.has(type),
 }
+
+export const useTaskFieldType = () => TASK_FIELD_TYPE_CHECKS

+ 76 - 15
src/pages-task/task-form/composables/useTaskForm.ts

@@ -74,7 +74,7 @@ export interface DynamicFormFieldValue {
   /**
    * 实际提交值。
    *
-   * datetime 字段使用毫秒级时间戳
+   * datetime 字段使用 YYYY-MM-DD HH:mm
    */
   value: FieldValue
 }
@@ -89,6 +89,15 @@ export interface DerivedFieldChangeEvent {
   label: string
 }
 
+export interface SingleSelectChangeEvent extends DerivedFieldChangeEvent {
+  selectedItem: TaskFieldOption
+}
+
+interface RegisteredTaskForm {
+  fields: TaskFieldConfigViewItem[]
+  taskTypes: TaskTypeItem[]
+}
+
 const createEmptyFieldValue = (): DynamicFormFieldValue => ({
   label: '',
   value: '',
@@ -163,7 +172,7 @@ const serializeTaskFieldType = (field: TaskFieldConfigItem): string => {
  *
  * 符合旧业务规则的 datetime 字段:
  * - label 为 YYYY-MM-DD HH:mm
- * - value 为毫秒级时间戳
+ * - value 为 YYYY-MM-DD HH:mm
  *
  * 其他字段初始化为空。
  */
@@ -285,6 +294,25 @@ const isRecord = (source: unknown): source is Record<string, unknown> => {
   return Boolean(source) && typeof source === 'object' && !Array.isArray(source)
 }
 
+const isTaskFieldConfigItem = (source: unknown): source is TaskFieldConfigItem => {
+  if (!isRecord(source)) return false
+
+  const id = source.id
+
+  return (
+    (typeof id === 'string' || typeof id === 'number') &&
+    String(id).trim().length > 0 &&
+    typeof source.taskTypeId === 'string' &&
+    typeof source.taskFiledKey === 'string' &&
+    typeof source.taskFiledValue === 'string' &&
+    source.taskFiledValue.trim().length > 0 &&
+    typeof source.taskFiledType === 'string' &&
+    source.taskFiledType.trim().length > 0 &&
+    (source.alias === null || typeof source.alias === 'string') &&
+    (source.dictGroupName === null || typeof source.dictGroupName === 'string')
+  )
+}
+
 const serializeTaskTypes = (source: unknown): TaskTypeItem[] => {
   if (!Array.isArray(source)) {
     return []
@@ -295,10 +323,15 @@ const serializeTaskTypes = (source: unknown): TaskTypeItem[] => {
       return result
     }
 
-    const { id, taskScoreEnd } = item
+    const id = item.id
+    const taskScoreEnd = Number(item.taskScoreEnd)
 
-    if (typeof id === 'string' && typeof taskScoreEnd === 'number') {
-      result.push(item as unknown as TaskTypeItem)
+    if (
+      (typeof id === 'string' || typeof id === 'number') &&
+      String(id).trim() &&
+      Number.isFinite(taskScoreEnd)
+    ) {
+      result.push({ ...item, id: String(id), taskScoreEnd } as unknown as TaskTypeItem)
     }
 
     return result
@@ -310,12 +343,16 @@ const serializeTaskContentConfigResponse = (source: unknown): TaskContentConfig
     return null
   }
 
-  if (!Array.isArray(source.config)) {
+  if (
+    !Array.isArray(source.config) ||
+    source.config.length === 0 ||
+    !source.config.every(isTaskFieldConfigItem)
+  ) {
     return null
   }
 
   return {
-    config: source.config as TaskFieldConfigItem[],
+    config: source.config,
     dict: isRecord(source.dict) ? (source.dict as TaskFieldDictMap) : {},
     taskTypes: serializeTaskTypes(source.taskTypes),
   }
@@ -367,15 +404,16 @@ const serializeDeriveFrom = (source: unknown): TaskContentConfigDeriveFrom | nul
 export const useTaskForm = () => {
   const userStore = useUserStore()
 
-  const deptId = userStore.currentUserInfo?.deptId ?? ''
   const form = ref<DynamicFormState>(createEmptyFormState())
   const taskFieldConfigList = ref<TaskFieldConfigViewItem[]>([])
   const activeTaskTypeId = ref('')
   const taskTypes = ref<TaskTypeItem[]>([])
 
-  const taskFormConfigMap = new Map<string, TaskFieldConfigViewItem[]>()
+  const taskFormConfigMap = new Map<string, RegisteredTaskForm>()
 
   let deriveFrom: TaskContentConfigDeriveFrom | null = null
+  let loadRequestVersion = 0
+
   const createFormState = (config: TaskFieldConfigViewItem[]): DynamicFormState => {
     const formValue: DynamicFormState['value'] = {}
 
@@ -404,6 +442,7 @@ export const useTaskForm = () => {
   }
 
   const resetTaskForm = (): void => {
+    loadRequestVersion += 1
     clearTaskFormState()
   }
 
@@ -412,12 +451,14 @@ export const useTaskForm = () => {
     derivedFieldValue?: DynamicFormFieldValue
   ): boolean => {
     const normalizedTaskTypeId = taskTypeId.trim()
-    const targetConfig = taskFormConfigMap.get(normalizedTaskTypeId)
+    const targetTaskForm = taskFormConfigMap.get(normalizedTaskTypeId)
 
-    if (!targetConfig) {
+    if (!targetTaskForm) {
       return false
     }
 
+    const { fields: targetConfig, taskTypes: targetTaskTypes } = targetTaskForm
+
     /**
      * 每次切换都重新创建目标表单状态:
      * - 不保留当前表单已经输入的内容
@@ -442,6 +483,7 @@ export const useTaskForm = () => {
 
     activeTaskTypeId.value = normalizedTaskTypeId
     taskFieldConfigList.value = targetConfig
+    taskTypes.value = targetTaskTypes
     form.value = targetFormState
 
     return true
@@ -457,14 +499,23 @@ export const useTaskForm = () => {
     const config = serializeTaskFieldConfig(
       taskContentConfig.config,
       taskContentConfig.dict,
-      deptId
+      String(userStore.currentUserInfo?.deptId ?? '')
     )
 
-    taskFormConfigMap.set(normalizedTaskTypeId, config)
+    const fieldIds = new Set(config.map((field) => field.id))
+    const submitKeys = new Set(config.map((field) => field.taskFiledValue))
+
+    if (fieldIds.size !== config.length || submitKeys.size !== config.length) {
+      throw new Error(`任务 ${normalizedTaskTypeId} 的字段 ID 或提交键重复`)
+    }
+
+    taskFormConfigMap.set(normalizedTaskTypeId, {
+      fields: config,
+      taskTypes: taskContentConfig.taskTypes ?? [],
+    })
   }
 
   const loadSingleTaskForm = (taskTypeId: string, taskContentConfig: TaskContentConfig): void => {
-    taskTypes.value = taskContentConfig.taskTypes ?? []
     registerTaskForm(taskTypeId, taskContentConfig)
     activateTaskForm(taskTypeId)
   }
@@ -493,7 +544,7 @@ export const useTaskForm = () => {
       mappedTaskTypeIds.find((taskTypeId) =>
         taskFormConfigMap
           .get(taskTypeId)
-          ?.some((field) => field.taskFiledValue.trim() === deriveFrom?.field)
+          ?.fields.some((field) => field.taskFiledValue.trim() === deriveFrom?.field)
       ) ??
       mappedTaskTypeIds.find((taskTypeId) => taskFormConfigMap.has(taskTypeId)) ??
       taskFormConfigMap.keys().next().value
@@ -558,9 +609,15 @@ 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)
 
@@ -575,6 +632,10 @@ export const useTaskForm = () => {
         throw new Error('动态表单配置格式无效')
       }
     } catch (error) {
+      if (requestVersion !== loadRequestVersion) {
+        return
+      }
+
       clearTaskFormState()
       throw error
     }

+ 36 - 17
src/pages-task/task-form/composables/useTaskFormAction.ts

@@ -4,7 +4,6 @@ import { draftTaskContentApi, saveTaskContentApi } from '@/services/modules/task
 import type {
   SaveTaskContentBody,
   SaveTaskContentQuery,
-  SaveTaskContentSuccessData,
 } from '@/services/modules/task/taskFrom/type'
 
 import {
@@ -25,6 +24,8 @@ const PACKAGE_FIELD_ALIASES = ['scorePackage', 'approvalResult'] as const
 const IMAGE_FIELD_TYPE = 'img'
 const TEXT_LENGTH_FIELD_TYPES = new Set(['text', 'readonlytext', 'longtext'])
 const SUCCESS_TOAST_DURATION = 3000
+const API_SUCCESS_CODE = 0
+const DUPLICATE_IMAGE_CODE = 1
 
 const ACTION_API_MAP = {
   save: draftTaskContentApi,
@@ -46,7 +47,9 @@ const normalizeSizeLimit = (value: unknown): number | null => {
     return null
   }
 
-  return Math.floor(size)
+  const normalizedSize = Math.floor(size)
+
+  return normalizedSize > 0 ? normalizedSize : null
 }
 
 const getContentLength = (value: unknown): number => {
@@ -91,17 +94,14 @@ const extractImageUrls = (data: unknown): string[] => {
   return [...new Set(imageUrls)]
 }
 
-/**
- * 判断是否为提交成功数据。
- */
-const isSaveTaskContentSuccessData = (data: unknown): data is SaveTaskContentSuccessData => {
+const getSuccessScore = (data: unknown): string | null => {
   if (!data || typeof data !== 'object' || Array.isArray(data)) {
-    return false
+    return null
   }
 
-  const record = data as Record<string, unknown>
+  const score = (data as Record<string, unknown>).score
 
-  return typeof record.score === 'string' && typeof record.type === 'string'
+  return typeof score === 'string' || typeof score === 'number' ? String(score) : null
 }
 
 export const useTaskFormAction = ({
@@ -113,8 +113,11 @@ export const useTaskFormAction = ({
   const duplicateImageVisible = ref(false)
   const duplicateImageList = ref<string[]>([])
   let navigateBackTimer: ReturnType<typeof setTimeout> | null = null
+  let disposed = false
 
   onScopeDispose(() => {
+    disposed = true
+
     if (navigateBackTimer) {
       clearTimeout(navigateBackTimer)
       navigateBackTimer = null
@@ -280,6 +283,7 @@ export const useTaskFormAction = ({
   }
 
   const clearDuplicateImages = (): void => {
+    duplicateImageVisible.value = false
     duplicateImageList.value = []
   }
 
@@ -292,19 +296,28 @@ export const useTaskFormAction = ({
     if (action === 'save') {
       showMessage('保存成功', SUCCESS_TOAST_DURATION)
     } else {
-      if (!isSaveTaskContentSuccessData(data)) {
+      const score = getSuccessScore(data)
+
+      if (score === null) {
         showMessage('提交结果数据异常')
         return false
       }
 
-      showMessage(`提交成功,若审核通过可获得${data.score}积分`, SUCCESS_TOAST_DURATION)
+      showMessage(`提交成功,若审核通过可获得${score}积分`, SUCCESS_TOAST_DURATION)
     }
 
+    if (disposed) return false
+
     navigateBackTimer = setTimeout(() => {
       navigateBackTimer = null
+
+      if (disposed) return
+
       uni.navigateBack({
         complete: () => {
-          isActionPending.value = false
+          if (!disposed) {
+            isActionPending.value = false
+          }
         },
       })
     }, SUCCESS_TOAST_DURATION)
@@ -336,9 +349,13 @@ export const useTaskFormAction = ({
 
       const response = await requestApi(query, payload)
 
-      // 该接口在业务错误白名单中:重复图片使用定制弹窗,其余错误在此兜底。
-      if (response.code === 1) {
-        const duplicateImages = action === 'submit' ? extractImageUrls(response.data) : []
+      if (disposed) return
+
+      if (response.code !== API_SUCCESS_CODE) {
+        const duplicateImages =
+          action === 'submit' && response.code === DUPLICATE_IMAGE_CODE
+            ? extractImageUrls(response.data)
+            : []
 
         if (duplicateImages.length > 0) {
           showDuplicateImages(duplicateImages)
@@ -355,9 +372,11 @@ export const useTaskFormAction = ({
 
       navigationScheduled = handleActionSuccess(action, response.data)
     } catch (error: unknown) {
-      console.error(`[useTaskFormAction] ${action} 失败`, error)
+      if (!disposed) {
+        console.error(`[useTaskFormAction] ${action} 失败`, error)
+      }
     } finally {
-      if (!navigationScheduled) {
+      if (!disposed && !navigationScheduled) {
         isActionPending.value = false
       }
     }

+ 5 - 3
src/pages-task/task-form/composables/useTaskFormFields.ts

@@ -45,9 +45,9 @@ export const useTaskFormFields = ({ form, taskFieldConfigList }: UseTaskFormFiel
     field: TaskFieldConfigViewItem,
     fieldValue: TaskFieldValuePatch
   ): void => {
-    const currentValue = getFieldFormValue(field)
+    const currentValue = getFieldFormValue(field) ?? { label: '', value: '' }
 
-    if (!currentValue) return
+    form.value.value[field.id] = currentValue
 
     if (fieldValue.label !== undefined) {
       currentValue.label = fieldValue.label
@@ -75,7 +75,9 @@ export const useTaskFormFields = ({ form, taskFieldConfigList }: UseTaskFormFiel
     if (!field) return
 
     const normalizedValue = normalizeFieldText(value)
-    const matchedOption = field.options?.find((option) => option.label === normalizedValue)
+    const matchedOption =
+      field.options?.find((option) => option.label === normalizedValue) ??
+      field.options?.find((option) => option.value === normalizedValue)
 
     setFieldFormValue(field, {
       label: matchedOption?.label ?? normalizedValue,

+ 19 - 1
src/pages-task/task-form/composables/useTaskFormPageFeatures.ts

@@ -31,6 +31,7 @@ export const useTaskFormPageFeatures = () => {
   const showNotice = ref(false)
   const noticeText = ref('')
   const selectLevel = ref<AreaSelectLevel>(DEFAULT_AREA_SELECT_LEVEL)
+  let areaRequestVersion = 0
 
   const setNavigationTitle = (title?: string) => {
     const normalizedTitle = title?.trim()
@@ -56,6 +57,8 @@ export const useTaskFormPageFeatures = () => {
    * 不阻断动态表单加载。
    */
   const checkAreaType = async (taskTypeId: string) => {
+    const requestVersion = ++areaRequestVersion
+
     selectLevel.value = DEFAULT_AREA_SELECT_LEVEL
 
     if (!AREA_LEVEL_TASK_TYPE_IDS.has(taskTypeId)) {
@@ -72,8 +75,23 @@ export const useTaskFormPageFeatures = () => {
     try {
       const res = await checkAreaTypeApi(deptId)
 
-      selectLevel.value = Number(res.data) === 0 ? 3 : 2
+      if (requestVersion !== areaRequestVersion) {
+        return
+      }
+
+      const areaType = res.data
+
+      if (typeof areaType !== 'number' || !Number.isFinite(areaType)) {
+        console.warn('[useTaskFormPageFeatures] 区域类型数据无效', areaType)
+        return
+      }
+
+      selectLevel.value = areaType === 0 ? 3 : 2
     } catch (error) {
+      if (requestVersion !== areaRequestVersion) {
+        return
+      }
+
       console.error('[useTaskFormPageFeatures] 获取区域类型失败', error)
     }
   }

+ 22 - 3
src/pages-task/task-form/composables/useTaskFormSafeArea.ts

@@ -33,6 +33,7 @@ export const useTaskFormSafeArea = () => {
   const windowWidth = ref(375)
   const safeAreaBottom = ref(0)
   const keyboardHeight = ref(0)
+  let keyboardListenerRegistered = false
 
   const rpxToPx = (rpx: number) => {
     return (windowWidth.value / 750) * rpx
@@ -94,12 +95,30 @@ export const useTaskFormSafeArea = () => {
 
   const initSafeArea = () => {
     initSystemLayout()
-    keyboardApi.onKeyboardHeightChange?.(handleKeyboardHeightChange)
+
+    if (
+      keyboardListenerRegistered ||
+      !keyboardApi.onKeyboardHeightChange ||
+      !keyboardApi.offKeyboardHeightChange
+    ) {
+      return
+    }
+
+    keyboardApi.onKeyboardHeightChange(handleKeyboardHeightChange)
+    keyboardListenerRegistered = true
   }
 
-  onUnload(() => {
+  const disposeSafeArea = () => {
+    if (!keyboardListenerRegistered) {
+      return
+    }
+
     keyboardApi.offKeyboardHeightChange?.(handleKeyboardHeightChange)
-  })
+    keyboardListenerRegistered = false
+    keyboardHeight.value = 0
+  }
+
+  onUnload(disposeSafeArea)
 
   return {
     contentStyle,

+ 52 - 35
src/pages-task/task-form/composables/useTaskSelectHandlers.ts

@@ -1,4 +1,4 @@
-import { computed, type Ref, ref } from 'vue'
+import { computed, type Ref, ref, watch } from 'vue'
 
 import { getPackageListByTaskTypeApi } from '@/services/modules/information'
 import type {
@@ -16,20 +16,13 @@ import type { PackageRuleTaskTypeItem, TaskTypeItem } from '@/services/modules/t
 
 import type {
   DynamicFormState,
-  FieldValue,
+  SingleSelectChangeEvent,
   TaskFieldConfigViewItem,
   TaskFieldOption,
 } from './useTaskForm'
 import { isMultipleSelectDrugField, serializeTaskFieldOptions } from './useTaskForm'
 import { useTaskFormFields } from './useTaskFormFields'
 
-export interface SingleSelectChangeEvent {
-  previousValue: FieldValue
-  value: string
-  label: string
-  selectedItem: TaskFieldOption
-}
-
 interface UseTaskSelectHandlersOptions {
   taskTypeId: Ref<string>
   form: Ref<DynamicFormState>
@@ -41,7 +34,7 @@ const MEETING_TASK_TYPE_IDS = new Set(['1', '2', '3', '4', '64'])
 const SURVEY_TARGET_TASK_TYPE_IDS = new Set(['14', '31', '37'])
 const HOSPITAL_OR_PHARMACY_TASK_TYPE_IDS = new Set(['75', '76'])
 const ENTITY_SELECTOR_URL = '/pages-common/entity-selector/index'
-const PHARMACY_AUTOFILL_FIELD_ALIASES = ['pharmacyType', 'address', 'shengshiqu']
+const PHARMACY_AUTOFILL_FIELD_ALIASES = ['pharmacyType', 'address', 'shengshiqu', 'entId']
 const HOSPITAL_AUTOFILL_FIELD_ALIASES = [
   'city',
   'address',
@@ -49,6 +42,7 @@ const HOSPITAL_AUTOFILL_FIELD_ALIASES = [
   'ownerType',
   'website',
   'bedNumber',
+  'entId',
 ]
 const COMPANY_AUTOFILL_FIELD_ALIASES = [
   'address',
@@ -61,6 +55,7 @@ const COMPANY_AUTOFILL_FIELD_ALIASES = [
   'gsjj',
   'jyys',
   'registerAddress',
+  'entId',
 ]
 
 const PACKAGE_DEPENDENT_FIELD_ALIASES: Readonly<Record<string, readonly string[]>> = {
@@ -125,16 +120,15 @@ export const useTaskSelectHandlers = ({
     getFieldFormValue,
     getFieldFormValueByAlias,
     getFieldValueByAlias,
-    setFieldFormValue,
     setFieldFormValueByAlias,
     setFieldTextByAlias,
-    clearFieldValue,
     clearFieldValueByAlias,
     clearFieldValuesByAlias,
     setFieldOptions,
-    setFieldOptionsByAlias,
   } = useTaskFormFields({ form, taskFieldConfigList })
 
+  const fieldOptionsRequestVersions = new Map<string, number>()
+
   const openEntitySelector = <T>(
     alias: string,
     eventName: string,
@@ -172,15 +166,24 @@ export const useTaskSelectHandlers = ({
 
     if (!field) return
 
+    fieldOptionsRequestVersions.set(field.id, (fieldOptionsRequestVersions.get(field.id) ?? 0) + 1)
     setFieldOptions(field, [])
   }
 
-  const loadFieldOptions = async (
+  const loadLatestFieldOptions = 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()
-    if (!taskFieldConfigList.value.includes(field)) return false
+    const isCurrentField = taskFieldConfigList.value.includes(field)
+
+    if (fieldOptionsRequestVersions.get(field.id) !== requestVersion || !isCurrentField) {
+      return false
+    }
 
     setFieldOptions(field, options)
     return true
@@ -222,6 +225,7 @@ export const useTaskSelectHandlers = ({
   }
 
   const otcScoreList = ref<PackageRuleTaskTypeItem[]>([])
+  let otcScoreRequestVersion = 0
 
   const normalizeScore = (value: unknown): number => {
     const score = Number(value)
@@ -229,6 +233,23 @@ export const useTaskSelectHandlers = ({
     return Number.isFinite(score) && score >= 0 ? score : 0
   }
 
+  const normalizeScoreLimit = (value: unknown): number | null => {
+    if (value === undefined || value === null || value === '') return null
+
+    const score = Number(value)
+
+    return Number.isFinite(score) && score >= 0 ? score : null
+  }
+
+  watch(
+    taskTypeId,
+    () => {
+      otcScoreRequestVersion += 1
+      otcScoreList.value = []
+    },
+    { flush: 'sync' }
+  )
+
   const otcQuantityField = computed(() => {
     return findFieldByAlias('entCount')
   })
@@ -258,7 +279,7 @@ export const useTaskSelectHandlers = ({
     const taskType = taskTypes.value.find((item) => String(item.id) === String(serviceId))
 
     if (taskType) {
-      return normalizeScore(taskType.taskScoreEnd)
+      return normalizeScoreLimit(taskType.taskScoreEnd) ?? Number.POSITIVE_INFINITY
     }
 
     const serviceField = findFieldByAlias('realTaskTypeId')
@@ -266,7 +287,7 @@ export const useTaskSelectHandlers = ({
       (item) => String(item.value) === String(serviceId)
     )
 
-    return normalizeScore(selectedOption?.taskScoreEnd)
+    return normalizeScoreLimit(selectedOption?.taskScoreEnd) ?? Number.POSITIVE_INFINITY
   })
 
   const otcRawEstimatedScore = computed(() => otcQuantity.value * otcUnitScore.value)
@@ -294,7 +315,7 @@ export const useTaskSelectHandlers = ({
         return false
       }
 
-      const loaded = await loadFieldOptions(field, async () => {
+      const loaded = await loadLatestFieldOptions(field, async () => {
         const res = await getPackageListByTaskTypeApi(trainingTypeId)
 
         return serializeTaskFieldOptions(res.data)
@@ -380,7 +401,7 @@ export const useTaskSelectHandlers = ({
           return false
         }
 
-        return loadFieldOptions(field, () => getListDrugTable(packageId))
+        return loadLatestFieldOptions(field, () => getListDrugTable(packageId))
       }
       if (field.alias === 'realTaskTypeId') {
         const drugsValue = getFieldFormValueByAlias('drugs')
@@ -466,11 +487,19 @@ export const useTaskSelectHandlers = ({
      */
     if (taskTypeId.value === '47' && field.alias === 'scorePackage') {
       otcScoreList.value = []
+      const requestVersion = ++otcScoreRequestVersion
 
       try {
         const res = await getPackageRuleTaskTypeApi(event.value)
-        otcScoreList.value = Array.isArray(res.data) ? res.data : []
+
+        if (requestVersion === otcScoreRequestVersion) {
+          otcScoreList.value = Array.isArray(res.data) ? res.data : []
+        }
       } catch (error) {
+        if (requestVersion !== otcScoreRequestVersion) {
+          return
+        }
+
         otcScoreList.value = []
         console.error('获取OTC服务积分规则失败:', error)
       }
@@ -490,7 +519,7 @@ export const useTaskSelectHandlers = ({
         return false
       }
 
-      return loadFieldOptions(field, () => getListDrugTable(packageId))
+      return loadLatestFieldOptions(field, () => getListDrugTable(packageId))
     }
 
     return true
@@ -542,6 +571,7 @@ export const useTaskSelectHandlers = ({
     setFieldTextByAlias('ownerType', selectedHospitalItem.yyxz)
     setFieldTextByAlias('website', selectedHospitalItem.website)
     setFieldTextByAlias('bedNumber', selectedHospitalItem.ybczs)
+    setFieldTextByAlias('entId', selectedHospitalItem.id)
   }
 
   const setCompanyNameSelectEvent = (selectedCompanyItem: DistributionItem | string) => {
@@ -570,6 +600,7 @@ export const useTaskSelectHandlers = ({
     setFieldTextByAlias('gsjj', selectedCompanyItem.gsjj)
     setFieldTextByAlias('jyys', selectedCompanyItem.jyys)
     setFieldTextByAlias('registerAddress', selectedCompanyItem.gszcd)
+    setFieldTextByAlias('entId', selectedCompanyItem.id)
   }
   return {
     handleSingleSelectBeforeOpen,
@@ -578,19 +609,5 @@ export const useTaskSelectHandlers = ({
     otcEstimatedScore,
     otcLargestTotal,
     otcIsScoreOverLimit,
-
-    /**
-     * 以下方法可按需在页面或其他业务逻辑中使用。
-     */
-    findFieldByAlias,
-    getFieldFormValue,
-    getFieldFormValueByAlias,
-    getFieldValueByAlias,
-    setFieldFormValue,
-    setFieldFormValueByAlias,
-    clearFieldValue,
-    clearFieldValueByAlias,
-    setFieldOptions,
-    setFieldOptionsByAlias,
   }
 }

+ 70 - 7
src/pages-task/task-form/index.vue

@@ -10,6 +10,19 @@
       <view class="task-form__content" :style="isQuestionnaireTask ? undefined : contentStyle">
         <QuestionnaireTask v-if="isQuestionnaireTask" />
 
+        <template v-else-if="formLoading">
+          <view class="task-form-state">正在加载表单...</view>
+        </template>
+
+        <template v-else-if="formErrorMessage">
+          <view class="task-form-state">
+            <view>{{ formErrorMessage }}</view>
+            <button v-if="taskTypeId" class="task-form-state__retry" @click="loadDynamicTaskForm()">
+              重新加载
+            </button>
+          </view>
+        </template>
+
         <template v-else>
           <view v-if="showNotice" class="workbench-notice">
             <wd-notice-bar
@@ -28,6 +41,7 @@
             :active-task-type-id="activeTaskTypeId"
             :task-types="taskTypes"
             :select-level="selectLevel"
+            :disabled="isActionPending"
             @derived-change="handleDerivedFieldChange"
           />
         </template>
@@ -86,6 +100,8 @@ interface PageLoadOptions {
 }
 
 const taskTypeId = ref('')
+const formLoading = ref(false)
+const formErrorMessage = ref('')
 
 const {
   form,
@@ -124,34 +140,53 @@ const decodeRouteParam = (value?: string): string => {
   }
 }
 
+const loadDynamicTaskForm = async (pageInitialization?: Promise<void>): Promise<void> => {
+  if (!taskTypeId.value || formLoading.value) return
+
+  formLoading.value = true
+  formErrorMessage.value = ''
+
+  try {
+    await Promise.all([pageInitialization ?? Promise.resolve(), loadTaskForm(taskTypeId.value)])
+  } catch (error) {
+    console.error('[task-form] 表单配置加载失败', error)
+    formErrorMessage.value = '表单加载失败,请稍后重试'
+  } finally {
+    formLoading.value = false
+  }
+}
+
 onLoad(async (options: PageLoadOptions = {}) => {
   initSafeArea()
 
   taskTypeId.value = options.taskTypeId?.trim() ?? ''
 
   if (!taskTypeId.value) {
+    formErrorMessage.value = '缺少任务类型参数'
     uni.showToast({
-      title: '缺少 taskTypeId',
+      title: formErrorMessage.value,
       icon: 'none',
     })
 
     return
   }
 
-  void initializeTaskFormPage({
+  const pageInitialization = initializeTaskFormPage({
     taskTypeId: taskTypeId.value,
     taskTitle: decodeRouteParam(options.taskTitle),
   })
 
   if (isQuestionnaireTask.value) {
+    try {
+      await pageInitialization
+    } catch (error) {
+      console.error('[task-form] 页面初始化失败', error)
+    }
+
     return
   }
 
-  try {
-    await loadTaskForm(taskTypeId.value)
-  } catch (error) {
-    console.error('[task-form] 表单配置加载失败', error)
-  }
+  await loadDynamicTaskForm(pageInitialization)
 })
 </script>
 
@@ -180,6 +215,34 @@ onLoad(async (options: PageLoadOptions = {}) => {
   box-sizing: border-box;
 }
 
+.task-form-state {
+  margin: 20rpx;
+  padding: 48rpx 24rpx;
+  color: #718096;
+  font-size: 28rpx;
+  line-height: 40rpx;
+  text-align: center;
+  border-radius: 16rpx;
+  background: #fff;
+}
+
+.task-form-state__retry {
+  width: 200rpx;
+  height: 64rpx;
+  margin: 24rpx auto 0;
+  padding: 0;
+  color: #3b9bed;
+  font-size: 26rpx;
+  line-height: 64rpx;
+  border: 0;
+  border-radius: 32rpx;
+  background: #edf6ff;
+}
+
+.task-form-state__retry::after {
+  border: 0;
+}
+
 .task-form-footer {
   position: fixed;
   right: 0;

+ 9 - 1
src/pages-task/task-form/utils/questionnaire.ts

@@ -8,6 +8,12 @@ interface BuildQuestionnaireUrlOptions {
   usesQuestionnaireList: boolean
 }
 
+const getQuerySeparator = (url: string): '' | '?' | '&' => {
+  if (url.endsWith('?') || url.endsWith('&')) return ''
+
+  return url.includes('?') ? '&' : '?'
+}
+
 export const resolveQuestionnaireEnvironment = (baseApi: string): QuestionnaireEnvironment => {
   const normalizedBaseApi = baseApi.trim().toLowerCase()
 
@@ -41,5 +47,7 @@ export const buildQuestionnaireQrContent = ({
     return `${normalizedUrl}${normalizedPackageId}&env=${environment}`
   }
 
-  return `${normalizedUrl}?yycUsername=${encodeURIComponent(phone.trim())}&yycPkgId=${encodeURIComponent(normalizedPackageId)}&env=${environment}`
+  const querySeparator = getQuerySeparator(normalizedUrl)
+
+  return `${normalizedUrl}${querySeparator}yycUsername=${encodeURIComponent(phone.trim())}&yycPkgId=${encodeURIComponent(normalizedPackageId)}&env=${environment}`
 }

+ 4 - 7
src/pages/login/index.vue

@@ -132,15 +132,12 @@ const userStore = useUserStore()
 const { title } = projectConfig
 
 const form = reactive({
-  username: '17503049515',
-  password: '049515',
-  code: '1001',
+  username: '',
+  password: '',
+  code: '',
 })
 
-const { errors, validate, validateField, clearFieldError } = useFormValidator(
-  loginRules,
-  form
-)
+const { errors, validate, validateField, clearFieldError } = useFormValidator(loginRules, form)
 
 const agreementChecked = ref(false)
 const agreementError = ref(false)

+ 2 - 4
src/services/modules/task/taskFrom/index.ts

@@ -40,7 +40,7 @@ export const draftTaskContentApi = (
   saveTaskContentBody: SaveTaskContentBody
 ) => {
   return http.post<SaveTaskContentResponse>(
-    `admin/api/task-content/draft?packageId=${saveTaskContentQuery.packageId}&taskTypeId=${saveTaskContentQuery.taskTypeId}`,
+    `/admin/api/task-content/draft?packageId=${saveTaskContentQuery.packageId}&taskTypeId=${saveTaskContentQuery.taskTypeId}`,
     saveTaskContentBody
   )
 }
@@ -50,9 +50,7 @@ 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) => {