Parcourir la source

完成任务表单+兼容性实现

yuanmingze il y a 1 mois
Parent
commit
1d3cc30e82

+ 4 - 3
package.json

@@ -42,6 +42,7 @@
     "pinia": "^3.0.4",
     "pinia-plugin-persistedstate": "^4.7.1",
     "qs": "^6.14.1",
+    "uqrcodejs": "^4.0.7",
     "vue": "^3.4.21",
     "vue-i18n": "^9.1.9"
   },
@@ -60,7 +61,7 @@
     "@uni-helper/vite-plugin-uni-components": "^0.2.6",
     "@uni-ku/root": "^1.4.1",
     "@vue/runtime-core": "^3.4.21",
-    "@vue/tsconfig": "^0.1.3",
+    "@vue/tsconfig": "^0.9.1",
     "cross-env": "^10.1.0",
     "dotenv": "^17.2.3",
     "eslint": "^8.57.1",
@@ -72,12 +73,12 @@
     "prettier": "^3.7.4",
     "sass": "1.78.0",
     "tsx": "^4.21.0",
-    "typescript": "^4.9.4",
+    "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",
     "vue-eslint-parser": "9.4.3",
-    "vue-tsc": "^1.0.24"
+    "vue-tsc": "^3.3.9"
   }
 }

Fichier diff supprimé car celui-ci est trop grand
+ 211 - 215
pnpm-lock.yaml


+ 3 - 3
src/pages-task/task-form/components/Area.vue

@@ -29,7 +29,7 @@ import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
 type AreaLevel = 2 | 3
 
