Sfoglia il codice sorgente

完成业务修改表单提交功能接口

yuanmingze 2 mesi fa
parent
commit
aa6021434a

+ 1 - 0
components.d.ts

@@ -23,6 +23,7 @@ declare module 'vue' {
     WdForm: typeof import('@wot-ui/ui/components/wd-form/wd-form.vue')['default']
     WdFormItem: typeof import('@wot-ui/ui/components/wd-form-item/wd-form-item.vue')['default']
     WdIcon: typeof import('@wot-ui/ui/components/wd-icon/wd-icon.vue')['default']
+    WdImg: typeof import('@wot-ui/ui/components/wd-img/wd-img.vue')['default']
     WdInput: typeof import('@wot-ui/ui/components/wd-input/wd-input.vue')['default']
     WdLoading: typeof import('@wot-ui/ui/components/wd-loading/wd-loading.vue')['default']
     WdNavbar: typeof import('@wot-ui/ui/components/wd-navbar/wd-navbar.vue')['default']

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

@@ -114,8 +114,14 @@ const getListTaskTypeIds = async () => {
 }
 
 const toTask = (item: TaskItem) => {
+  const taskTypeId = String(item.id)
+  const taskTitle = String(item.title ?? '')
+
   uni.navigateTo({
-    url: `/pages-task/task-form/index?taskId=${item.id}&taskTitle=${item.title}`,
+    url:
+      `/pages-task/task-form/index` +
+      `?taskTypeId=${encodeURIComponent(taskTypeId)}` +
+      `&taskTitle=${encodeURIComponent(taskTitle)}`,
   })
 }
 onLoad(async (options: PageLoadOptions = {}) => {

+ 0 - 10
src/pages-task/task-form/components/123

@@ -1,10 +0,0 @@
-<template>
-
-</template>
-
-<script setup lang="ts">
-
-</script>
-
-<style lang="scss" scoped>
-</style>

+ 171 - 19
src/pages-task/task-form/components/DateTimePicker.vue

@@ -1,18 +1,18 @@
 <template>
-  <view class="single-select">
+  <view class="date-time-picker">
     <wd-cell
       title-width="200rpx"
-      :placeholder="'请选择' + taskFieldConfig.taskFiledValue"
+      :placeholder="`请选择${taskFieldConfig.taskFiledValue}`"
       :title="taskFieldConfig.taskFiledValue"
       :required="taskFieldConfig.isMustfill === '1'"
       :value="cellValue"
       :is-link="!isDisabled"
-      @click="pickerClick"
+      @click="handlePickerOpen"
     />
 
     <wd-datetime-picker
       v-model="datetimePickerValue"
-      v-model:visible="datetimePickerShow"
+      v-model:visible="datetimePickerVisible"
       type="datetime"
       @confirm="handleConfirm"
     />
@@ -22,53 +22,205 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 
-import dayjs from 'dayjs'
+import dayjs, { type Dayjs } from 'dayjs'
+import customParseFormat from 'dayjs/plugin/customParseFormat'
 
 import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
+dayjs.extend(customParseFormat)
+
+type FieldValue = string | number | undefined
+
+type DateTimeTaskFieldConfig = Pick<TaskFieldConfigItem, 'taskFiledValue' | 'isMustfill'>
+
 interface Props {
-  taskFieldConfig: TaskFieldConfigItem
+  taskFieldConfig: DateTimeTaskFieldConfig
   disabled?: boolean
 }
 
+interface DateTimeConfirmPayload {
+  value: number
+}
+
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
 })
 
 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 datetimePickerShow = ref(false)
-const datetimePickerValue = ref(Date.now())
+const datetimePickerVisible = ref(false)
+const datetimePickerValue = ref(createCurrentTimestamp())
 
 const isDisabled = computed(() => props.disabled)
 
-const cellValue = computed(() => label.value || value.value || '')
+const cellValue = computed(() => {
+  return typeof label.value === 'string' ? label.value : ''
+})
+
+/**
+ * 当前时间统一精确到分钟。
+ */
+function createCurrentTimestamp(): number {
+  return dayjs().second(0).millisecond(0).valueOf()
+}
+
+/**
+ * 将秒级或毫秒级时间戳统一转换为毫秒级时间戳。
+ */
+function normalizeNumericTimestamp(timestamp: number): number {
+  return Math.abs(timestamp) < SECOND_TIMESTAMP_THRESHOLD ? timestamp * 1000 : timestamp
+}
+
+/**
+ * 解析外部表单值。
+ *
+ * 兼容:
+ * - YYYY-MM-DD HH:mm
+ * - 秒级时间戳
+ * - 毫秒级时间戳
+ * - 数字字符串时间戳
+ * - 旧日期字符串
+ */
+function parseDateTime(rawValue: FieldValue): Dayjs | null {
+  if (rawValue === undefined || rawValue === null || rawValue === '') {
+    return null
+  }
+
+  if (typeof rawValue === 'number') {
+    if (!Number.isFinite(rawValue)) {
+      return null
+    }
+
+    const parsedDate = dayjs(normalizeNumericTimestamp(rawValue))
+
+    return parsedDate.isValid() ? parsedDate : null
+  }
+
+  const normalizedValue = rawValue.trim()
+
+  if (!normalizedValue) {
+    return null
+  }
+
+  if (/^-?\d+$/.test(normalizedValue)) {
+    const numericValue = Number(normalizedValue)
+
+    if (!Number.isFinite(numericValue)) {
+      return null
+    }
+
+    const parsedDate = dayjs(normalizeNumericTimestamp(numericValue))
+
+    return parsedDate.isValid() ? parsedDate : null
+  }
+
+  /**
+   * 优先严格按照表单标准格式解析。
+   */
+  const standardDate = dayjs(normalizedValue, DATE_TIME_FORMAT, true)
+
+  if (standardDate.isValid()) {
+    return standardDate
+  }
+
+  /**
+   * 兼容旧数据中的其他日期格式。
+   */
+  const legacyDate = dayjs(normalizedValue)
+
+  return legacyDate.isValid() ? legacyDate : null
+}
+
+/**
+ * 转换为表单统一格式。
+ */
+function formatDateTime(date: Dayjs): string {
+  return date.second(0).millisecond(0).format(DATE_TIME_FORMAT)
+}
+
+/**
+ * 同步 Picker、value 和 label。
+ *
+ * value、label 都使用 YYYY-MM-DD HH:mm。
+ */
+function updateDateTime(date: Dayjs): void {
+  const normalizedDate = date.second(0).millisecond(0)
+  const formattedValue = formatDateTime(normalizedDate)
+
+  datetimePickerValue.value = normalizedDate.valueOf()
+  value.value = formattedValue
+  label.value = formattedValue
+}
 
+/**
+ * 自动回显,同时将旧时间戳数据转换为标准日期字符串。
+ */
 watch(
   value,
   (newValue) => {
-    if (!newValue) return
+    const parsedDate = parseDateTime(newValue)
+
+    if (!parsedDate) {
+      datetimePickerValue.value = createCurrentTimestamp()
+
+      if (label.value !== '') {
+        label.value = ''
+      }
+
+      return
+    }
+
+    const normalizedDate = parsedDate.second(0).millisecond(0)
 
-    datetimePickerValue.value = new Date(String(newValue).replace(' ', 'T')).getTime()
+    const formattedValue = normalizedDate.format(DATE_TIME_FORMAT)
+
+    datetimePickerValue.value = normalizedDate.valueOf()
+
+    if (value.value !== formattedValue) {
+      value.value = formattedValue
+    }
+
+    if (label.value !== formattedValue) {
+      label.value = formattedValue
+    }
   },
   {
     immediate: true,
   }
 )
 
-const pickerClick = () => {
-  if (isDisabled.value) return
+function handlePickerOpen(): void {
+  if (isDisabled.value) {
+    return
+  }
 
-  datetimePickerShow.value = true
+  const parsedDate = parseDateTime(value.value) ?? parseDateTime(label.value)
+
+  datetimePickerValue.value = parsedDate
+    ? parsedDate.second(0).millisecond(0).valueOf()
+    : createCurrentTimestamp()
+
+  datetimePickerVisible.value = true
 }
 
-const handleConfirm = (timestamp: { value: number }) => {
-  const formattedValue = dayjs(timestamp.value).format(DATE_TIME_FORMAT)
-  value.value = formattedValue
-  label.value = formattedValue
-  datetimePickerValue.value = timestamp.value
+function handleConfirm({ value: timestamp }: DateTimeConfirmPayload): void {
+  const selectedDate = dayjs(timestamp)
+
+  if (!selectedDate.isValid()) {
+    return
+  }
+
+  updateDateTime(selectedDate)
+  datetimePickerVisible.value = false
 }
 </script>
+
+<style scoped lang="scss">
+.date-time-picker {
+  width: 100%;
+}
+</style>

+ 266 - 0
src/pages-task/task-form/components/DuplicateImageDialog.vue

@@ -0,0 +1,266 @@
+<template>
+  <wd-popup
+    v-model="visible"
+    position="center"
+    root-portal
+    :z-index="300"
+    :close-on-click-modal="false"
+    custom-style="
+      width: 650rpx;
+      max-width: calc(100vw - 64rpx);
+      overflow: hidden;
+      border-radius: 32rpx;
+      background: #ffffff;
+    "
+    @after-leave="handleAfterLeave"
+  >
+    <view class="duplicate-dialog">
+      <view class="duplicate-dialog__header">
+        <view class="duplicate-dialog__icon">
+          <text class="duplicate-dialog__icon-text">!</text>
+        </view>
+
+        <view class="duplicate-dialog__title"> 提交的图片重复 </view>
+
+        <view class="duplicate-dialog__description">
+          检测到以下图片已被提交,请删除或更换后重新提交
+        </view>
+      </view>
+
+      <scroll-view class="duplicate-dialog__scroll" scroll-y enhanced :show-scrollbar="false">
+        <view class="duplicate-dialog__grid">
+          <view
+            v-for="(image, index) in images"
+            :key="`${image}-${index}`"
+            class="duplicate-dialog__image-item"
+          >
+            <wd-img
+              width="100%"
+              height="252rpx"
+              mode="aspectFill"
+              radius="20rpx"
+              :src="image"
+              :preview-src="image"
+              :enable-preview="true"
+            >
+              <template #loading>
+                <view class="duplicate-dialog__image-status">
+                  <wd-loading />
+                </view>
+              </template>
+
+              <template #error>
+                <view class="duplicate-dialog__image-status">
+                  <text>图片加载失败</text>
+                </view>
+              </template>
+            </wd-img>
+
+            <view class="duplicate-dialog__image-index">
+              {{ index + 1 }}
+            </view>
+          </view>
+        </view>
+      </scroll-view>
+
+      <view class="duplicate-dialog__summary">
+        共检测到
+        <text class="duplicate-dialog__count">
+          {{ images.length }}
+        </text>
+        张重复图片
+      </view>
+
+      <view class="duplicate-dialog__footer">
+        <wd-button
+          block
+          type="primary"
+          :disabled="closing"
+          custom-style="
+            width: 100%;
+            height: 88rpx;
+            border-radius: 20rpx;
+            font-size: 30rpx;
+          "
+          @click="handleConfirm"
+        >
+          返回修改
+        </wd-button>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+interface Props {
+  images: string[]
+}
+
+withDefaults(defineProps<Props>(), {
+  images: () => [],
+})
+
+const emit = defineEmits<{
+  confirm: []
+}>()
+
+const visible = defineModel<boolean>({
+  default: false,
+})
+
+/**
+ * 标记是否由“返回修改”按钮主动关闭。
+ *
+ * 只有按钮关闭并且离场动画结束后,
+ * 才通知父组件清空重复图片。
+ */
+const pendingConfirm = ref(false)
+const closing = ref(false)
+
+const handleConfirm = (): void => {
+  if (closing.value) {
+    return
+  }
+
+  closing.value = true
+  pendingConfirm.value = true
+  visible.value = false
+}
+
+/**
+ * Popup 离场动画结束后再清空图片,
+ * 避免动画过程中列表先变为空。
+ */
+const handleAfterLeave = (): void => {
+  closing.value = false
+
+  if (!pendingConfirm.value) {
+    return
+  }
+
+  pendingConfirm.value = false
+  emit('confirm')
+}
+</script>
+
+<style scoped lang="scss">
+.duplicate-dialog {
+  width: 100%;
+  overflow: hidden;
+  background: #fff;
+}
+
+.duplicate-dialog__header {
+  padding: 40rpx 36rpx 28rpx;
+  text-align: center;
+  background: radial-gradient(circle at top center, rgba(255, 77, 79, 0.12), transparent 64%), #fff;
+}
+
+.duplicate-dialog__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 88rpx;
+  height: 88rpx;
+  margin: 0 auto 24rpx;
+  border: 12rpx solid rgba(255, 77, 79, 0.12);
+  border-radius: 50%;
+  color: #fff;
+  background: #ff4d4f;
+  box-sizing: border-box;
+}
+
+.duplicate-dialog__icon-text {
+  font-size: 46rpx;
+  font-weight: 700;
+  line-height: 1;
+}
+
+.duplicate-dialog__title {
+  color: #1f2329;
+  font-size: 36rpx;
+  font-weight: 600;
+  line-height: 50rpx;
+}
+
+.duplicate-dialog__description {
+  margin-top: 12rpx;
+  color: #86909c;
+  font-size: 26rpx;
+  line-height: 40rpx;
+}
+
+.duplicate-dialog__scroll {
+  width: 100%;
+  max-height: 560rpx;
+  padding: 0 32rpx;
+  box-sizing: border-box;
+}
+
+.duplicate-dialog__grid {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 18rpx;
+  padding: 8rpx 0;
+}
+
+.duplicate-dialog__image-item {
+  position: relative;
+  min-width: 0;
+  overflow: hidden;
+  border: 2rpx solid #f0f0f0;
+  border-radius: 22rpx;
+  background: #f7f8fa;
+  box-shadow: 0 8rpx 20rpx rgba(0, 0, 0, 0.06);
+}
+
+.duplicate-dialog__image-status {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 252rpx;
+  color: #86909c;
+  font-size: 24rpx;
+  background: #f7f8fa;
+}
+
+.duplicate-dialog__image-index {
+  position: absolute;
+  top: 12rpx;
+  left: 12rpx;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-width: 44rpx;
+  height: 44rpx;
+  padding: 0 12rpx;
+  border-radius: 22rpx;
+  color: #fff;
+  font-size: 22rpx;
+  line-height: 44rpx;
+  background: rgba(0, 0, 0, 0.58);
+  box-sizing: border-box;
+  backdrop-filter: blur(8rpx);
+}
+
+.duplicate-dialog__summary {
+  padding: 24rpx 32rpx 0;
+  color: #86909c;
+  font-size: 26rpx;
+  text-align: center;
+}
+
+.duplicate-dialog__count {
+  padding: 0 6rpx;
+  color: #ff4d4f;
+  font-size: 30rpx;
+  font-weight: 600;
+}
+
+.duplicate-dialog__footer {
+  padding: 28rpx 32rpx 36rpx;
+}
+</style>

+ 104 - 49
src/pages-task/task-form/components/MultipleSelect.vue

@@ -13,7 +13,7 @@
     <wd-select-picker
       v-model="pickerValue"
       v-model:visible="pickerShow"
-      :columns="dict"
+      :columns="pickerOptions"
       @confirm="handleSelect"
     />
   </view>
@@ -22,21 +22,28 @@
 <script setup lang="ts">
 import { computed, ref, watch } from 'vue'
 
-import type { DictItem } from '@/services/modules/common/type'
-import type { TaskFieldConfigItem, TaskFieldDictMap } from '@/services/modules/task/taskFrom/type'
+import type { TaskFieldConfigViewItem, TaskFieldOption } from '../composables/useTaskForm'
 
-type OptionValue = string | number
-type FieldValue = OptionValue[] | OptionValue | undefined
+type RawOptionValue = string | number
+
+type FieldValue = RawOptionValue[] | RawOptionValue | null | undefined
 
 interface SelectPickerConfirmEvent {
-  value: OptionValue[]
-  selectedItems: DictItem[]
+  value: RawOptionValue[]
+  selectedItems: TaskFieldOption[]
 }
 
 interface Props {
-  taskFieldConfig: TaskFieldConfigItem
-  taskFieldDict: TaskFieldDictMap
+  taskFieldConfig: TaskFieldConfigViewItem
   disabled?: boolean
+
+  /**
+   * 打开选择器前执行。
+   *
+   * 特殊字段可在页面回调中请求接口,
+   * 然后重新赋值当前字段的 options。
+   */
+  beforeOpen?: () => boolean | Promise<boolean>
 }
 
 const props = withDefaults(defineProps<Props>(), {
@@ -47,27 +54,24 @@ const label = defineModel<string | undefined>('label')
 const value = defineModel<FieldValue>('value')
 
 const pickerShow = ref(false)
-const pickerValue = ref<OptionValue[]>([])
-
-const isDisabled = computed(() => props.disabled)
-
-const dict = computed<DictItem[]>(() => {
-  const dictName = props.taskFieldConfig.dictGroupName
-  return dictName ? (props.taskFieldDict[dictName] ?? []) : []
+const opening = ref(false)
+const pickerValue = ref<string[]>([])
+
+/**
+ * 选项统一从字段配置中读取。
+ *
+ * 普通字段由 useTaskForm 初始化字典;
+ * 特殊字段由页面 beforeOpen 重新赋值。
+ */
+const pickerOptions = computed<TaskFieldOption[]>(() => {
+  return props.taskFieldConfig.options ?? []
 })
 
 const cellValue = computed(() => label.value ?? '')
 
-const pickerClick = () => {
-  if (isDisabled.value) return
-  pickerShow.value = true
-}
-
-const normalizeValue = (rawValue: FieldValue): OptionValue[] => {
+const normalizeValue = (rawValue: FieldValue): string[] => {
   if (Array.isArray(rawValue)) {
-    return rawValue.filter(
-      (item): item is OptionValue => item !== undefined && item !== null && item !== ''
-    )
+    return rawValue.filter((item) => item !== undefined && item !== null && item !== '').map(String)
   }
 
   if (rawValue === undefined || rawValue === null || rawValue === '') {
@@ -78,23 +82,19 @@ const normalizeValue = (rawValue: FieldValue): OptionValue[] => {
     return rawValue
       .split(',')
       .map((item) => item.trim())
-      .filter((item): item is string => item.length > 0)
+      .filter(Boolean)
   }
 
-  return [rawValue]
-}
-
-const isSameValue = (left: unknown, right: unknown) => {
-  return String(left) === String(right)
+  return [String(rawValue)]
 }
 
-const getSelectedItemsByValues = (selectedValues: OptionValue[]) => {
+const getSelectedItemsByValues = (selectedValues: string[]): TaskFieldOption[] => {
   return selectedValues
-    .map((selectedValue) => dict.value.find((item) => isSameValue(item.value, selectedValue)))
-    .filter((item): item is DictItem => Boolean(item))
+    .map((selectedValue) => pickerOptions.value.find((item) => item.value === selectedValue))
+    .filter((item): item is TaskFieldOption => Boolean(item))
 }
 
-const syncLabelByValue = (selectedValues: OptionValue[]) => {
+const syncLabelByValue = (selectedValues: string[]) => {
   const selectedItems = getSelectedItemsByValues(selectedValues)
 
   label.value = selectedItems.map((item) => item.label).join(',')
@@ -103,27 +103,23 @@ const syncLabelByValue = (selectedValues: OptionValue[]) => {
 const syncPickerValueByModelValue = () => {
   const normalizedValues = normalizeValue(value.value)
 
-  const matchedValues = normalizedValues
-    .map((selectedValue) => dict.value.find((item) => isSameValue(item.value, selectedValue)))
-    .filter((item): item is DictItem => Boolean(item))
-    .map((item) => item.value as OptionValue)
+  const matchedValues = normalizedValues.filter((selectedValue) =>
+    pickerOptions.value.some((item) => item.value === selectedValue)
+  )
 
   pickerValue.value = matchedValues
 
   syncLabelByValue(matchedValues)
 }
 
-const handleSelect = (option: SelectPickerConfirmEvent) => {
-  const selectedValues = option.value
-  const selectedItems = option.selectedItems
-
-  pickerValue.value = selectedValues
-  value.value = selectedValues.join(',')
-  label.value = selectedItems.map((item) => item.label).join(',')
-}
-
+/**
+ * 表单值或选项变化时,同步选择器状态。
+ *
+ * beforeOpen 中重新赋值 field.options 后,
+ * 此处也会自动重新匹配已有值。
+ */
 watch(
-  [() => value.value, dict],
+  [() => value.value, () => props.taskFieldConfig.options],
   () => {
     syncPickerValueByModelValue()
   },
@@ -132,4 +128,63 @@ watch(
     deep: true,
   }
 )
+
+const pickerClick = async () => {
+  if (props.disabled || opening.value) {
+    return
+  }
+
+  opening.value = true
+
+  try {
+    /**
+     * 页面可在 beforeOpen 中请求特殊接口,
+     * 并重新赋值 item.options。
+     */
+    const canOpen = await props.beforeOpen?.()
+
+    if (canOpen === false) {
+      return
+    }
+
+    /**
+     * beforeOpen 完成后读取最新 options。
+     */
+    if (!pickerOptions.value.length) {
+      uni.showToast({
+        title: '暂无可选内容',
+        icon: 'none',
+      })
+
+      return
+    }
+
+    syncPickerValueByModelValue()
+
+    pickerShow.value = true
+  } catch (error) {
+    console.error(`打开多选字段失败:${props.taskFieldConfig.taskFiledValue}`, error)
+
+    uni.showToast({
+      title: '选项加载失败,请稍后重试',
+      icon: 'none',
+    })
+  } finally {
+    opening.value = false
+  }
+}
+
+const handleSelect = (event: SelectPickerConfirmEvent) => {
+  const selectedValues = event.value.map(String)
+
+  pickerValue.value = selectedValues
+
+  /**
+   * 保持后端原有提交格式:
+   * 多选 value 存储为逗号分隔字符串。
+   */
+  value.value = selectedValues.join(',')
+
+  label.value = event.selectedItems.map((item) => item.label).join(',')
+}
 </script>

+ 166 - 20
src/pages-task/task-form/components/SingleSelect.vue

@@ -6,66 +6,212 @@
       :title="taskFieldConfig.taskFiledValue"
       :required="taskFieldConfig.isMustfill === '1'"
       :value="cellValue"
-      :is-link="!isDisabled"
+      is-link
       @click="pickerClick"
     />
 
     <wd-picker
       v-model="pickerValue"
       v-model:visible="pickerShow"
-      :columns="dict"
+      :columns="pickerOptions"
       @confirm="handleSelect"
     />
   </view>
 </template>
 
 <script setup lang="ts">
-import { computed, ref } from 'vue'
+import { computed, ref, watch } from 'vue'
 
-import type { DictItem } from '@/services/modules/common/type'
-import type { TaskFieldConfigItem, TaskFieldDictMap } from '@/services/modules/task/taskFrom/type'
+import type { TaskFieldConfigViewItem, TaskFieldOption } from '../composables/useTaskForm'
 
 type FieldValue = string | number | undefined
 
 interface PickerConfirmEvent {
-  selectedItems: DictItem[]
+  selectedItems: TaskFieldOption[]
+}
+
+export interface SingleSelectChangeEvent {
+  /**
+   * 修改前的值。
+   */
+  previousValue: FieldValue
+
+  /**
+   * 修改后的值。
+   */
+  value: string
+
+  /**
+   * 修改后的显示文本。
+   */
+  label: string
+
+  /**
+   * 当前选中的完整选项。
+   */
+  selectedItem: TaskFieldOption
 }
 
 interface Props {
-  taskFieldConfig: TaskFieldConfigItem
-  taskFieldDict: TaskFieldDictMap
+  taskFieldConfig: TaskFieldConfigViewItem
   disabled?: boolean
+
+  /**
+   * 打开选择器前执行。
+   *
+   * 可以在页面回调中请求特殊接口,
+   * 请求完成后重新赋值当前字段的 options。
+   */
+  beforeOpen?: () => boolean | Promise<boolean>
 }
 
 const props = withDefaults(defineProps<Props>(), {
   disabled: false,
+  beforeOpen: undefined,
 })
 
+const emit = defineEmits<{
+  /**
+   * 只有新值与原值不一致时才触发。
+   */
+  change: [event: SingleSelectChangeEvent]
+}>()
+
 const label = defineModel<FieldValue>('label')
 const value = defineModel<FieldValue>('value')
 
-const dict = ref<DictItem[]>([])
 const pickerShow = ref(false)
-const pickerValue = ref<(string | number)[]>([])
+const opening = ref(false)
+const pickerValue = ref<string[]>([])
 
-const isDisabled = computed(() => props.disabled)
+/**
+ * 直接读取字段配置上挂载的选项。
+ *
+ * 普通字段:
+ * useTaskForm 初始化时挂载字典数据。
+ *
+ * 特殊字段:
+ * 页面 beforeOpen 请求接口后重新赋值 options。
+ */
+const pickerOptions = computed<TaskFieldOption[]>(() => {
+  return props.taskFieldConfig.options ?? []
+})
+
+const cellValue = computed(() => {
+  return label.value === undefined ? '' : String(label.value)
+})
+
+/**
+ * 同步外部表单值和 picker 当前选中值。
+ */
+watch(
+  value,
+  (currentValue) => {
+    if (currentValue === undefined || currentValue === '') {
+      pickerValue.value = []
+      return
+    }
 
-const cellValue = computed(() => label.value ?? '')
+    pickerValue.value = [String(currentValue)]
+  },
+  {
+    immediate: true,
+  }
+)
+
+/**
+ * 比较新旧字段值。
+ *
+ * 将 number 和 string 转为字符串比较,
+ * 因此数字 1 和字符串 "1" 会被视为同一个值。
+ */
+const isSameValue = (previousValue: FieldValue, currentValue: string): boolean => {
+  if (previousValue === undefined || previousValue === '') {
+    return currentValue === ''
+  }
+
+  return String(previousValue) === currentValue
+}
 
-const pickerClick = () => {
-  if (isDisabled.value) return
+/**
+ * 仅当值真正发生变化时触发 change。
+ */
+const emitChangeIfNeeded = (previousValue: FieldValue, selectedItem: TaskFieldOption) => {
+  if (isSameValue(previousValue, selectedItem.value)) {
+    return
+  }
 
-  const dictName = props.taskFieldConfig.dictGroupName
-  dict.value = dictName ? (props.taskFieldDict[dictName] ?? []) : []
-  pickerShow.value = true
+  emit('change', {
+    previousValue,
+    value: selectedItem.value,
+    label: selectedItem.label,
+    selectedItem,
+  })
 }
 
-const handleSelect = (option: PickerConfirmEvent) => {
-  const selected = option.selectedItems[0]
-  if (!selected) return
+const pickerClick = async () => {
+  if (props.disabled || opening.value) {
+    return
+  }
+
+  opening.value = true
+
+  try {
+    /**
+     * beforeOpen 中可以请求特殊接口,
+     * 并重新给 taskFieldConfig.options 赋值。
+     */
+    const canOpen = await props.beforeOpen?.()
+
+    if (canOpen === false) {
+      return
+    }
+
+    /**
+     * beforeOpen 执行完成后再读取最新选项。
+     */
+    if (!pickerOptions.value.length) {
+      uni.showToast({
+        title: '暂无可选内容',
+        icon: 'none',
+      })
+
+      return
+    }
+
+    pickerShow.value = true
+  } catch (error) {
+    console.error(`打开单选字段失败:${props.taskFieldConfig.taskFiledValue}`, error)
+
+    uni.showToast({
+      title: '选项加载失败,请稍后重试',
+      icon: 'none',
+    })
+  } finally {
+    opening.value = false
+  }
+}
+
+const handleSelect = (event: PickerConfirmEvent) => {
+  const selected = event.selectedItems[0]
+
+  if (!selected) {
+    return
+  }
+
+  /**
+   * 必须在更新 v-model 之前保存原值。
+   */
+  const previousValue = value.value
 
   pickerValue.value = [selected.value]
+
   label.value = selected.label
   value.value = selected.value
+
+  /**
+   * 重复确认同一个选项时不会触发 change。
+   */
+  emitChangeIfNeeded(previousValue, selected)
 }
 </script>

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

@@ -10,7 +10,6 @@
         :type="inputType"
         :placeholder="'请输入' + taskFieldConfig.taskFiledValue"
         :clearable="!props.disabled && !props.readonly"
-        clear-trigger="focus"
         align-right
         :disabled="props.disabled"
         :readonly="props.readonly"

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

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

+ 0 - 15
src/pages-task/task-form/composables/useNavigation.ts

@@ -1,15 +0,0 @@
-import { onMounted } from 'vue'
-
-export const useNavigation = () => {
-  const setNavigationTitle = (title?: string) => {
-    if (!title) return
-
-    uni.setNavigationBarTitle({
-      title,
-    })
-  }
-
-  return {
-    setNavigationTitle,
-  }
-}

+ 89 - 0
src/pages-task/task-form/composables/useTaskFieldType.ts

@@ -0,0 +1,89 @@
+/**
+ * 动态表单字段类型集合。
+ *
+ * 后端增加或调整字段类型时,只需要修改当前文件。
+ */
+const FIELD_TYPES = {
+  singleSelect: new Set<string>(['domain', 'select']),
+  textInput: new Set<string>(['text', 'readonlytext', 'money', 'number']),
+  dateTime: new Set<string>(['datetime']),
+  location: new Set<string>(['map', 'mapwithimg', 'currPosi']),
+  imageUpload: new Set<string>(['img']),
+  sign: new Set<string>(['sign']),
+  multipleSelect: new Set<string>(['multiple_select']),
+  inputAutoSelect: new Set<string>(['inputautoselect']),
+  dateTimeRange: new Set<string>(['datatimerange']),
+  longText: new Set<string>(['longtext']),
+  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,
+  }
+}

+ 237 - 27
src/pages-task/task-form/composables/useTaskForm.ts

@@ -1,56 +1,266 @@
 import { ref } from 'vue'
 
+import dayjs from 'dayjs'
+
 import { getTaskContentConfigByTaskTypeIdApi } from '@/services/modules/task/taskFrom'
 import type { TaskFieldConfigItem, TaskFieldDictMap } from '@/services/modules/task/taskFrom/type'
 
-interface DynamicFormState {
-  value: Record<
-    string,
-    {
-      label: FieldValue
-      value: FieldValue
+export type FieldValue = string | number | undefined
+
+/**
+ * 日期字段展示格式。
+ */
+export const TASK_DATE_TIME_FORMAT = 'YYYY-MM-DD HH:mm'
+
+/**
+ * 这些 taskTypeId 的 datetime 字段不自动初始化当前时间。
+ */
+const EXCLUDED_AUTO_DATETIME_BASE_TYPE_IDS = new Set(['81', '85', '86', '87'])
+
+/**
+ * 单选、多选组件统一使用的选项结构。
+ *
+ * label、value 以及其他扩展属性统一转换为字符串。
+ */
+export interface TaskFieldOption {
+  label: string
+  value: string
+  [key: string]: string
+}
+
+export type TaskFieldConfigViewItem = Omit<TaskFieldConfigItem, 'id'> & {
+  id: string
+  options: TaskFieldOption[] | null
+}
+
+export interface DynamicFormFieldValue {
+  /**
+   * 页面展示值。
+   *
+   * datetime 字段使用 YYYY-MM-DD HH:mm。
+   */
+  label: FieldValue
+
+  /**
+   * 实际提交值。
+   *
+   * datetime 字段使用毫秒级时间戳。
+   */
+  value: FieldValue
+}
+
+export interface DynamicFormState {
+  value: Record<string, DynamicFormFieldValue>
+}
+
+const createEmptyFieldValue = (): DynamicFormFieldValue => ({
+  label: '',
+  value: '',
+})
+
+const createEmptyFormState = (): DynamicFormState => ({
+  value: {},
+})
+
+/**
+ * 将日期统一截断到分钟。
+ *
+ * 避免 label 只展示到分钟,但 value 仍包含秒和毫秒。
+ */
+const normalizeDateTimeToMinute = (date: ReturnType<typeof dayjs>): ReturnType<typeof dayjs> => {
+  return date.second(0).millisecond(0)
+}
+
+/**
+ * 判断当前 datetime 字段是否需要自动初始化当前时间。
+ */
+const shouldInitializeCurrentDateTime = (field: TaskFieldConfigViewItem): boolean => {
+  const fieldType = String(field.taskFiledType ?? '').trim()
+  const taskTypeId = String(field.taskTypeId ?? '').trim()
+
+  return fieldType === 'datetime' && !EXCLUDED_AUTO_DATETIME_BASE_TYPE_IDS.has(taskTypeId)
+}
+
+/**
+ * 创建字段初始值。
+ *
+ * 符合旧业务规则的 datetime 字段:
+ * - label 为 YYYY-MM-DD HH:mm
+ * - value 为毫秒级时间戳
+ *
+ * 其他字段初始化为空。
+ */
+const createInitialFieldValue = (
+  field: TaskFieldConfigViewItem,
+  initialDateTime: ReturnType<typeof dayjs>
+): DynamicFormFieldValue => {
+  if (!shouldInitializeCurrentDateTime(field)) {
+    return createEmptyFieldValue()
+  }
+
+  const normalizedDateTime = normalizeDateTimeToMinute(initialDateTime)
+
+  return {
+    label: normalizedDateTime.format(TASK_DATE_TIME_FORMAT),
+    value: normalizedDateTime.format(TASK_DATE_TIME_FORMAT),
+  }
+}
+
+/**
+ * 将字典或特殊接口返回的数据转换为统一选项格式。
+ *
+ * 可以在页面的 beforeOpen 中复用。
+ */
+export const serializeTaskFieldOptions = (source: unknown): TaskFieldOption[] => {
+  if (!Array.isArray(source)) {
+    return []
+  }
+
+  return source.reduce<TaskFieldOption[]>((result, item) => {
+    if (!item || typeof item !== 'object') {
+      return result
     }
-  >
+
+    const rawItem = item as Record<string, unknown>
+
+    const normalizedItem = Object.entries(rawItem).reduce<Record<string, string>>(
+      (record, [key, value]) => {
+        record[key] = value === undefined || value === null ? '' : String(value)
+
+        return record
+      },
+      {}
+    )
+
+    if (!normalizedItem.label || !normalizedItem.value) {
+      return result
+    }
+
+    result.push({
+      ...normalizedItem,
+      label: normalizedItem.label,
+      value: normalizedItem.value,
+    })
+
+    return result
+  }, [])
+}
+
+/**
+ * 获取字段已有的字典内容。
+ *
+ * 以下情况返回 null:
+ * 1. 字段没有 dictGroupName
+ * 2. 返回字典中不存在该分组
+ * 3. 对应字典不是数组
+ * 4. 对应字典没有有效选项
+ */
+const getFieldDictOptions = (
+  field: TaskFieldConfigItem,
+  dict: TaskFieldDictMap
+): TaskFieldOption[] | null => {
+  const dictName = field.dictGroupName?.trim()
+
+  if (!dictName) {
+    return null
+  }
+
+  const source = dict[dictName]
+
+  if (!Array.isArray(source)) {
+    return null
+  }
+
+  const options = serializeTaskFieldOptions(source)
+
+  return options.length > 0 ? options : null
 }
 
+/**
+ * 将接口原始字段配置转换成页面字段配置。
+ *
+ * 不处理任何特殊接口或特殊业务判断。
+ */
+const serializeTaskFieldConfig = (
+  config: TaskFieldConfigItem[],
+  dict: TaskFieldDictMap
+): TaskFieldConfigViewItem[] => {
+  return config.map((field) => ({
+    ...field,
+    id: String(field.id),
+    options: getFieldDictOptions(field, dict),
+  }))
+}
+
+/**
+ * 负责:
+ * 1. 获取动态表单配置
+ * 2. 将字段 id 转换为字符串
+ * 3. 挂载已有字典 options
+ * 4. 初始化动态表单值
+ * 5. 自动初始化符合条件的 datetime 字段
+ *
+ * 特殊字段的接口请求不在这里处理,
+ * 由页面的 beforeOpen 自行请求并覆盖 field.options。
+ */
 export const useTaskForm = () => {
-  const form = ref<DynamicFormState>({
-    value: {},
-  })
+  const form = ref<DynamicFormState>(createEmptyFormState())
 
-  const taskFieldConfigList = ref<TaskFieldConfigItem[]>([])
+  const taskFieldConfigList = ref<TaskFieldConfigViewItem[]>([])
 
-  const taskFieldDict = ref<TaskFieldDictMap>({})
+  const initFormState = (config: TaskFieldConfigViewItem[]): void => {
+    const formValue: DynamicFormState['value'] = {}
 
-  const initFormState = (config: TaskFieldConfigItem[]) => {
-    const value: DynamicFormState['value'] = {}
+    /**
+     * 同一次初始化使用同一个时间点,
+     * 避免多个 datetime 字段出现分钟边界差异。
+     */
+    const initialDateTime = dayjs()
 
-    config.forEach((item) => {
-      value[String(item.id)] = {
-        label: '',
-        value: '',
-      }
-    })
+    for (const field of config) {
+      formValue[field.id] = createInitialFieldValue(field, initialDateTime)
+    }
 
     form.value = {
-      value,
+      value: formValue,
     }
   }
 
-  const loadTaskForm = async (taskTypeId: string) => {
-    const res = await getTaskContentConfigByTaskTypeIdApi(taskTypeId)
+  const resetTaskForm = (): void => {
+    form.value = createEmptyFormState()
+    taskFieldConfigList.value = []
+  }
+
+  const loadTaskForm = async (taskTypeId: string): Promise<void> => {
+    const normalizedTaskTypeId = taskTypeId.trim()
+
+    if (!normalizedTaskTypeId) {
+      throw new Error('缺少 taskTypeId')
+    }
 
-    taskFieldConfigList.value = res.data.config
+    try {
+      const res = await getTaskContentConfigByTaskTypeIdApi(normalizedTaskTypeId)
 
-    taskFieldDict.value = res.data.dict
+      const rawConfig: TaskFieldConfigItem[] = Array.isArray(res.data?.config)
+        ? res.data.config
+        : []
 
-    initFormState(res.data.config)
+      const dict: TaskFieldDictMap = res.data?.dict ?? {}
+
+      const config = serializeTaskFieldConfig(rawConfig, dict)
+
+      taskFieldConfigList.value = config
+      initFormState(config)
+    } catch (error) {
+      resetTaskForm()
+      throw error
+    }
   }
 
   return {
     form,
     taskFieldConfigList,
-    taskFieldDict,
     loadTaskForm,
+    resetTaskForm,
   }
 }

+ 361 - 0
src/pages-task/task-form/composables/useTaskFormAction.ts

@@ -0,0 +1,361 @@
+import { type Ref, ref } from 'vue'
+
+import { draftTaskContentApi, saveTaskContentApi } from '@/services/modules/task/taskFrom'
+import type {
+  SaveTaskContentBody,
+  SaveTaskContentQuery,
+  SaveTaskContentSuccessData,
+} from '@/services/modules/task/taskFrom/type'
+
+import type { DynamicFormState, TaskFieldConfigViewItem } from './useTaskForm'
+
+interface UseTaskFormActionOptions {
+  taskTypeId: Ref<string>
+  form: Ref<DynamicFormState>
+  taskFieldConfigList: Ref<TaskFieldConfigViewItem[]>
+}
+
+type FormAction = 'save' | 'submit'
+
+const PACKAGE_FIELD_ALIAS = 'scorePackage'
+const IMAGE_FIELD_TYPE = 'img'
+const SUCCESS_TOAST_DURATION = 3000
+
+const ACTION_API_MAP = {
+  save: draftTaskContentApi,
+  submit: saveTaskContentApi,
+} satisfies Record<FormAction, typeof saveTaskContentApi>
+
+const ACTION_FAILURE_TEXT = {
+  save: '保存失败',
+  submit: '提交失败',
+} satisfies Record<FormAction, string>
+
+const isEmptyValue = (value: unknown): boolean => {
+  return value === undefined || value === null || String(value).trim() === ''
+}
+
+const normalizeSizeLimit = (value: unknown): number | null => {
+  if (value === undefined || value === null || value === '') {
+    return null
+  }
+
+  const size = Number(value)
+
+  if (!Number.isFinite(size) || size <= 0) {
+    return null
+  }
+
+  return Math.floor(size)
+}
+
+const getContentLength = (value: unknown): number => {
+  if (value === undefined || value === null) {
+    return 0
+  }
+
+  return Array.from(String(value).trim()).length
+}
+
+const getImageCount = (value: unknown): number => {
+  if (typeof value !== 'string') {
+    return 0
+  }
+
+  return value
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean).length
+}
+
+const showMessage = (title: string, duration?: number): void => {
+  uni.showToast({
+    title,
+    icon: 'none',
+    ...(duration === undefined ? {} : { duration }),
+  })
+}
+
+/**
+ * 从接口 data 中提取有效图片地址。
+ */
+const extractImageUrls = (data: unknown): string[] => {
+  if (!Array.isArray(data)) {
+    return []
+  }
+
+  const imageUrls = data
+    .filter((item): item is string => typeof item === 'string' && item.trim().length > 0)
+    .map((item) => item.trim())
+
+  return [...new Set(imageUrls)]
+}
+
+/**
+ * 判断是否为提交成功数据。
+ */
+const isSaveTaskContentSuccessData = (data: unknown): data is SaveTaskContentSuccessData => {
+  if (!data || typeof data !== 'object' || Array.isArray(data)) {
+    return false
+  }
+
+  const record = data as Record<string, unknown>
+
+  return typeof record.score === 'string' && typeof record.type === 'string'
+}
+
+export const useTaskFormAction = ({
+  taskTypeId,
+  form,
+  taskFieldConfigList,
+}: UseTaskFormActionOptions) => {
+  let actionLocked = false
+
+  const duplicateImageVisible = ref(false)
+  const duplicateImageList = ref<string[]>([])
+
+  const findPackageField = (): TaskFieldConfigViewItem | undefined => {
+    return taskFieldConfigList.value.find((field) => field.alias === PACKAGE_FIELD_ALIAS)
+  }
+
+  const getPackageId = (): string => {
+    const packageField = findPackageField()
+
+    if (!packageField) {
+      return ''
+    }
+
+    return String(form.value.value[packageField.id]?.value ?? '').trim()
+  }
+
+  const validateImageField = (field: TaskFieldConfigViewItem, value: unknown): boolean => {
+    const imageCount = getImageCount(value)
+
+    const configuredMinSize = normalizeSizeLimit(field.taskFiledMinsize)
+
+    const maxSize = normalizeSizeLimit(field.taskFiledMaxsize)
+
+    const minSize = field.isMustfill === '1' ? (configuredMinSize ?? 1) : configuredMinSize
+
+    if (imageCount === 0 && field.isMustfill !== '1') {
+      return true
+    }
+
+    if (minSize !== null && imageCount < minSize) {
+      showMessage(`${field.taskFiledValue}最少需要${minSize}张`)
+
+      return false
+    }
+
+    if (maxSize !== null && imageCount > maxSize) {
+      showMessage(`${field.taskFiledValue}最多上传${maxSize}张`)
+
+      return false
+    }
+
+    return true
+  }
+
+  const validateContentField = (field: TaskFieldConfigViewItem, value: unknown): boolean => {
+    if (isEmptyValue(value)) {
+      if (field.isMustfill === '1') {
+        showMessage(`请完善${field.taskFiledValue}`)
+        return false
+      }
+
+      return true
+    }
+
+    const contentLength = getContentLength(value)
+
+    const minSize = normalizeSizeLimit(field.taskFiledMinsize)
+
+    const maxSize = normalizeSizeLimit(field.taskFiledMaxsize)
+
+    if (minSize !== null && contentLength < minSize) {
+      showMessage(`${field.taskFiledValue}最少输入${minSize}个字符`)
+
+      return false
+    }
+
+    if (maxSize !== null && contentLength > maxSize) {
+      showMessage(`${field.taskFiledValue}最多输入${maxSize}个字符`)
+
+      return false
+    }
+
+    return true
+  }
+
+  const validateField = (field: TaskFieldConfigViewItem): boolean => {
+    const value = form.value.value[field.id]?.value
+
+    if (field.taskFiledType === IMAGE_FIELD_TYPE) {
+      return validateImageField(field, value)
+    }
+
+    return validateContentField(field, value)
+  }
+
+  const validateSubmitForm = (): boolean => {
+    for (const field of taskFieldConfigList.value) {
+      if (!validateField(field)) {
+        return false
+      }
+    }
+
+    return true
+  }
+
+  const buildActionQuery = (action: FormAction): SaveTaskContentQuery | null => {
+    const normalizedTaskTypeId = taskTypeId.value.trim()
+
+    if (!normalizedTaskTypeId) {
+      showMessage('缺少 taskTypeId')
+      return null
+    }
+
+    const packageId = getPackageId()
+
+    if (action === 'submit' && !packageId) {
+      const packageField = findPackageField()
+
+      showMessage(
+        packageField ? `请完善${packageField.taskFiledValue}` : '表单配置中未找到关联积分包字段'
+      )
+
+      return null
+    }
+
+    return {
+      taskTypeId: normalizedTaskTypeId,
+      packageId,
+    }
+  }
+
+  const buildSubmitPayload = (): SaveTaskContentBody => {
+    const payload: SaveTaskContentBody = {}
+
+    for (const field of taskFieldConfigList.value) {
+      const fieldValue = form.value.value[field.id]
+
+      payload[field.taskFiledValue] = {
+        seq: field.seq ?? 0,
+
+        ...(field.isMustfill === '1'
+          ? {
+              required: true,
+            }
+          : {}),
+
+        items: [
+          {
+            label: String(fieldValue?.label ?? ''),
+            type: field.taskFiledType,
+            value: String(fieldValue?.value ?? ''),
+          },
+        ],
+      }
+    }
+
+    return payload
+  }
+
+  const clearDuplicateImages = (): void => {
+    duplicateImageList.value = []
+  }
+
+  const showDuplicateImages = (images: string[]): void => {
+    duplicateImageList.value = images
+    duplicateImageVisible.value = true
+  }
+
+  const handleActionSuccess = (action: FormAction, data: unknown): void => {
+    if (action === 'save') {
+      showMessage('保存成功', SUCCESS_TOAST_DURATION)
+    } else {
+      if (!isSaveTaskContentSuccessData(data)) {
+        showMessage('提交结果数据异常')
+        return
+      }
+
+      showMessage(`提交成功,若审核通过可获得${data.score}积分`, SUCCESS_TOAST_DURATION)
+    }
+
+    setTimeout(() => {
+      uni.navigateBack()
+    }, SUCCESS_TOAST_DURATION)
+  }
+
+  const executeAction = async (action: FormAction): Promise<void> => {
+    if (actionLocked) {
+      return
+    }
+
+    actionLocked = true
+
+    try {
+      if (action === 'submit' && !validateSubmitForm()) {
+        return
+      }
+
+      const query = buildActionQuery(action)
+
+      if (!query) {
+        return
+      }
+
+      const payload = buildSubmitPayload()
+      const requestApi = ACTION_API_MAP[action]
+
+      const response = await requestApi(query, payload)
+
+      /**
+       * 提交时:
+       * code === 1 且 data 为非空图片数组,
+       * 视为图片重复,不能进入成功逻辑。
+       */
+      if (action === 'submit' && response.code === 1) {
+        const duplicateImages = extractImageUrls(response.data)
+
+        if (duplicateImages.length > 0) {
+          showDuplicateImages(duplicateImages)
+          return
+        }
+      }
+
+      /**
+       * 所有非 0 状态码均不能视为成功。
+       */
+      if (response.code !== 0) {
+        showMessage(response.msg || ACTION_FAILURE_TEXT[action])
+
+        return
+      }
+
+      handleActionSuccess(action, response.data)
+    } catch (error: unknown) {
+      console.error(`[useTaskFormAction] ${action} 失败`, error)
+
+      showMessage(ACTION_FAILURE_TEXT[action])
+    } finally {
+      actionLocked = false
+    }
+  }
+
+  const handleSave = (): Promise<void> => {
+    return executeAction('save')
+  }
+
+  const handleSubmit = (): Promise<void> => {
+    return executeAction('submit')
+  }
+
+  return {
+    duplicateImageVisible,
+    duplicateImageList,
+    clearDuplicateImages,
+    handleSave,
+    handleSubmit,
+  }
+}

+ 97 - 0
src/pages-task/task-form/composables/useTaskFormPageFeatures.ts

@@ -0,0 +1,97 @@
+import { ref } from 'vue'
+
+import { checkAreaTypeApi } from '@/services/modules/task/taskFrom'
+
+import { useUserStore } from '@/stores/modules/user'
+
+export type AreaSelectLevel = 2 | 3
+
+interface InitializeTaskFormPageOptions {
+  taskTypeId: string
+  taskTitle?: string
+}
+
+const NOTICE_MAP: Readonly<Record<string, string>> = {
+  '13': '请以合法方式获取任务所需信息且不得侵犯任何商业秘密,否则将因此承担民/刑事责任。',
+  '14': '请勿统方,否则您将可能被医疗机构处罚并反馈至相关企业且被企业追究法律责任。',
+  '81': '注意该任务仅支持采集已购买产品的终端!',
+  '99999': '测试qaqaq',
+}
+
+/**
+ * 需要根据部门区域类型调整选择层级的任务。
+ */
+const AREA_LEVEL_TASK_TYPE_IDS = new Set<string>(['12', '13', '45', '78', '80'])
+
+const DEFAULT_AREA_SELECT_LEVEL: AreaSelectLevel = 3
+
+export const useTaskFormPageFeatures = () => {
+  const userStore = useUserStore()
+
+  const showNotice = ref(false)
+  const noticeText = ref('')
+  const selectLevel = ref<AreaSelectLevel>(DEFAULT_AREA_SELECT_LEVEL)
+
+  const setNavigationTitle = (title?: string) => {
+    const normalizedTitle = title?.trim()
+
+    if (!normalizedTitle) return
+
+    uni.setNavigationBarTitle({
+      title: normalizedTitle,
+    })
+  }
+
+  const setTaskNotice = (taskTypeId: string) => {
+    const text = NOTICE_MAP[taskTypeId] ?? ''
+
+    noticeText.value = text
+    showNotice.value = Boolean(text)
+  }
+
+  /**
+   * 根据任务类型和当前部门设置区域组件层级。
+   *
+   * 接口失败时继续使用默认三级区域,
+   * 不阻断动态表单加载。
+   */
+  const checkAreaType = async (taskTypeId: string) => {
+    selectLevel.value = DEFAULT_AREA_SELECT_LEVEL
+
+    if (!AREA_LEVEL_TASK_TYPE_IDS.has(taskTypeId)) {
+      return
+    }
+
+    const deptId = String(userStore.currentUserInfo?.deptId ?? '').trim()
+
+    if (!deptId) {
+      console.warn('[useTaskFormPageFeatures] 当前用户缺少 deptId')
+      return
+    }
+
+    try {
+      const res = await checkAreaTypeApi(deptId)
+
+      selectLevel.value = Number(res.data) === 0 ? 3 : 2
+    } catch (error) {
+      console.error('[useTaskFormPageFeatures] 获取区域类型失败', error)
+    }
+  }
+
+  const initializeTaskFormPage = async ({
+    taskTypeId,
+    taskTitle,
+  }: InitializeTaskFormPageOptions) => {
+    setNavigationTitle(taskTitle)
+    setTaskNotice(taskTypeId)
+
+    await checkAreaType(taskTypeId)
+  }
+
+  return {
+    showNotice,
+    noticeText,
+    selectLevel,
+    initializeTaskFormPage,
+  }
+}

+ 0 - 32
src/pages-task/task-form/composables/useTaskNotice.ts

@@ -1,32 +0,0 @@
-import { ref } from 'vue'
-
-const NOTICE_MAP: Record<string, string> = {
-  '13': '请以合法方式获取任务所需信息且不得侵犯任何商业秘密,否则将因此承担民/刑事责任。',
-  '14': '请勿统方,否则您将可能被医疗机构处罚并反馈至相关企业且被企业追究法律责任。',
-  '81': '注意该任务仅支持采集已购买产品的终端!',
-  '99999': '测试qaqaq',
-}
-
-export const useTaskNotice = () => {
-  const showNotice = ref(false)
-  const noticeText = ref('')
-
-  const setNotice = (taskId: string) => {
-    const text = NOTICE_MAP[taskId]
-
-    if (!text) {
-      showNotice.value = false
-      noticeText.value = ''
-      return
-    }
-
-    showNotice.value = true
-    noticeText.value = text
-  }
-
-  return {
-    showNotice,
-    noticeText,
-    setNotice,
-  }
-}

+ 304 - 0
src/pages-task/task-form/composables/useTaskSelectHandlers.ts

@@ -0,0 +1,304 @@
+import type { Ref } from 'vue'
+
+import { getListDrugTableApi } from '@/services/modules/task/taskFrom/index'
+
+import type {
+  DynamicFormFieldValue,
+  DynamicFormState,
+  FieldValue,
+  TaskFieldConfigViewItem,
+  TaskFieldOption,
+} from './useTaskForm'
+
+export interface SingleSelectChangeEvent {
+  previousValue: FieldValue
+  value: string
+  label: string
+  selectedItem: TaskFieldOption
+}
+
+interface UseTaskSelectHandlersOptions {
+  taskTypeId: Ref<string>
+  form: Ref<DynamicFormState>
+  taskFieldConfigList: Ref<TaskFieldConfigViewItem[]>
+}
+
+/**
+ * 动态表单选择器业务处理。
+ *
+ * 统一处理:
+ * 1. 单选打开前逻辑
+ * 2. 单选值变化逻辑
+ * 3. 多选打开前逻辑
+ * 4. 字段查找
+ * 5. 表单值读取和修改
+ * 6. 字段 options 修改
+ */
+export const useTaskSelectHandlers = ({
+  taskTypeId,
+  form,
+  taskFieldConfigList,
+}: UseTaskSelectHandlersOptions) => {
+  /**
+   * 根据 alias 查找字段配置。
+   */
+  const findFieldByAlias = (alias: string): TaskFieldConfigViewItem | undefined => {
+    return taskFieldConfigList.value.find((item) => item.alias === alias)
+  }
+
+  /**
+   * 根据字段配置获取对应表单值。
+   */
+  const getFieldFormValue = (field: TaskFieldConfigViewItem): DynamicFormFieldValue | undefined => {
+    return form.value.value[field.id]
+  }
+
+  /**
+   * 根据 alias 获取对应表单值。
+   */
+  const getFieldFormValueByAlias = (alias: string): DynamicFormFieldValue | undefined => {
+    const field = findFieldByAlias(alias)
+
+    if (!field) {
+      return undefined
+    }
+
+    return getFieldFormValue(field)
+  }
+
+  /**
+   * 根据 alias 获取字段实际 value。
+   */
+  const getFieldValueByAlias = (alias: string): FieldValue => {
+    return getFieldFormValueByAlias(alias)?.value
+  }
+
+  /**
+   * 更新指定字段的表单值。
+   */
+  const setFieldFormValue = (
+    field: TaskFieldConfigViewItem,
+    fieldValue: Partial<DynamicFormFieldValue>
+  ) => {
+    const currentValue = getFieldFormValue(field)
+
+    if (!currentValue) {
+      return
+    }
+
+    if (fieldValue.label !== undefined) {
+      currentValue.label = fieldValue.label
+    }
+
+    if (fieldValue.value !== undefined) {
+      currentValue.value = fieldValue.value
+    }
+  }
+
+  /**
+   * 根据 alias 更新字段表单值。
+   */
+  const setFieldFormValueByAlias = (alias: string, fieldValue: Partial<DynamicFormFieldValue>) => {
+    const field = findFieldByAlias(alias)
+
+    if (!field) {
+      return
+    }
+
+    setFieldFormValue(field, fieldValue)
+  }
+
+  /**
+   * 清空字段值。
+   */
+  const clearFieldValue = (field: TaskFieldConfigViewItem) => {
+    const currentValue = getFieldFormValue(field)
+
+    if (!currentValue) {
+      return
+    }
+
+    currentValue.label = ''
+    currentValue.value = ''
+  }
+
+  /**
+   * 根据 alias 清空字段值。
+   */
+  const clearFieldValueByAlias = (alias: string) => {
+    const field = findFieldByAlias(alias)
+
+    if (!field) {
+      return
+    }
+
+    clearFieldValue(field)
+  }
+
+  /**
+   * 更新字段选项。
+   *
+   * 没有选项时统一设置为 null。
+   */
+  const setFieldOptions = (field: TaskFieldConfigViewItem, options: TaskFieldOption[]) => {
+    field.options = options.length ? options : null
+  }
+
+  /**
+   * 根据 alias 更新字段选项。
+   */
+  const setFieldOptionsByAlias = (alias: string, options: TaskFieldOption[]) => {
+    const field = findFieldByAlias(alias)
+
+    if (!field) {
+      return
+    }
+
+    setFieldOptions(field, options)
+  }
+
+  /**
+   * 单选打开前处理。
+   *
+   * 特殊接口请求和业务校验写在这里。
+   */
+  const handleSingleSelectBeforeOpen = async (field: TaskFieldConfigViewItem): Promise<boolean> => {
+    /**
+     * OTC 推广任务示例。
+     */
+    if (taskTypeId.value === '47') {
+      const scorePackageValue = getFieldFormValueByAlias('scorePackage')
+      console.log('scorePackageValue', scorePackageValue)
+      if (field.taskFiledValue === '产品') {
+        if (!scorePackageValue?.value) {
+          uni.showToast({
+            title: '请先选择积分包',
+            icon: 'none',
+          })
+          return false
+        } else {
+          const arr = await getListDrugTable(String(scorePackageValue.value))
+          field.options = arr
+          return true
+        }
+      }
+
+      if (field.alias === 'realTaskTypeId') {
+        return true
+      }
+    }
+
+    return true
+  }
+
+  const getListDrugTable = async (packageId: string) => {
+    try {
+      const res = await getListDrugTableApi(packageId)
+      const arr = res.data.map((item: string) => {
+        return {
+          label: item,
+          value: item,
+        }
+      })
+      return arr
+    } catch (error) {
+      console.error('获取药品列表失败:', error)
+      return []
+    }
+  }
+
+  /**
+   * 单选值发生变化。
+   *
+   * SingleSelect 内部已经保证新值与原值不同时
+   * 才会触发此方法。
+   */
+  const handleSingleSelectChange = (
+    field: TaskFieldConfigViewItem,
+    event: SingleSelectChangeEvent
+  ) => {
+    console.log('单选字段:', field)
+    console.log('单选变化:', event)
+
+    /**
+     * OTC 推广任务的字段联动示例。
+     */
+    if (taskTypeId.value === '47') {
+      if (field.alias === 'scorePackage') {
+        clearFieldValueByAlias('realTaskTypeId')
+      }
+    }
+  }
+
+  /**
+   * 多选打开前处理。
+   */
+  const handleMultipleSelectBeforeOpen = async (
+    field: TaskFieldConfigViewItem
+  ): Promise<boolean> => {
+    const currentFormValue = getFieldFormValue(field)
+
+    if (!currentFormValue) {
+      console.warn(`未找到字段对应的表单值:${field.id}`)
+
+      return false
+    }
+
+    /**
+     * 特殊多选接口请求写在这里。
+     *
+     * 示例:
+     *
+     * if (
+     *   taskTypeId.value === '47' &&
+     *   field.alias === 'targetUsers'
+     * ) {
+     *   const packageValue =
+     *     getFieldValueByAlias('scorePackage')
+     *
+     *   if (!packageValue) {
+     *     uni.showToast({
+     *       title: '请先选择分数包',
+     *       icon: 'none',
+     *     })
+     *
+     *     return false
+     *   }
+     *
+     *   const res = await getTargetUserListApi({
+     *     packageId: String(packageValue),
+     *   })
+     *
+     *   setFieldOptions(
+     *     field,
+     *     res.data.map((item) => ({
+     *       label: String(item.name),
+     *       value: String(item.id),
+     *     }))
+     *   )
+     * }
+     */
+
+    return true
+  }
+
+  return {
+    handleSingleSelectBeforeOpen,
+    handleSingleSelectChange,
+    handleMultipleSelectBeforeOpen,
+
+    /**
+     * 以下方法可按需在页面或其他业务逻辑中使用。
+     */
+    findFieldByAlias,
+    getFieldFormValue,
+    getFieldFormValueByAlias,
+    getFieldValueByAlias,
+    setFieldFormValue,
+    setFieldFormValueByAlias,
+    clearFieldValue,
+    clearFieldValueByAlias,
+    setFieldOptions,
+    setFieldOptionsByAlias,
+  }
+}

+ 128 - 181
src/pages-task/task-form/index.vue

@@ -8,7 +8,7 @@
       :bounces="false"
     >
       <view class="task-form__content" :style="contentStyle">
-        <view class="workbench-notice" v-if="showNotice">
+        <view v-if="showNotice" class="workbench-notice">
           <wd-notice-bar
             :text="noticeText"
             prefix="warn-bold"
@@ -20,111 +20,116 @@
         </view>
 
         <view class="task-form-fields">
-          <view class="task-form-field" v-for="item in taskFieldConfigList" :key="item.id">
-            <template v-if="isSingleSelect(item.taskFiledType)">
+          <template v-for="item in taskFieldConfigList" :key="item.id">
+            <view v-if="form.value[item.id]" class="task-form-field">
               <SingleSelect
-                :taskFieldConfig="item"
-                :taskFieldDict="taskFieldDict"
+                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="handleSingleSelectChange(item, $event)"
               />
-            </template>
 
-            <template v-else-if="isTextInput(item.taskFiledType)">
               <SingleText
-                :taskFieldConfig="item"
-                :readonly="item.taskFiledType === 'readonlytext'"
+                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>
 
-            <template v-else-if="isDateTime(item.taskFiledType)">
               <DateTimePicker
-                :taskFieldConfig="item"
+                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"
               />
-            </template>
 
-            <template v-else-if="isLocation(item.taskFiledType)">
               <TaskLocation
-                :taskFieldConfig="item"
+                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>
 
-            <template v-else-if="isImgUpload(item.taskFiledType)">
-              <view class="line" />
-              <ImgUpload
-                :taskFieldConfig="item"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-              />
-            </template>
+              <template v-else-if="isImgUpload(item.taskFiledType)">
+                <view class="line" />
 
-            <template v-else-if="isSign(item.taskFiledType)">
-              <view class="line" />
-              <Sign
-                :taskFieldConfig="item"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-              />
-            </template>
+                <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>
 
-            <template v-else-if="isMultipleSelect(item.taskFiledType)">
               <MultipleSelect
-                :taskFieldConfig="item"
-                :taskFieldDict="taskFieldDict"
+                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)"
               />
-            </template>
 
-            <template v-else-if="isInputAutoSelect(item.taskFiledType)">
               <InputAutoSelect
-                :taskFieldConfig="item"
+                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"
               />
-            </template>
 
-            <template v-else-if="isDateTimeRange(item.taskFiledType)">
               <DataTimeRange
-                :taskFieldConfig="item"
+                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>
 
-            <template v-else-if="isLongText(item.taskFiledType)">
-              <view class="line" />
-              <LongText
-                :taskFieldConfig="item"
-                v-model:label="form.value[item.id].label"
-                v-model:value="form.value[item.id].value"
-              />
-              <view class="line" />
-            </template>
+              <template v-else-if="isLongText(item.taskFiledType)">
+                <view class="line" />
+
+                <LongText
+                  v-model:label="form.value[item.id].label"
+                  v-model:value="form.value[item.id].value"
+                  :task-field-config="item"
+                />
+
+                <view class="line" />
+              </template>
 
-            <template v-else-if="isArea(item.taskFiledType)">
               <Area
-                :taskFieldConfig="item"
+                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"
               />
-            </template>
-          </view>
+            </view>
+          </template>
         </view>
       </view>
     </scroll-view>
 
-    <view class="task-form-footer" :style="footerStyle">
+    <DuplicateImageDialog
+      v-model="duplicateImageVisible"
+      :images="duplicateImageList"
+      @confirm="clearDuplicateImages"
+    />
+
+    <view v-if="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"
-        :disabled="actionLoading !== null"
         @click="handleSave"
       >
         保存
@@ -133,7 +138,6 @@
       <button
         class="task-form-footer__button task-form-footer__button--submit"
         hover-class="task-form-footer__button--hover"
-        :disabled="actionLoading !== null"
         @click="handleSubmit"
       >
         提交
@@ -147,12 +151,10 @@ import { ref } from 'vue'
 
 import { onLoad } from '@dcloudio/uni-app'
 
-import { saveTaskContentApi } from '@/services/modules/task/taskFrom'
-import type { SaveTaskContentBody } from '@/services/modules/task/taskFrom/type'
-
 import Area from './components/Area.vue'
 import DataTimeRange from './components/DataTimeRange.vue'
 import DateTimePicker from './components/DateTimePicker.vue'
+import DuplicateImageDialog from './components/DuplicateImageDialog.vue'
 import ImgUpload from './components/ImgUpload.vue'
 import InputAutoSelect from './components/InputAutoSelect.vue'
 import LongText from './components/LongText.vue'
@@ -161,138 +163,89 @@ import Sign from './components/Sign.vue'
 import SingleSelect from './components/SingleSelect.vue'
 import SingleText from './components/SingleText.vue'
 import TaskLocation from './components/TaskLocation.vue'
-import { useNavigation } from './composables/useNavigation'
+import { useTaskFieldType } from './composables/useTaskFieldType'
 import { useTaskForm } from './composables/useTaskForm'
+import { useTaskFormAction } from './composables/useTaskFormAction'
+import { useTaskFormPageFeatures } from './composables/useTaskFormPageFeatures'
 import { useTaskFormSafeArea } from './composables/useTaskFormSafeArea'
-import { useTaskNotice } from './composables/useTaskNotice'
+import { useTaskSelectHandlers } from './composables/useTaskSelectHandlers'
 
-type PageLoadOptions = {
+interface PageLoadOptions {
+  taskTypeId?: string
   taskTitle?: string
-  taskId?: string
 }
 
-type FormAction = 'save' | 'submit'
+const taskTypeId = ref('')
+
+const { form, taskFieldConfigList, loadTaskForm } = useTaskForm()
+
+const { handleSingleSelectBeforeOpen, handleSingleSelectChange, handleMultipleSelectBeforeOpen } =
+  useTaskSelectHandlers({
+    taskTypeId,
+    form,
+    taskFieldConfigList,
+  })
+
+const {
+  duplicateImageVisible,
+  duplicateImageList,
+  clearDuplicateImages,
+  handleSave,
+  handleSubmit,
+} = useTaskFormAction({
+  taskTypeId,
+  form,
+  taskFieldConfigList,
+})
+const {
+  isSingleSelect,
+  isTextInput,
+  isReadonlyText,
+  isDateTime,
+  isLocation,
+  isImgUpload,
+  isSign,
+  isMultipleSelect,
+  isInputAutoSelect,
+  isDateTimeRange,
+  isLongText,
+  isArea,
+} = useTaskFieldType()
+
+const { showNotice, noticeText, selectLevel, initializeTaskFormPage } = useTaskFormPageFeatures()
 
-const { setNavigationTitle } = useNavigation()
-const { showNotice, noticeText, setNotice } = useTaskNotice()
-const { form, taskFieldConfigList, taskFieldDict, loadTaskForm } = useTaskForm()
 const { contentStyle, footerStyle, initSafeArea } = useTaskFormSafeArea()
 
-const actionLoading = ref<FormAction | null>(null)
-
-const isSingleSelect = (taskFiledType: string) => {
-  return ['domain', 'select'].includes(taskFiledType)
-}
-
-const isTextInput = (taskFiledType: string) => {
-  return ['text', 'readonlytext', 'money', 'number'].includes(taskFiledType)
-}
-
-const isDateTime = (taskFiledType: string) => {
-  return ['datetime'].includes(taskFiledType)
-}
-
-const isLocation = (taskFiledType: string) => {
-  return ['map', 'mapwithimg', 'currPosi'].includes(taskFiledType)
-}
-
-const isImgUpload = (taskFiledType: string) => {
-  return ['img'].includes(taskFiledType)
-}
-
-const isSign = (taskFiledType: string) => {
-  return ['sign'].includes(taskFiledType)
-}
-
-const isMultipleSelect = (taskFiledType: string) => {
-  return ['multiple_select'].includes(taskFiledType)
-}
-
-const isInputAutoSelect = (taskFiledType: string) => {
-  return ['inputautoselect'].includes(taskFiledType)
-}
-
-const isDateTimeRange = (taskFiledType: string) => {
-  return ['datatimerange'].includes(taskFiledType)
-}
-
-const isLongText = (taskFiledType: string) => {
-  return ['longtext'].includes(taskFiledType)
-}
-
-const isArea = (taskFiledType: string) => {
-  return ['area'].includes(taskFiledType)
-}
-
-const handleSave = () => {
-  void handleFormAction('save')
-}
-
-const handleSubmit = () => {
-  void handleFormAction('submit')
-}
-
-const buildSubmitPayload = (): SaveTaskContentBody => {
-  const payload: SaveTaskContentBody = {}
-
-  for (const field of taskFieldConfigList.value) {
-    const fieldValue = form.value.value[String(field.id)]
-
-    payload[field.taskFiledValue] = {
-      seq: field.seq ?? 0,
-      ...(field.isMustfill === '1' ? { required: true } : {}),
-      items: [
-        {
-          label: String(fieldValue?.label ?? ''),
-          type: field.taskFiledType,
-          value: String(fieldValue?.value ?? ''),
-        },
-      ],
-    }
-  }
-
-  return payload
-}
-
-const handleFormAction = async (action: FormAction) => {
-  if (actionLoading.value !== null) return
-
-  actionLoading.value = action
+const decodeRouteParam = (value?: string): string => {
+  if (!value) return ''
 
   try {
-    console.log(`${action}: form.value`, form.value)
-
-    const payload = buildSubmitPayload()
-    console.log('payload', payload)
-
-    if (action === 'save') {
-      // TODO: 调保存接口
-      return
-    }
-
-    const saveTaskContentQuery = {
-      taskTypeId: '46',
-      packageId: '8612',
-    }
-    const res = await saveTaskContentApi(saveTaskContentQuery, payload)
-    console.log('res', res)
-
-    // TODO: 调提交接口
-  } finally {
-    actionLoading.value = null
+    return decodeURIComponent(value)
+  } catch {
+    return value
   }
 }
 
 onLoad(async (options: PageLoadOptions = {}) => {
   initSafeArea()
 
-  setNavigationTitle(options.taskTitle)
+  taskTypeId.value = options.taskTypeId?.trim() ?? ''
+
+  if (!taskTypeId.value) {
+    uni.showToast({
+      title: '缺少 taskTypeId',
+      icon: 'none',
+    })
 
-  const taskId = options.taskId ?? ''
+    return
+  }
 
-  setNotice(taskId)
+  initializeTaskFormPage({
+    taskTypeId: taskTypeId.value,
+    taskTitle: decodeRouteParam(options.taskTitle),
+  })
 
-  await loadTaskForm(taskId)
+  await loadTaskForm(taskTypeId.value)
 })
 </script>
 
@@ -339,10 +292,10 @@ onLoad(async (options: PageLoadOptions = {}) => {
   position: fixed;
   right: 0;
   left: 0;
-  z-index: 99;
+  z-index: 9;
   display: flex;
   gap: 16rpx;
-  padding: 20rpx 28rpx 20rpx;
+  padding: 20rpx 28rpx;
   background: #fff;
   box-sizing: border-box;
   box-shadow: 0 -8rpx 24rpx rgba(0, 0, 0, 0.06);
@@ -356,19 +309,13 @@ onLoad(async (options: PageLoadOptions = {}) => {
   border: 0;
   border-radius: 12rpx;
   font-size: 32rpx;
-  font-weight: 400;
   line-height: 88rpx;
-  box-sizing: border-box;
 }
 
 .task-form-footer__button::after {
   border: 0;
 }
 
-.task-form-footer__button[disabled] {
-  opacity: 0.6;
-}
-
 .task-form-footer__button--hover {
   opacity: 0.85;
 }

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

@@ -2,6 +2,7 @@ import http from '../../../index'
 import type {
   SaveTaskContentBody,
   SaveTaskContentQuery,
+  SaveTaskContentResponse,
   TaskContentConfigByTaskTypeIdResponse,
 } from './type'
 
@@ -15,8 +16,26 @@ export const saveTaskContentApi = (
   saveTaskContentQuery: SaveTaskContentQuery,
   saveTaskContentBody: SaveTaskContentBody
 ) => {
-  return http.post<TaskContentConfigByTaskTypeIdResponse>(
-    `/admin/api/saveTaskContent/?packageId=${saveTaskContentQuery.packageId}&taskTypeId=${saveTaskContentQuery.taskTypeId}`,
+  return http.post<SaveTaskContentResponse>(
+    `/admin/api/saveTaskContent?packageId=${saveTaskContentQuery.packageId}&taskTypeId=${saveTaskContentQuery.taskTypeId}`,
     saveTaskContentBody
   )
 }
+
+export const draftTaskContentApi = (
+  saveTaskContentQuery: SaveTaskContentQuery,
+  saveTaskContentBody: SaveTaskContentBody
+) => {
+  return http.post<SaveTaskContentResponse>(
+    `admin/api/task-content/draft?packageId=${saveTaskContentQuery.packageId}&taskTypeId=${saveTaskContentQuery.taskTypeId}`,
+    saveTaskContentBody
+  )
+}
+
+export const checkAreaTypeApi = (deptId: string) => {
+  return http.get<number>(`/admin/dept/check-area-type?deptId=${deptId}`)
+}
+
+export const getListDrugTableApi = (packageId: string) => {
+  return http.get<string[]>(`/admin/wmdadrugentdrugtable/list-drug-table?packageId=${packageId}`)
+}

+ 8 - 1
src/services/modules/task/taskFrom/type.d.ts

@@ -6,7 +6,7 @@ export interface TaskContentConfigByTaskTypeIdResponse {
 }
 
 export interface TaskFieldConfigItem {
-  id: number
+  id: number | string
   alias: string | null
   baseTypeId: string
   taskTypeId: string
@@ -53,3 +53,10 @@ export interface SaveTaskContentField {
 }
 
 export type SaveTaskContentBody = Record<string, SaveTaskContentField>
+
+export interface SaveTaskContentSuccessData {
+  score: string
+  type: string
+}
+
+export type SaveTaskContentResponse = SaveTaskContentSuccessData | string[]