-type FieldValue = string | undefined
+type FieldValue = string
 
 interface AreaOption {
   value: string
@@ -58,9 +58,9 @@ const props = withDefaults(defineProps<Props>(), {
  * 二级:
  * 北京市-北京市城区
  */
-const value = defineModel<FieldValue>('value')
+const value = defineModel<FieldValue>('value', { default: '' })
 
-const label = defineModel<string>('label')
+const label = defineModel<string>('label', { default: '' })
 
 const visible = ref(false)
 

+ 3 - 3
src/pages-task/task-form/components/DataTimeRange.vue

@@ -36,7 +36,7 @@ interface CalendarConfirmPayload {
   value: number[]
 }
 
-type FieldModelValue = string | undefined
+type FieldModelValue = string
 
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
@@ -45,8 +45,8 @@ const props = withDefaults(defineProps<Props>(), {
 const DATE_FORMAT = 'YYYY-MM-DD'
 const RANGE_SEPARATOR = ' ~ '
 
-const value = defineModel<FieldModelValue>('value')
-const label = defineModel<FieldModelValue>('label')
+const value = defineModel<FieldModelValue>('value', { default: '' })
+const label = defineModel<FieldModelValue>('label', { default: '' })
 
 const calendarShow = ref(false)
 const calendarValue = ref<number[]>([])

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

@@ -29,7 +29,7 @@ import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
 dayjs.extend(customParseFormat)
 
-type FieldValue = string | number | undefined
+type FieldValue = string
 
 type DateTimeTaskFieldConfig = Pick<TaskFieldConfigItem, 'taskFiledValue' | 'isMustfill'>
 
@@ -49,8 +49,8 @@ const props = withDefaults(defineProps<Props>(), {
 const DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm'
 const SECOND_TIMESTAMP_THRESHOLD = 100_000_000_000
 
-const value = defineModel<FieldValue>('value')
-const label = defineModel<FieldValue>('label')
+const value = defineModel<FieldValue>('value', { default: '' })
+const label = defineModel<FieldValue>('label', { default: '' })
 
 const datetimePickerVisible = ref(false)
 const datetimePickerValue = ref(createCurrentTimestamp())
@@ -85,7 +85,7 @@ function normalizeNumericTimestamp(timestamp: number): number {
  * - 数字字符串时间戳
  * - 旧日期字符串
  */
-function parseDateTime(rawValue: FieldValue): Dayjs | null {
+function parseDateTime(rawValue: unknown): Dayjs | null {
   if (rawValue === undefined || rawValue === null || rawValue === '') {
     return null
   }
@@ -100,6 +100,10 @@ function parseDateTime(rawValue: FieldValue): Dayjs | null {
     return parsedDate.isValid() ? parsedDate : null
   }
 
+  if (typeof rawValue !== 'string') {
+    return null
+  }
+
   const normalizedValue = rawValue.trim()
 
   if (!normalizedValue) {

+ 6 - 6
src/pages-task/task-form/components/ImgUpload.vue

@@ -40,7 +40,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed, getCurrentInstance, onMounted, ref } from 'vue'
+import { computed, type CSSProperties, getCurrentInstance, onMounted, ref } from 'vue'
 
 import { getLocation } from '@/lib/location'
 
@@ -48,7 +48,7 @@ import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
 import { useUserStore } from '@/stores/modules/user'
 
-type FieldValue = string | null
+type FieldValue = string
 type UploadSource = 'camera' | 'album'
 
 interface UploadResponse {
@@ -79,11 +79,11 @@ const props = withDefaults(defineProps<Props>(), {
 })
 
 const value = defineModel<FieldValue>('value', {
-  default: null,
+  default: '',
 })
 
 const label = defineModel<FieldValue>('label', {
-  default: null,
+  default: '',
 })
 
 const userStore = useUserStore()
@@ -98,7 +98,7 @@ const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
 
 const isDisabled = computed(() => props.disabled)
 
-const watermarkCanvasStyle = computed(() => {
+const watermarkCanvasStyle = computed<CSSProperties>(() => {
   return {
     width: `${watermarkCanvasWidth.value}px`,
     height: `${watermarkCanvasHeight.value}px`,
@@ -419,7 +419,7 @@ const parseImageValue = (input: unknown): string[] => {
 const stringifyImageList = (urls: string[]): FieldValue => {
   const normalizedUrls = urls.map((url) => String(url).trim()).filter(Boolean)
 
-  return normalizedUrls.length > 0 ? normalizedUrls.join(',') : null
+  return normalizedUrls.join(',')
 }
 
 const normalizeImageValue = (input: unknown): FieldValue => {

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

@@ -23,13 +23,15 @@ interface Props {
   beforeOpen?: () => boolean | Promise<boolean>
 }
 
+type FieldValue = string
+
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
   beforeOpen: undefined,
 })
 
-const label = defineModel<FieldValue>('label')
-const value = defineModel<FieldValue>('value')
+const label = defineModel<FieldValue>('label', { default: '' })
+defineModel<FieldValue>('value', { default: '' })
 
 const cellValue = computed(() => {
   return label.value === undefined ? '' : String(label.value)

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

@@ -27,7 +27,7 @@ import { computed } from 'vue'
 
 import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
-type FieldValue = string | null
+type FieldValue = string
 
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
@@ -41,11 +41,11 @@ const props = withDefaults(defineProps<Props>(), {
 })
 
 const value = defineModel<FieldValue>('value', {
-  default: null,
+  default: '',
 })
 
 const label = defineModel<FieldValue>('label', {
-  default: null,
+  default: '',
 })
 
 const maxLength = computed(() => {

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

@@ -25,8 +25,7 @@ import { computed, ref, watch } from 'vue'
 import type { TaskFieldConfigViewItem, TaskFieldOption } from '../composables/useTaskForm'
 
 type RawOptionValue = string | number
-
-type FieldValue = RawOptionValue[] | RawOptionValue | null | undefined
+type FieldValue = string
 
 interface SelectPickerConfirmEvent {
   value: RawOptionValue[]
@@ -50,8 +49,8 @@ const props = withDefaults(defineProps<Props>(), {
   disabled: false,
 })
 
-const label = defineModel<string | undefined>('label')
-const value = defineModel<FieldValue>('value')
+const label = defineModel<string>('label', { default: '' })
+const value = defineModel<FieldValue>('value', { default: '' })
 
 const pickerShow = ref(false)
 const opening = ref(false)
@@ -69,7 +68,7 @@ const pickerOptions = computed<TaskFieldOption[]>(() => {
 
 const cellValue = computed(() => label.value ?? '')
 
-const normalizeValue = (rawValue: FieldValue): string[] => {
+const normalizeValue = (rawValue: unknown): string[] => {
   if (Array.isArray(rawValue)) {
     return rawValue.filter((item) => item !== undefined && item !== null && item !== '').map(String)
   }

+ 483 - 0
src/pages-task/task-form/components/QuestionnaireTask.vue

@@ -0,0 +1,483 @@
+<template>
+  <view class="questionnaire-task">
+    <view v-if="configLoading" class="questionnaire-task__page-state">
+      <wd-loading color="#3b9bed" />
+      <text>任务配置加载中...</text>
+    </view>
+
+    <view v-else-if="configError" class="questionnaire-task__page-state">
+      <text>{{ configError }}</text>
+      <wd-button size="small" type="primary" @click="loadTaskConfig">重新加载</wd-button>
+    </view>
+
+    <template v-else>
+      <view class="questionnaire-task__panel">
+        <wd-cell
+          title-width="200rpx"
+          :title="packageFieldLabel"
+          :placeholder="`请选择${packageFieldLabel}`"
+          :value="selectedPackageLabel"
+          required
+          is-link
+          @click="openPackagePicker"
+        />
+
+        <wd-cell v-if="questionnaireLoading" title-width="200rpx" title="问卷" required>
+          <view class="questionnaire-task__loading">
+            <wd-loading size="34rpx" color="#3b9bed" />
+            <text>加载中...</text>
+          </view>
+        </wd-cell>
+
+        <wd-cell
+          v-else-if="questionnaireOptions.length"
+          title-width="200rpx"
+          title="问卷"
+          placeholder="请选择问卷"
+          :value="selectedQuestionnaireLabel"
+          required
+          is-link
+          @click="openQuestionnairePicker"
+        />
+
+        <wd-cell v-else title-width="200rpx" title="问卷" required>
+          <wd-input
+            v-model="manualQuestionnaireUrl"
+            class="questionnaire-task__input"
+            placeholder="请输入问卷地址"
+            clearable
+            align-right
+            custom-style="--wot-input-padding: 0; padding: 0;"
+            @input="clearQrCode"
+          />
+          <template #suffix>
+            <wd-button
+              size="mini"
+              type="success"
+              custom-style="min-width: 88rpx; margin-left: 12rpx; padding: 0 18rpx;"
+              :disabled="qrCodeLoading"
+              @click.stop="generateManualQrCode"
+            >
+              生成
+            </wd-button>
+          </template>
+        </wd-cell>
+      </view>
+
+      <view v-if="qrCodeImageUrl" class="questionnaire-task__qrcode">
+        <image
+          class="questionnaire-task__image"
+          :src="qrCodeImageUrl"
+          mode="aspectFit"
+          :show-menu-by-longpress="true"
+        />
+      </view>
+    </template>
+
+    <canvas
+      canvas-id="questionnaireQrCanvas"
+      class="questionnaire-task__canvas"
+      :style="canvasStyle"
+    />
+
+    <wd-picker
+      v-model="selectedPackagePickerValue"
+      v-model:visible="packagePickerVisible"
+      :columns="packageOptions"
+      @confirm="handlePackageConfirm"
+    />
+
+    <wd-picker
+      v-model="selectedQuestionnairePickerValue"
+      v-model:visible="questionnairePickerVisible"
+      :columns="questionnaireOptions"
+      @confirm="handleQuestionnaireConfirm"
+    />
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, getCurrentInstance, nextTick, onMounted, ref } from 'vue'
+
+import UQRCode from 'uqrcodejs'
+
+import {
+  getQuestionnaireListApi,
+  getQuestionnaireTaskContentConfigApi,
+} from '@/services/modules/task/taskFrom'
+import type {
+  QuestionnaireListItem,
+  TaskFieldConfigItem,
+} from '@/services/modules/task/taskFrom/type'
+
+import { useUserStore } from '@/stores/modules/user'
+
+import { hideGlobalLoading, showGlobalLoading } from '@/utils/loading'
+
+import {
+  buildQuestionnaireQrContent,
+  resolveQuestionnaireEnvironment,
+} from '../utils/questionnaire'
+
+interface PickerOption {
+  label: string
+  value: string
+}
+
+interface PickerConfirmEvent {
+  selectedItems: PickerOption[]
+}
+
+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)
+const configError = ref('')
+const packageField = ref<TaskFieldConfigItem | null>(null)
+const packageOptions = ref<PickerOption[]>([])
+const packagePickerVisible = ref(false)
+const selectedPackagePickerValue = ref<string[]>([])
+const selectedPackageLabel = ref('')
+const selectedPackageId = ref('')
+
+const questionnaireList = ref<QuestionnaireListItem[]>([])
+const questionnaireLoading = ref(false)
+const questionnairePickerVisible = ref(false)
+const selectedQuestionnairePickerValue = ref<string[]>([])
+const selectedQuestionnaireLabel = ref('')
+const manualQuestionnaireUrl = ref('')
+
+const qrCodeImageUrl = ref('')
+const qrCodeLoading = ref(false)
+
+let questionnaireRequestVersion = 0
+let qrCodeGenerationVersion = 0
+let lastQrContent = ''
+
+const packageFieldLabel = computed(() => packageField.value?.taskFiledValue || '关联积分包')
+
+const questionnaireOptions = computed<PickerOption[]>(() => {
+  return questionnaireList.value.map((item) => ({
+    label: item.label,
+    value: item.url,
+  }))
+})
+
+const canvasStyle = {
+  width: `${QR_CODE_SIZE}px`,
+  height: `${QR_CODE_SIZE}px`,
+}
+
+onMounted(() => {
+  void loadTaskConfig()
+})
+
+const loadTaskConfig = async () => {
+  if (configLoading.value) {
+    return
+  }
+
+  configLoading.value = true
+  configError.value = ''
+
+  try {
+    const response = await getQuestionnaireTaskContentConfigApi()
+    const taskConfig = response.data[TASK_TYPE_ID]
+    const scorePackageField = taskConfig.config.find(
+      (field) => field.alias === 'scorePackage' || field.taskFiledKey === 'temp32'
+    )
+
+    if (!scorePackageField) {
+      throw new Error('801 任务配置中缺少关联积分包字段')
+    }
+
+    const dictGroupName = scorePackageField.dictGroupName?.trim() || ''
+    const dictItems = dictGroupName ? (taskConfig.dict[dictGroupName] ?? []) : []
+
+    packageField.value = scorePackageField
+    packageOptions.value = dictItems
+      .map((item) => ({
+        label: String(item.label ?? '').trim(),
+        value: String(item.value ?? '').trim(),
+      }))
+      .filter((item) => item.label && item.value)
+
+    if (!packageOptions.value.length) {
+      throw new Error('暂无可用的关联积分包')
+    }
+  } catch (error) {
+    console.error('[QuestionnaireTask] 加载 801 配置失败', error)
+    configError.value = error instanceof Error ? error.message : '任务配置加载失败'
+  } finally {
+    configLoading.value = false
+  }
+}
+
+const openPackagePicker = () => {
+  if (!packageOptions.value.length) {
+    uni.showToast({ title: configError.value || '暂无可选积分包', icon: 'none' })
+    return
+  }
+
+  packagePickerVisible.value = true
+}
+
+const handlePackageConfirm = (event: PickerConfirmEvent) => {
+  const selectedItem = event.selectedItems[0]
+
+  if (!selectedItem || selectedItem.value === selectedPackageId.value) {
+    return
+  }
+
+  selectedPackagePickerValue.value = [selectedItem.value]
+  selectedPackageLabel.value = selectedItem.label
+  selectedPackageId.value = selectedItem.value
+
+  resetQuestionnaire()
+  void loadQuestionnaireList(selectedItem.value)
+}
+
+const resetQuestionnaire = () => {
+  questionnaireRequestVersion += 1
+  questionnaireList.value = []
+  questionnaireLoading.value = false
+  questionnairePickerVisible.value = false
+  selectedQuestionnairePickerValue.value = []
+  selectedQuestionnaireLabel.value = ''
+  manualQuestionnaireUrl.value = ''
+  clearQrCode()
+}
+
+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
+    }
+  }
+}
+
+const openQuestionnairePicker = () => {
+  if (!selectedPackageId.value) {
+    uni.showToast({ title: '请先选择关联积分包', icon: 'none' })
+    return
+  }
+
+  questionnairePickerVisible.value = true
+}
+
+const handleQuestionnaireConfirm = (event: PickerConfirmEvent) => {
+  const selectedItem = event.selectedItems[0]
+
+  if (!selectedItem) {
+    return
+  }
+
+  selectedQuestionnairePickerValue.value = [selectedItem.value]
+  selectedQuestionnaireLabel.value = selectedItem.label
+
+  void generateQrCode(selectedItem.value, true)
+}
+
+const generateManualQrCode = () => {
+  if (qrCodeLoading.value) {
+    return
+  }
+
+  const questionnaireUrl = manualQuestionnaireUrl.value.trim()
+
+  if (!selectedPackageId.value) {
+    uni.showToast({ title: '请选择关联积分包', icon: 'none' })
+    return
+  }
+
+  if (!questionnaireUrl) {
+    uni.showToast({ title: '请输入问卷地址', icon: 'none' })
+    return
+  }
+
+  void generateQrCode(questionnaireUrl, false)
+}
+
+const clearQrCode = () => {
+  qrCodeGenerationVersion += 1
+  qrCodeImageUrl.value = ''
+  lastQrContent = ''
+}
+
+const generateQrCode = async (questionnaireUrl: string, usesQuestionnaireList: boolean) => {
+  const packageId = selectedPackageId.value
+
+  if (!packageId || qrCodeLoading.value) {
+    return
+  }
+
+  const environment = resolveQuestionnaireEnvironment(String(import.meta.env.VITE_BASE_API ?? ''))
+  const qrContent = buildQuestionnaireQrContent({
+    questionnaireUrl,
+    packageId,
+    environment,
+    phone: userStore.currentUserInfo?.phone,
+    usesQuestionnaireList,
+  })
+
+  if (qrContent === lastQrContent && qrCodeImageUrl.value) {
+    return
+  }
+
+  const generationVersion = ++qrCodeGenerationVersion
+
+  qrCodeImageUrl.value = ''
+  qrCodeLoading.value = true
+  showGlobalLoading('二维码生成中')
+
+  try {
+    await nextTick()
+    await waitForLoadingPaint()
+
+    const canvasContext = uni.createCanvasContext(
+      QR_CODE_CANVAS_ID,
+      instance?.proxy as unknown as WechatMiniprogram.Component.TrivialInstance
+    )
+    const qrCode = new UQRCode()
+
+    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()
+
+    await qrCode.drawCanvas()
+
+    if (generationVersion !== qrCodeGenerationVersion) {
+      return
+    }
+
+    qrCodeImageUrl.value = await exportQrCodeImage()
+    lastQrContent = qrContent
+  } catch (error) {
+    if (generationVersion !== qrCodeGenerationVersion) {
+      return
+    }
+
+    console.error('[QuestionnaireTask] 生成二维码失败', error)
+    uni.showToast({ title: '二维码生成失败,请重试', icon: 'none' })
+  } finally {
+    qrCodeLoading.value = false
+    hideGlobalLoading()
+  }
+}
+
+const waitForLoadingPaint = (): Promise<void> => {
+  return new Promise((resolve) => {
+    setTimeout(resolve, 60)
+  })
+}
+
+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
+    )
+  })
+}
+</script>
+
+<style lang="scss" scoped>
+.questionnaire-task {
+  min-height: 100vh;
+  padding-top: 20rpx;
+  box-sizing: border-box;
+}
+
+.questionnaire-task__page-state {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  gap: 24rpx;
+  min-height: 360rpx;
+  color: #8a8f99;
+  font-size: 28rpx;
+}
+
+.questionnaire-task__panel {
+  background: #fff;
+}
+
+.questionnaire-task__loading {
+  display: flex;
+  align-items: center;
+  justify-content: flex-end;
+  gap: 12rpx;
+  width: 100%;
+  color: #8a8f99;
+  font-size: 28rpx;
+}
+
+.questionnaire-task__input {
+  width: 100%;
+  min-width: 0;
+}
+
+.questionnaire-task__qrcode {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 36rpx 0;
+}
+
+.questionnaire-task__image {
+  display: block;
+  width: 360rpx;
+  height: 360rpx;
+}
+
+.questionnaire-task__canvas {
+  position: fixed;
+  top: -9999px;
+  left: -9999px;
+}
+</style>

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

@@ -24,7 +24,7 @@
 <script setup lang="ts">
 import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
-type FieldValue = string | null
+type FieldValue = string
 
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
@@ -38,11 +38,11 @@ const props = withDefaults(defineProps<Props>(), {
 })
 
 const value = defineModel<FieldValue>('value', {
-  default: null,
+  default: '',
 })
 
 const label = defineModel<FieldValue>('label', {
-  default: null,
+  default: '',
 })
 
 const handleSignClick = () => {

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

@@ -24,7 +24,7 @@ import { computed, ref, watch } from 'vue'
 
 import type { TaskFieldConfigViewItem, TaskFieldOption } from '../composables/useTaskForm'
 
-type FieldValue = string | number | undefined
+type FieldValue = string
 
 interface PickerConfirmEvent {
   selectedItems: TaskFieldOption[]
@@ -77,8 +77,8 @@ const emit = defineEmits<{
   change: [event: SingleSelectChangeEvent]
 }>()
 
-const label = defineModel<FieldValue>('label')
-const value = defineModel<FieldValue>('value')
+const label = defineModel<FieldValue>('label', { default: '' })
+const value = defineModel<FieldValue>('value', { default: '' })
 
 const pickerShow = ref(false)
 const opening = ref(false)
@@ -151,7 +151,7 @@ watch(
  * 因此数字 1 和字符串 "1" 会被视为同一个值。
  */
 const isSameValue = (previousValue: FieldValue, currentValue: string): boolean => {
-  if (previousValue === undefined || previousValue === '') {
+  if (previousValue === '') {
     return currentValue === ''
   }
 

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

@@ -25,7 +25,7 @@ import { computed } from 'vue'
 
 import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
-type FieldValue = string | number | null | undefined
+type FieldValue = string
 type InputType = 'text' | 'number' | 'digit'
 
 interface Props {
@@ -39,8 +39,8 @@ const props = withDefaults(defineProps<Props>(), {
   readonly: false,
 })
 
-const label = defineModel<FieldValue>('label')
-const value = defineModel<FieldValue>('value')
+const label = defineModel<FieldValue>('label', { default: '' })
+const value = defineModel<FieldValue>('value', { default: '' })
 
 const inputType = computed<InputType>(() => {
   switch (props.taskFieldConfig.taskFiledType) {

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

@@ -22,12 +22,14 @@ interface Props {
   disabled?: boolean
 }
 
+type FieldValue = string
+
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
 })
 
-const value = defineModel<FieldValue>('value')
-const label = defineModel<FieldValue>('label')
+const value = defineModel<FieldValue>('value', { default: '' })
+const label = defineModel<FieldValue>('label', { default: '' })
 
 const isDisabled = computed(() => props.disabled)
 

+ 28 - 2
src/pages-task/task-form/composables/useTaskForm.ts

@@ -8,11 +8,12 @@ import type {
   TaskContentConfigDeriveFrom,
   TaskFieldConfigItem,
   TaskFieldDictMap,
+  TaskTypeItem,
 } from '@/services/modules/task/taskFrom/type'
 
 import { useUserStore } from '@/stores/modules/user'
 
-export type FieldValue = string | number | undefined
+export type FieldValue = string
 
 /**
  * 日期字段展示格式。
@@ -213,6 +214,26 @@ const isRecord = (source: unknown): source is Record<string, unknown> => {
   return Boolean(source) && typeof source === 'object' && !Array.isArray(source)
 }
 
+const serializeTaskTypes = (source: unknown): TaskTypeItem[] => {
+  if (!Array.isArray(source)) {
+    return []
+  }
+
+  return source.reduce<TaskTypeItem[]>((result, item) => {
+    if (!isRecord(item)) {
+      return result
+    }
+
+    const { id, taskScoreEnd } = item
+
+    if (typeof id === 'string' && typeof taskScoreEnd === 'number') {
+      result.push(item as unknown as TaskTypeItem)
+    }
+
+    return result
+  }, [])
+}
+
 const serializeTaskContentConfigResponse = (source: unknown): TaskContentConfig | null => {
   if (!isRecord(source)) {
     return null
@@ -225,6 +246,7 @@ const serializeTaskContentConfigResponse = (source: unknown): TaskContentConfig
   return {
     config: source.config as TaskFieldConfigItem[],
     dict: isRecord(source.dict) ? (source.dict as TaskFieldDictMap) : {},
+    taskTypes: serializeTaskTypes(source.taskTypes),
   }
 }
 
@@ -278,6 +300,7 @@ export const useTaskForm = () => {
   const form = ref<DynamicFormState>(createEmptyFormState())
   const taskFieldConfigList = ref<TaskFieldConfigViewItem[]>([])
   const activeTaskTypeId = ref('')
+  const taskTypes = ref<TaskTypeItem[]>([])
 
   const taskFormConfigMap = new Map<string, TaskFieldConfigViewItem[]>()
 
@@ -308,6 +331,7 @@ export const useTaskForm = () => {
     form.value = createEmptyFormState()
     taskFieldConfigList.value = []
     activeTaskTypeId.value = ''
+    taskTypes.value = []
     taskFormConfigMap.clear()
     deriveFrom = null
   }
@@ -365,6 +389,7 @@ export const useTaskForm = () => {
   }
 
   const loadSingleTaskForm = (taskTypeId: string, taskContentConfig: TaskContentConfig): void => {
+    taskTypes.value = taskContentConfig.taskTypes ?? []
     registerTaskForm(taskTypeId, taskContentConfig)
     activateTaskForm(taskTypeId)
   }
@@ -460,7 +485,7 @@ export const useTaskForm = () => {
 
     try {
       const res = await getTaskContentConfigByTaskTypeIdApi(normalizedTaskTypeId)
-      const responseData: unknown = res.data
+      const responseData = res.data
       const singleTaskContentConfig = serializeTaskContentConfigResponse(responseData)
 
       resetTaskForm()
@@ -483,6 +508,7 @@ export const useTaskForm = () => {
     form,
     taskFieldConfigList,
     activeTaskTypeId,
+    taskTypes,
     loadTaskForm,
     resetTaskForm,
     handleDerivedFieldChange,

+ 106 - 12
src/pages-task/task-form/composables/useTaskSelectHandlers.ts

@@ -1,4 +1,4 @@
-import type { Ref } from 'vue'
+import { computed, type Ref, ref } from 'vue'
 
 import { getPackageListByTaskTypeApi } from '@/services/modules/information'
 import type {
@@ -7,7 +7,11 @@ import type {
   PharmacyItem,
   SurveyTargetItem,
 } from '@/services/modules/task/entitySelector/type'
-import { getListDrugTableApi } from '@/services/modules/task/taskFrom/index'
+import {
+  getListDrugTableApi,
+  getPackageRuleTaskTypeApi,
+} from '@/services/modules/task/taskFrom/index'
+import type { PackageRuleTaskTypeItem, TaskTypeItem } from '@/services/modules/task/taskFrom/type'
 
 import type {
   DynamicFormFieldValue,
@@ -29,6 +33,7 @@ interface UseTaskSelectHandlersOptions {
   taskTypeId: Ref<string>
   form: Ref<DynamicFormState>
   taskFieldConfigList: Ref<TaskFieldConfigViewItem[]>
+  taskTypes: Ref<TaskTypeItem[]>
 }
 
 /**
@@ -46,6 +51,7 @@ export const useTaskSelectHandlers = ({
   taskTypeId,
   form,
   taskFieldConfigList,
+  taskTypes,
 }: UseTaskSelectHandlersOptions) => {
   /**
    * 根据 alias 查找字段配置。
@@ -77,7 +83,7 @@ export const useTaskSelectHandlers = ({
   /**
    * 根据 alias 获取字段实际 value。
    */
-  const getFieldValueByAlias = (alias: string): FieldValue => {
+  const getFieldValueByAlias = (alias: string): FieldValue | undefined => {
     return getFieldFormValueByAlias(alias)?.value
   }
 
@@ -165,6 +171,64 @@ export const useTaskSelectHandlers = ({
     setFieldOptions(field, options)
   }
 
+  const otcScoreList = ref<PackageRuleTaskTypeItem[]>([])
+  let otcScoreRequestVersion = 0
+
+  const normalizeScore = (value: unknown): number => {
+    const score = Number(value)
+
+    return Number.isFinite(score) && score >= 0 ? score : 0
+  }
+
+  const otcQuantityField = computed(() => {
+    return (
+      findFieldByAlias('entCount') ??
+      taskFieldConfigList.value.find((field) => field.taskFiledValue.trim() === '数量')
+    )
+  })
+
+  const otcQuantity = computed(() => {
+    const field = otcQuantityField.value
+
+    return field ? normalizeScore(getFieldFormValue(field)?.value) : 0
+  })
+
+  const otcServiceValue = computed(() => getFieldFormValueByAlias('realTaskTypeId'))
+
+  const otcUnitScore = computed(() => {
+    const serviceName = String(otcServiceValue.value?.label ?? '')
+    const scoreItem = otcScoreList.value.find((item) => item.taskTypeName === serviceName)
+
+    return normalizeScore(scoreItem?.score)
+  })
+
+  const otcLargestTotal = computed(() => {
+    const serviceId = otcServiceValue.value?.value
+
+    if (serviceId === undefined || serviceId === '') {
+      return 0
+    }
+
+    const taskType = taskTypes.value.find((item) => String(item.id) === String(serviceId))
+
+    if (taskType) {
+      return normalizeScore(taskType.taskScoreEnd)
+    }
+
+    const serviceField = findFieldByAlias('realTaskTypeId')
+    const selectedOption = serviceField?.options?.find(
+      (item) => String(item.value) === String(serviceId)
+    )
+
+    return normalizeScore(selectedOption?.taskScoreEnd)
+  })
+
+  const otcRawEstimatedScore = computed(() => otcQuantity.value * otcUnitScore.value)
+  const otcEstimatedScore = computed(() =>
+    Math.min(otcRawEstimatedScore.value, otcLargestTotal.value)
+  )
+  const otcIsScoreOverLimit = computed(() => otcRawEstimatedScore.value > otcLargestTotal.value)
+
   /**
    * 单选打开前处理。
    *
@@ -256,9 +320,9 @@ export const useTaskSelectHandlers = ({
       /**
        * OTC 推广任务示例。
        */
-      const scorePackageValue = getFieldFormValueByAlias('scorePackage')
-      console.log('scorePackageValue', scorePackageValue)
-      if (field.taskFiledValue === '产品') {
+
+      if (field.alias === 'drugs') {
+        const scorePackageValue = getFieldFormValueByAlias('scorePackage')
         if (!scorePackageValue?.value) {
           uni.showToast({
             title: '请先选择积分包',
@@ -271,9 +335,15 @@ export const useTaskSelectHandlers = ({
           return true
         }
       }
-
       if (field.alias === 'realTaskTypeId') {
-        return true
+        const drugsValue = getFieldFormValueByAlias('drugs')
+        if (!drugsValue?.value) {
+          uni.showToast({
+            title: '请先选择产品',
+            icon: 'none',
+          })
+          return false
+        }
       }
     }
 
@@ -302,7 +372,7 @@ export const useTaskSelectHandlers = ({
    * SingleSelect 内部已经保证新值与原值不同时
    * 才会触发此方法。
    */
-  const handleSingleSelectChange = (
+  const handleSingleSelectChange = async (
     field: TaskFieldConfigViewItem,
     event: SingleSelectChangeEvent
   ) => {
@@ -312,9 +382,30 @@ export const useTaskSelectHandlers = ({
     /**
      * OTC 推广任务的字段联动示例。
      */
-    if (taskTypeId.value === '47') {
-      if (field.alias === 'scorePackage') {
-        clearFieldValueByAlias('realTaskTypeId')
+    if (taskTypeId.value === '47' && field.alias === 'scorePackage') {
+      clearFieldValueByAlias('drugs')
+      clearFieldValueByAlias('realTaskTypeId')
+      otcScoreList.value = []
+
+      const requestVersion = ++otcScoreRequestVersion
+
+      try {
+        const res = await getPackageRuleTaskTypeApi(event.value)
+
+        if (requestVersion === otcScoreRequestVersion) {
+          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',
+        })
       }
     }
   }
@@ -504,6 +595,9 @@ export const useTaskSelectHandlers = ({
     handleSingleSelectBeforeOpen,
     handleSingleSelectChange,
     handleMultipleSelectBeforeOpen,
+    otcEstimatedScore,
+    otcLargestTotal,
+    otcIsScoreOverLimit,
 
     /**
      * 以下方法可按需在页面或其他业务逻辑中使用。

+ 156 - 106
src/pages-task/task-form/index.vue

@@ -7,128 +7,144 @@
       :show-scrollbar="false"
       :bounces="false"
     >
-      <view class="task-form__content" :style="contentStyle">
-        <view v-if="showNotice" class="workbench-notice">
-          <wd-notice-bar
-            :text="noticeText"
-            prefix="warn-bold"
-            type="info"
-            color="#f90"
-            background-color="#fdf6ec"
-            custom-style="border-radius: 12rpx; min-height: 64rpx; padding: 0 20rpx;"
-          />
-        </view>
-
-        <view class="task-form-fields">
-          <template v-for="item in taskFieldConfigList" :key="item.id">
-            <view v-if="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"
-                :before-open="() => handleSingleSelectBeforeOpen(item)"
-                @change="handleTaskSingleSelectChange(item, $event)"
-              />
-
-              <InputAutoSelect
-                v-else-if="isInputAutoSelect(item.taskFiledType)"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-                :task-field-config="item"
-                :before-open="() => handleSingleSelectBeforeOpen(item)"
-                @change="handleSingleSelectChange(item, $event)"
-              />
-
-              <SingleText
-                v-else-if="isTextInput(item.taskFiledType)"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-                :task-field-config="item"
-                :readonly="isReadonlyText(item.taskFiledType)"
-              />
-
-              <DateTimePicker
-                v-else-if="isDateTime(item.taskFiledType)"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-                :task-field-config="item"
-              />
-
-              <TaskLocation
-                v-else-if="isLocation(item.taskFiledType)"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-                :task-field-config="item"
-              />
-
-              <template v-else-if="isImgUpload(item.taskFiledType)">
-                <view class="line" />
-
-                <ImgUpload
+      <view class="task-form__content" :style="isQuestionnaireTask ? undefined : contentStyle">
+        <QuestionnaireTask v-if="isQuestionnaireTask" />
+
+        <template v-else>
+          <view v-if="showNotice" class="workbench-notice">
+            <wd-notice-bar
+              :text="noticeText"
+              prefix="warn-bold"
+              type="info"
+              color="#f90"
+              background-color="#fdf6ec"
+              custom-style="border-radius: 12rpx; min-height: 64rpx; padding: 0 20rpx;"
+            />
+          </view>
+
+          <view class="task-form-fields">
+            <template v-for="item in taskFieldConfigList" :key="item.id">
+              <view v-if="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"
+                  :before-open="() => handleSingleSelectBeforeOpen(item)"
+                  @change="handleTaskSingleSelectChange(item, $event)"
                 />
-              </template>
 
-              <template v-else-if="isSign(item.taskFiledType)">
-                <view class="line" />
+                <InputAutoSelect
+                  v-else-if="isInputAutoSelect(item.taskFiledType)"
+                  v-model:label="form.value[item.id].label"
+                  v-model:value="form.value[item.id].value"
+                  :task-field-config="item"
+                  :before-open="() => handleSingleSelectBeforeOpen(item)"
+                  @change="handleSingleSelectChange(item, $event)"
+                />
 
-                <Sign
+                <SingleText
+                  v-else-if="isTextInput(item.taskFiledType)"
                   v-model:label="form.value[item.id].label"
                   v-model:value="form.value[item.id].value"
                   :task-field-config="item"
+                  :readonly="isReadonlyText(item.taskFiledType)"
                 />
-              </template>
-
-              <MultipleSelect
-                v-else-if="isMultipleSelect(item.taskFiledType)"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-                :task-field-config="item"
-                :before-open="() => handleMultipleSelectBeforeOpen(item)"
-              />
-
-              <DataTimeRange
-                v-else-if="isDateTimeRange(item.taskFiledType)"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-                :task-field-config="item"
-              />
-
-              <template v-else-if="isLongText(item.taskFiledType) && item.show">
-                <view class="line" />
-
-                <LongText
+
+                <DateTimePicker
+                  v-else-if="isDateTime(item.taskFiledType)"
                   v-model:label="form.value[item.id].label"
                   v-model:value="form.value[item.id].value"
                   :task-field-config="item"
                 />
 
-                <view class="line" />
-              </template>
+                <TaskLocation
+                  v-else-if="isLocation(item.taskFiledType)"
+                  v-model:label="form.value[item.id].label"
+                  v-model:value="form.value[item.id].value"
+                  :task-field-config="item"
+                />
+
+                <template v-else-if="isImgUpload(item.taskFiledType)">
+                  <view class="line" />
+
+                  <ImgUpload
+                    v-model:label="form.value[item.id].label"
+                    v-model:value="form.value[item.id].value"
+                    :task-field-config="item"
+                  />
+                </template>
+
+                <template v-else-if="isSign(item.taskFiledType)">
+                  <view class="line" />
+
+                  <Sign
+                    v-model:label="form.value[item.id].label"
+                    v-model:value="form.value[item.id].value"
+                    :task-field-config="item"
+                  />
+                </template>
+
+                <MultipleSelect
+                  v-else-if="isMultipleSelect(item.taskFiledType)"
+                  v-model:label="form.value[item.id].label"
+                  v-model:value="form.value[item.id].value"
+                  :task-field-config="item"
+                  :before-open="() => handleMultipleSelectBeforeOpen(item)"
+                />
+
+                <DataTimeRange
+                  v-else-if="isDateTimeRange(item.taskFiledType)"
+                  v-model:label="form.value[item.id].label"
+                  v-model:value="form.value[item.id].value"
+                  :task-field-config="item"
+                />
+
+                <template v-else-if="isLongText(item.taskFiledType) && item.show">
+                  <view class="line" />
+
+                  <LongText
+                    v-model:label="form.value[item.id].label"
+                    v-model:value="form.value[item.id].value"
+                    :task-field-config="item"
+                  />
 
-              <Area
-                v-else-if="isArea(item.taskFiledType)"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-                :task-field-config="item"
-                :select-level="selectLevel"
-              />
+                  <view class="line" />
+                </template>
+
+                <Area
+                  v-else-if="isArea(item.taskFiledType)"
+                  v-model:label="form.value[item.id].label"
+                  v-model:value="form.value[item.id].value"
+                  :task-field-config="item"
+                  :select-level="selectLevel"
+                />
+              </view>
+            </template>
+          </view>
+
+          <view v-if="activeTaskTypeId === '47'" class="otc-estimated-score">
+            <view class="otc-estimated-score__value">预计积分:{{ otcEstimatedScore }}</view>
+            <view v-if="otcIsScoreOverLimit" class="otc-estimated-score__limit">
+              该服务获取积分上限为{{ otcLargestTotal }}分
             </view>
-          </template>
-        </view>
+          </view>
+        </template>
       </view>
     </scroll-view>
 
     <DuplicateImageDialog
+      v-if="!isQuestionnaireTask"
       v-model="duplicateImageVisible"
       :images="duplicateImageList"
       @confirm="clearDuplicateImages"
     />
 
-    <view v-if="taskFieldConfigList.length" class="task-form-footer" :style="footerStyle">
+    <view
+      v-if="!isQuestionnaireTask && taskFieldConfigList.length"
+      class="task-form-footer"
+      :style="footerStyle"
+    >
       <button
         class="task-form-footer__button task-form-footer__button--save"
         hover-class="task-form-footer__button--hover"
@@ -149,7 +165,7 @@
 </template>
 
 <script setup lang="ts">
-import { ref } from 'vue'
+import { computed, ref } from 'vue'
 
 import { onLoad } from '@dcloudio/uni-app'
 
@@ -161,6 +177,7 @@ import ImgUpload from './components/ImgUpload.vue'
 import InputAutoSelect from './components/InputAutoSelect.vue'
 import LongText from './components/LongText.vue'
 import MultipleSelect from './components/MultipleSelect.vue'
+import QuestionnaireTask from './components/QuestionnaireTask.vue'
 import Sign from './components/Sign.vue'
 import SingleSelect from './components/SingleSelect.vue'
 import SingleText from './components/SingleText.vue'
@@ -182,15 +199,28 @@ interface PageLoadOptions {
 
 const taskTypeId = ref('')
 
-const { form, taskFieldConfigList, activeTaskTypeId, loadTaskForm, handleDerivedFieldChange } =
-  useTaskForm()
+const {
+  form,
+  taskFieldConfigList,
+  activeTaskTypeId,
+  taskTypes,
+  loadTaskForm,
+  handleDerivedFieldChange,
+} = useTaskForm()
 
-const { handleSingleSelectBeforeOpen, handleSingleSelectChange, handleMultipleSelectBeforeOpen } =
-  useTaskSelectHandlers({
-    taskTypeId: activeTaskTypeId,
-    form,
-    taskFieldConfigList,
-  })
+const {
+  handleSingleSelectBeforeOpen,
+  handleSingleSelectChange,
+  handleMultipleSelectBeforeOpen,
+  otcEstimatedScore,
+  otcLargestTotal,
+  otcIsScoreOverLimit,
+} = useTaskSelectHandlers({
+  taskTypeId: activeTaskTypeId,
+  form,
+  taskFieldConfigList,
+  taskTypes,
+})
 
 const {
   duplicateImageVisible,
@@ -222,6 +252,8 @@ const { showNotice, noticeText, selectLevel, initializeTaskFormPage } = useTaskF
 
 const { contentStyle, footerStyle, initSafeArea } = useTaskFormSafeArea()
 
+const isQuestionnaireTask = computed(() => taskTypeId.value === '801')
+
 const decodeRouteParam = (value?: string): string => {
   if (!value) return ''
 
@@ -236,7 +268,7 @@ const handleTaskSingleSelectChange = (
   field: TaskFieldConfigViewItem,
   event: SingleSelectChangeEvent
 ) => {
-  handleSingleSelectChange(field, event)
+  void handleSingleSelectChange(field, event)
   handleDerivedFieldChange(field, event)
 }
 
@@ -259,6 +291,10 @@ onLoad(async (options: PageLoadOptions = {}) => {
     taskTitle: decodeRouteParam(options.taskTitle),
   })
 
+  if (isQuestionnaireTask.value) {
+    return
+  }
+
   await loadTaskForm(taskTypeId.value)
 })
 </script>
@@ -296,6 +332,20 @@ onLoad(async (options: PageLoadOptions = {}) => {
   background: #fff;
 }
 
+.otc-estimated-score {
+  min-height: 120rpx;
+  padding: 28rpx;
+  box-sizing: border-box;
+  font-size: 30rpx;
+  line-height: 44rpx;
+  color: #333;
+}
+
+.otc-estimated-score__limit {
+  margin-top: 12rpx;
+  color: #cd423e;
+}
+
 .line {
   width: 100%;
   height: 20rpx;

+ 49 - 0
src/pages-task/task-form/utils/questionnaire.ts

@@ -0,0 +1,49 @@
+export type QuestionnaireEnvironment = 'prod' | 'demo' | 'pre' | 'luoxin'
+
+interface BuildQuestionnaireUrlOptions {
+  questionnaireUrl: string
+  packageId: string
+  environment: QuestionnaireEnvironment
+  phone?: string
+  usesQuestionnaireList: boolean
+}
+
+export const resolveQuestionnaireEnvironment = (baseApi: string): QuestionnaireEnvironment => {
+  const normalizedBaseApi = baseApi.trim().toLowerCase()
+
+  if (/^https?:\/\/luoxin\.yaoyi\.net(?:\/|$)/.test(normalizedBaseApi)) {
+    return 'luoxin'
+  }
+
+  if (/^https?:\/\/api\.yaoyi\.net(?:\/|$)/.test(normalizedBaseApi)) {
+    return 'prod'
+  }
+
+  if (/^https?:\/\/demo\.yaoyi\.net(?:\/|$)/.test(normalizedBaseApi)) {
+    return 'demo'
+  }
+
+  return 'pre'
+}
+
+/**
+ * 保持 801 原业务的拼接协议:
+ * - 接口问卷 URL 已预留 yycPkgId,直接追加 packageId 和 env。
+ * - 手填 URL 追加 yycUsername、yycPkgId 和 env。
+ */
+export const buildQuestionnaireQrContent = ({
+  questionnaireUrl,
+  packageId,
+  environment,
+  phone = '',
+  usesQuestionnaireList,
+}: BuildQuestionnaireUrlOptions): string => {
+  const normalizedUrl = questionnaireUrl.trim()
+  const normalizedPackageId = packageId.trim()
+
+  if (usesQuestionnaireList) {
+    return `${normalizedUrl}${normalizedPackageId}&env=${environment}`
+  }
+
+  return `${normalizedUrl}?yycUsername=${encodeURIComponent(phone.trim())}&yycPkgId=${encodeURIComponent(normalizedPackageId)}&env=${environment}`
+}

+ 19 - 0
src/services/modules/task/taskFrom/index.ts

@@ -1,5 +1,8 @@
 import http from '../../../index'
 import type {
+  PackageRuleTaskTypeResponse,
+  QuestionnaireListItem,
+  QuestionnaireTaskContentConfigResponse,
   SaveTaskContentBody,
   SaveTaskContentQuery,
   SaveTaskContentResponse,
@@ -12,6 +15,12 @@ export const getTaskContentConfigByTaskTypeIdApi = (id: string) => {
   )
 }
 
+export const getQuestionnaireTaskContentConfigApi = () => {
+  return http.get<QuestionnaireTaskContentConfigResponse>(
+    '/admin/api/getTaskContentConfigByTaskTypeId/801'
+  )
+}
+
 export const saveTaskContentApi = (
   saveTaskContentQuery: SaveTaskContentQuery,
   saveTaskContentBody: SaveTaskContentBody
@@ -39,3 +48,13 @@ export const checkAreaTypeApi = (deptId: string) => {
 export const getListDrugTableApi = (packageId: string) => {
   return http.get<string[]>(`/admin/wmdadrugentdrugtable/list-drug-table?packageId=${packageId}`)
 }
+
+export const getQuestionnaireListApi = (packageId: string) => {
+  return http.get<QuestionnaireListItem[]>(
+    `/admin/wmdadrugentdrugtable/list-lit?packageId=${packageId}`
+  )
+}
+
+export const getPackageRuleTaskTypeApi = (packageId: string | number) => {
+  return http.get<PackageRuleTaskTypeResponse>(`/admin/api/package-rule-task-type/${packageId}`)
+}

+ 45 - 0
src/services/modules/task/taskFrom/type.d.ts

@@ -3,6 +3,35 @@ import type { DictItem } from '@/services/modules/common/type'
 export interface TaskContentConfig {
   config: TaskFieldConfigItem[]
   dict: TaskFieldDictMap
+  taskTypes?: TaskTypeItem[]
+}
+
+export interface TaskTypeItem {
+  baseId: string
+  createTime: string
+  createUser: number | null
+  delFlag: string
+  deptId: number | null
+  durgentId: string | null
+  enableFlag: string
+  id: string
+  level1Id: string | null
+  midCategoryName: string
+  parentId: string
+  realFlag: string | null
+  ruleId: string
+  score: number
+  status: string
+  taskEntId: string | null
+  taskReceiverType: string
+  taskRemark: string | null
+  taskScoreEnd: number
+  taskScoreStart: number
+  taskTypeLevel: string
+  taskTypeName: string
+  tenantId: number
+  updateTime: string
+  updateUser: number | null
 }
 
 export interface TaskContentConfigDeriveFrom {
@@ -15,6 +44,10 @@ export type MultipleTaskContentConfig = {
   [taskTypeId: string]: TaskContentConfig | TaskContentConfigDeriveFrom
 }
 
+export interface QuestionnaireTaskContentConfigResponse {
+  '801': TaskContentConfig
+}
+
 export type TaskContentConfigByTaskTypeIdResponse = TaskContentConfig | MultipleTaskContentConfig
 
 export interface TaskFieldConfigItem {
@@ -72,3 +105,15 @@ export interface SaveTaskContentSuccessData {
 }
 
 export type SaveTaskContentResponse = SaveTaskContentSuccessData | string[]
+
+export type PackageRuleTaskTypeResponse = PackageRuleTaskTypeItem[]
+
+export interface PackageRuleTaskTypeItem {
+  taskTypeName: string
+  score: string
+}
+
+export interface QuestionnaireListItem {
+  label: string
+  url: string
+}

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

@@ -0,0 +1,21 @@
+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>
+  }
+}

+ 1 - 1
src/types/wotui-global.d.ts

@@ -3,4 +3,4 @@ declare interface PickerConfirmEvent {
   value: string[]
 }
 
-type FieldValue = string | number | undefined
+type FieldValue = string

+ 3 - 4
tsconfig.json

@@ -1,13 +1,12 @@
 {
-  "extends": "@vue/tsconfig/tsconfig.json",
+  "extends": "@vue/tsconfig/tsconfig.dom.json",
   "compilerOptions": {
     "sourceMap": true,
-    "baseUrl": ".",
     "paths": {
-      "@/*": ["src/*"]
+      "@/*": ["./src/*"]
     },
-    "lib": ["esnext", "dom"],
     "types": [
+      "node",
       "@dcloudio/types",
       "@uni-helper/uni-app-types",
       "@uni-helper/uni-ui-types",

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff