yuanmingze 3 долоо хоног өмнө
parent
commit
d2f552934b

+ 239 - 20
src/pages-task/task-detail/components/NormalTaskDetail.vue

@@ -17,20 +17,41 @@
         {{ field.name }}
       </view>
 
-      <view v-if="field.imageUrls.length" class="task-detail-field__images">
-        <image
-          v-for="(url, index) in field.imageUrls"
-          :key="`${url}-${index}`"
-          class="task-detail-field__image"
-          :src="url"
-          mode="aspectFill"
-          @click="previewImages(field.imageUrls, index)"
-        />
+      <view v-if="field.attachments.length" class="task-detail-field__attachments">
+        <template
+          v-for="attachment in field.attachments"
+          :key="`${attachment.type}:${attachment.url}`"
+        >
+          <image
+            v-if="attachment.type === 'image'"
+            class="task-detail-field__image"
+            :src="attachment.url"
+            mode="aspectFill"
+            @click="previewFieldImage(field.imageUrls, attachment.url)"
+          />
+
+          <view
+            v-else
+            class="task-detail-field__file"
+            hover-class="task-detail-field__file--hover"
+            @click="downloadFileAttachment(attachment)"
+          >
+            <image
+              class="task-detail-field__file-icon"
+              :src="
+                attachment.ext === 'pdf'
+                  ? '/static/images/icon/pdf.svg'
+                  : '/static/images/icon/ppt.svg'
+              "
+              mode="aspectFit"
+            />
+          </view>
+        </template>
       </view>
 
       <view v-if="field.text" class="task-detail-field__text">{{ field.text }}</view>
       <view
-        v-else-if="!field.imageUrls.length"
+        v-else-if="!field.attachments.length"
         class="task-detail-field__text task-detail-field__text--empty"
       >
         -
@@ -53,11 +74,23 @@ import type {
   TaskContentConfigByTaskTypeIdResponse,
 } from '@/services/modules/task/taskFrom/type'
 
-import { previewImages, toFullUrl } from '../utils'
+import { toFullUrl } from '../utils'
 import TaskDetailSummary from './TaskDetailSummary.vue'
 
 const IMAGE_FIELD_TYPES = new Set(['img', 'sign'])
+const IMAGE_AND_FILE_FIELD_TYPE = 'imgandfile'
 const DICT_FIELD_TYPES = new Set(['select', 'domain', 'multiple_select'])
+const FILE_EXTENSIONS = new Set(['pdf', 'ppt', 'pptx'])
+const FILE_DOWNLOAD_TIMEOUT = 60_000
+
+type DetailAttachmentType = 'image' | 'file'
+
+interface DetailAttachment {
+  type: DetailAttachmentType
+  url: string
+  name: string
+  ext: string
+}
 
 const props = defineProps<{
   taskDetail: TaskFormDetailResponse
@@ -109,7 +142,8 @@ const dictLabelsByField = computed(() => {
  * wmTaskContent 按 seq 排序;select、domain、multiple_select 类型优先从字段配置
  * 对应的字典中获取 label,字典缺失或值无法完整匹配时直接回显 value。
  * 其余情况在 item.label 为空时用 value 回显。
- * img、sign 类型转成图片列表,其余类型显示标准化后的 label。
+ * img、sign 类型转成图片列表;imgAndFile 根据值末尾来源标记转成图片或文件;
+ * 其余类型显示标准化后的 label。
  */
 const contentFields = computed(() => {
   const content = props.taskDetail.wmTaskContent
@@ -122,11 +156,29 @@ const contentFields = computed(() => {
         ...item,
         label: resolveItemLabel(name, item),
       }))
-      const imageUrls = items
-        .filter((item) => isImageFieldType(item.type))
-        .flatMap((item) => parseImageUrls(item.label))
+      const attachments = items.flatMap((item) => {
+        const attachmentValue = item.value.trim() || item.label
+
+        if (isImageFieldType(item.type)) {
+          return parseAttachments(attachmentValue, 'image')
+        }
+
+        if (isImageAndFileFieldType(item.type)) {
+          return parseAttachments(attachmentValue, 'mixed')
+        }
+
+        return []
+      })
+      const uniqueAttachments = [
+        ...new Map(
+          attachments.map((attachment) => [`${attachment.type}:${attachment.url}`, attachment])
+        ).values(),
+      ]
+      const imageUrls = uniqueAttachments
+        .filter((attachment) => attachment.type === 'image')
+        .map((attachment) => attachment.url)
       const text = items
-        .filter((item) => !isImageFieldType(item.type))
+        .filter((item) => !isImageFieldType(item.type) && !isImageAndFileFieldType(item.type))
         .map((item) => item.label)
         .filter(Boolean)
         .join('、')
@@ -135,7 +187,8 @@ const contentFields = computed(() => {
         name,
         seq: field.seq,
         required: field.required,
-        imageUrls: [...new Set(imageUrls)],
+        attachments: uniqueAttachments,
+        imageUrls,
         text,
       }
     })
@@ -165,11 +218,156 @@ const isImageFieldType = (type: string) => {
   return IMAGE_FIELD_TYPES.has(type.trim().toLowerCase())
 }
 
-const parseImageUrls = (value: string): string[] => {
+const isImageAndFileFieldType = (type: string): boolean => {
+  return type.trim().toLowerCase() === IMAGE_AND_FILE_FIELD_TYPE
+}
+
+const parseAttachments = (value: string, fieldMode: 'image' | 'mixed'): DetailAttachment[] => {
   return value
     .split(',')
-    .map((url) => toFullUrl(url))
+    .map((storedUrl) => storedUrl.trim())
     .filter(Boolean)
+    .map((storedUrl) => {
+      const suffix = getAttachmentSuffix(storedUrl)
+      const url = toFullUrl(storedUrl)
+      const name = extractFileName(storedUrl)
+      const ext = getFileExtension(name)
+      const type: DetailAttachmentType =
+        fieldMode === 'mixed' && isFileAttachment(suffix, ext) ? 'file' : 'image'
+
+      return {
+        type,
+        url,
+        name,
+        ext,
+      }
+    })
+    .filter((attachment) => Boolean(attachment.url))
+}
+
+const getAttachmentSuffix = (url: string): string => {
+  return url.match(/;([1-4])(?=$|[?#])/i)?.[1] ?? ''
+}
+
+const isFileAttachment = (suffix: string, ext: string): boolean => {
+  if (suffix) return suffix === '4'
+
+  return FILE_EXTENSIONS.has(ext)
+}
+
+const extractFileName = (url: string): string => {
+  const cleanUrl = url.replace(/;[1-4](?=$|[?#])/i, '').split(/[?#]/)[0]
+  const encodedName = cleanUrl.slice(cleanUrl.lastIndexOf('/') + 1)
+
+  try {
+    return decodeURIComponent(encodedName)
+  } catch {
+    return encodedName
+  }
+}
+
+const getFileExtension = (name: string): string => {
+  const lastDotIndex = name.lastIndexOf('.')
+
+  return lastDotIndex >= 0 ? name.slice(lastDotIndex + 1).toLowerCase() : ''
+}
+
+const previewFieldImage = (imageUrls: string[], currentUrl: string): void => {
+  if (!imageUrls.length) return
+
+  uni.previewImage({
+    urls: imageUrls,
+    current: currentUrl,
+  })
+}
+
+const downloadFileAttachment = (attachment: DetailAttachment): void => {
+  // #ifdef H5
+  openFileInBrowser(attachment)
+  // #endif
+
+  // #ifndef H5
+  downloadAndOpenFile(attachment)
+  // #endif
+}
+
+const downloadAndOpenFile = (attachment: DetailAttachment): void => {
+  uni.showLoading({
+    title: '正在下载文件',
+    mask: true,
+  })
+
+  let downloadSettled = false
+  let downloadTask: UniApp.DownloadTask | null = null
+  const downloadTimer = setTimeout(() => {
+    if (downloadSettled) return
+
+    downloadSettled = true
+    downloadTask?.abort()
+    uni.hideLoading()
+    showFileMessage('文件下载超时,请检查网络后重试')
+  }, FILE_DOWNLOAD_TIMEOUT)
+
+  const settleDownload = (): boolean => {
+    if (downloadSettled) return false
+
+    downloadSettled = true
+    clearTimeout(downloadTimer)
+
+    return true
+  }
+
+  downloadTask = uni.downloadFile({
+    url: attachment.url,
+    success: (result) => {
+      if (!settleDownload()) return
+
+      if (result.statusCode !== 200) {
+        uni.hideLoading()
+        console.error('[NormalTaskDetail] 文件下载状态异常', {
+          statusCode: result.statusCode,
+          url: attachment.url,
+        })
+        showFileMessage('文件下载失败')
+        return
+      }
+
+      openDownloadedFile(result.tempFilePath, attachment)
+    },
+    fail: (error) => {
+      if (!settleDownload()) return
+
+      uni.hideLoading()
+      console.error('[NormalTaskDetail] 下载文件失败', error)
+      showFileMessage('文件下载失败')
+    },
+  })
+}
+
+const openDownloadedFile = (filePath: string, attachment: DetailAttachment): void => {
+  // 部分小程序环境在文档查看器关闭前不会及时回调 openDocument。
+  // 下载完成后先结束 Loading,再交给系统文档查看器,避免页面一直显示“正在打开文件”。
+  uni.hideLoading()
+
+  uni.openDocument({
+    filePath,
+    fileType: attachment.ext,
+    fail: (error) => {
+      console.error('[NormalTaskDetail] 打开文件失败', error)
+      showFileMessage('无法打开该文件,请确认已安装文档查看应用')
+    },
+  })
+}
+
+const openFileInBrowser = (attachment: DetailAttachment): void => {
+  window.open(attachment.url, '_blank', 'noopener,noreferrer')
+}
+
+const showFileMessage = (title: string): void => {
+  uni.showToast({
+    title,
+    icon: 'none',
+  })
 }
 </script>
 
@@ -234,7 +432,7 @@ const parseImageUrls = (value: string): string[] => {
   color: #9aa6b5;
 }
 
-.task-detail-field__images {
+.task-detail-field__attachments {
   display: flex;
   flex-wrap: wrap;
   gap: 16rpx;
@@ -248,6 +446,27 @@ const parseImageUrls = (value: string): string[] => {
   border-radius: 14rpx;
 }
 
+.task-detail-field__file {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 196rpx;
+  height: 196rpx;
+  border: 2rpx solid #edf1f5;
+  border-radius: 14rpx;
+  background: #f8fafc;
+  box-sizing: border-box;
+}
+
+.task-detail-field__file--hover {
+  opacity: 0.76;
+}
+
+.task-detail-field__file-icon {
+  width: 104rpx;
+  height: 104rpx;
+}
+
 .task-detail-content__empty {
   padding: 80rpx 28rpx;
   color: #9aa6b5;

+ 14 - 2
src/pages-task/task-form/components/DynamicTaskFormFields.vue

@@ -57,6 +57,17 @@
           />
         </template>
 
+        <template v-else-if="isImgAndFileUpload(item.taskFiledType)">
+          <view class="line" />
+          <ImgAndFileUpload
+            v-model:label="form.value[item.id].label"
+            v-model:value="form.value[item.id].value"
+            :task-field-config="item"
+            :disabled="isFieldDisabled(item)"
+            @uploading-change="emit('uploading-change', item.id, $event)"
+          />
+        </template>
+
         <template v-else-if="isSign(item.taskFiledType)">
           <view class="line" />
 
@@ -106,7 +117,6 @@
           :disabled="isFieldDisabled(item)"
           :select-level="selectLevel"
         />
-
       </view>
     </template>
   </view>
@@ -136,6 +146,7 @@ import { useTaskSelectHandlers } from '../composables/useTaskSelectHandlers'
 import Area from './Area.vue'
 import DataTimeRange from './DataTimeRange.vue'
 import DateTimePicker from './DateTimePicker.vue'
+import ImgAndFileUpload from './ImgAndFileUpload.vue'
 import ImgUpload from './ImgUpload.vue'
 import InputAutoSelect from './InputAutoSelect.vue'
 import LongText from './LongText.vue'
@@ -166,6 +177,7 @@ const props = withDefaults(
 
 const emit = defineEmits<{
   (event: 'derived-change', field: TaskFieldConfigViewItem, change: DerivedFieldChangeEvent): void
+  (event: 'uploading-change', fieldId: string, uploading: boolean): void
 }>()
 
 const form = defineModel<DynamicFormState>({ required: true })
@@ -198,6 +210,7 @@ const {
   isDateTime,
   isLocation,
   isImgUpload,
+  isImgAndFileUpload,
   isSign,
   isMultipleSelect,
   isInputAutoSelect,
@@ -258,5 +271,4 @@ const handleTaskSingleSelectChange = (
   height: 20rpx;
   background: #f4f4f4;
 }
-
 </style>

+ 1351 - 0
src/pages-task/task-form/components/ImgAndFileUpload.vue

@@ -0,0 +1,1351 @@
+<template>
+  <view class="mixed-upload">
+    <FormField
+      :title="taskFieldConfig.taskFiledValue"
+      :required="taskFieldConfig.isMustfill === '1'"
+      description="(至少上传2张图片或1个PPT/PDF文件)"
+      layout="vertical"
+    >
+      <view class="attachment-grid" :class="{ 'is-disabled': disabled }">
+        <view
+          v-for="(attachment, index) in attachments"
+          :key="attachment.id"
+          class="attachment-item"
+        >
+          <image
+            v-if="attachment.type === 'image'"
+            class="attachment-image"
+            :src="getAttachmentUrl(attachment)"
+            mode="aspectFill"
+            @click.stop="previewImage(attachment)"
+          />
+
+          <view v-else class="file-card" @click.stop="previewFile(attachment)">
+            <image
+              class="file-icon"
+              :src="
+                attachment.ext === 'pdf'
+                  ? '/static/images/icon/pdf.svg'
+                  : '/static/images/icon/ppt.svg'
+              "
+              mode="aspectFit"
+            />
+          </view>
+
+          <view
+            v-if="!disabled && !uploading"
+            class="delete-button"
+            @click.stop="removeAttachment(index)"
+          >
+            <wd-icon name="close" size="22rpx" color="#fff" />
+          </view>
+        </view>
+
+        <view
+          v-if="canUpload"
+          class="upload-trigger"
+          hover-class="upload-trigger--hover"
+          @click.stop="openSelector"
+        >
+          <wd-icon name="plus" color="#3b9bed" size="44rpx" />
+          <text class="upload-trigger__text">选择附件</text>
+          <text class="upload-trigger__count">{{ attachments.length }}/{{ maxCount }}</text>
+        </view>
+      </view>
+    </FormField>
+
+    <wd-popup
+      v-model="selectorVisible"
+      position="bottom"
+      root-portal
+      :z-index="220"
+      custom-style="border-radius: 28rpx 28rpx 0 0; overflow: hidden;"
+    >
+      <view class="selector-panel">
+        <view class="popup-handle" />
+        <view class="selector-title">选择上传方式</view>
+        <view class="selector-description">
+          图片与文件合计最多{{ maxCount }}个,文件仅支持PPT、PPTX、PDF
+        </view>
+
+        <view class="selector-options">
+          <view class="selector-option" hover-class="selector-option--hover" @click="chooseImage">
+            <view class="selector-option__icon selector-option__icon--image">
+              <wd-icon name="camera-fill" color="#3b9bed" size="52rpx" />
+            </view>
+            <view class="selector-option__name">上传图片</view>
+            <view class="selector-option__description">拍摄或从相册选择</view>
+          </view>
+
+          <view class="selector-option" hover-class="selector-option--hover" @click="openGuide">
+            <view class="selector-option__icon selector-option__icon--file">
+              <wd-icon name="file" color="#e89424" size="52rpx" />
+            </view>
+            <view class="selector-option__name">微信聊天文件</view>
+            <view class="selector-option__description">从聊天记录中选择</view>
+          </view>
+        </view>
+
+        <button class="popup-cancel" @click="selectorVisible = false">取消</button>
+      </view>
+    </wd-popup>
+
+    <wd-popup
+      v-model="guideVisible"
+      position="bottom"
+      root-portal
+      :z-index="230"
+      custom-style="border-radius: 28rpx 28rpx 0 0; overflow: hidden;"
+    >
+      <view class="guide-panel">
+        <view class="popup-handle" />
+        <view class="guide-title">聊天文件上传指引</view>
+        <view class="guide-subtitle">左右滑动查看完整步骤</view>
+
+        <swiper
+          class="guide-swiper"
+          indicator-dots
+          circular
+          autoplay
+          :interval="2600"
+          indicator-color="rgba(59, 155, 237, 0.22)"
+          indicator-active-color="#3b9bed"
+        >
+          <swiper-item v-for="(imageUrl, index) in guideImages" :key="imageUrl">
+            <view class="guide-slide">
+              <image class="guide-image" :src="imageUrl" mode="aspectFit" />
+              <view class="guide-step">步骤 {{ index + 1 }}/{{ guideImages.length }}</view>
+            </view>
+          </swiper-item>
+        </swiper>
+
+        <view class="guide-tip"> 请先将文件发送至微信好友或文件传输助手,再回到这里选择 </view>
+
+        <view class="guide-actions">
+          <button class="guide-button guide-button--cancel" @click="guideVisible = false">
+            取消
+          </button>
+          <button class="guide-button guide-button--confirm" @click="confirmGuide">
+            知道了,开始选择
+          </button>
+        </view>
+      </view>
+    </wd-popup>
+
+    <wd-popup
+      v-model="fileProgressVisible"
+      position="center"
+      root-portal
+      :z-index="260"
+      :close-on-click-modal="false"
+      custom-style="width: 610rpx; border-radius: 28rpx; overflow: hidden;"
+    >
+      <view class="upload-progress">
+        <view class="upload-progress__icon">
+          <wd-loading color="#3b9bed" size="44rpx" />
+        </view>
+        <view class="upload-progress__title">正在上传</view>
+        <view class="upload-progress__name">{{ uploadingName }}</view>
+        <view class="progress-track">
+          <view class="progress-value" :style="{ width: `${progress}%` }" />
+        </view>
+        <view class="upload-progress__percent">{{ progress }}%</view>
+        <view class="upload-progress__tip">上传完成前请勿关闭页面</view>
+      </view>
+    </wd-popup>
+
+    <canvas :canvas-id="canvasId" :style="watermarkCanvasStyle" />
+  </view>
+</template>
+
+<script setup lang="ts">
+import {
+  computed,
+  type CSSProperties,
+  getCurrentInstance,
+  nextTick,
+  onBeforeUnmount,
+  onMounted,
+  ref,
+  watch,
+} from 'vue'
+
+import { getLocation } from '@/lib/location'
+
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
+
+import { useUserStore } from '@/stores/modules/user'
+
+type AttachmentType = 'image' | 'file'
+type UploadSource = 'camera' | 'album' | 'file'
+type SourceSuffix = '' | ';1' | ';2' | ';3' | ';4'
+
+interface Attachment {
+  id: string
+  rawUrl: string
+  name: string
+  ext: string
+  type: AttachmentType
+  suffix: SourceSuffix
+  pending?: boolean
+}
+
+interface SelectedFile {
+  path: string
+  name: string
+  size: number
+}
+
+interface UploadResponse {
+  code: number | string
+  success?: boolean
+  msg?: string | null
+  data?: {
+    url?: string
+  } | null
+}
+
+interface Props {
+  taskFieldConfig: TaskFieldConfigItem
+  disabled?: boolean
+}
+
+const props = withDefaults(defineProps<Props>(), {
+  disabled: false,
+})
+
+const emit = defineEmits<{
+  (event: 'uploading-change', uploading: boolean): void
+}>()
+
+const value = defineModel<string>('value', { default: '' })
+const label = defineModel<string>('label', { default: '' })
+
+const FILE_EXTENSIONS = new Set(['pdf', 'ppt', 'pptx'])
+const FILE_EXTENSION_LIST = ['ppt', 'pptx', 'pdf']
+const IMAGE_MAX_SIZE = 2 * 1024 * 1024
+const FILE_MAX_SIZE = 50 * 1024 * 1024
+const SOURCE_SUFFIX_MAP: Record<UploadSource, SourceSuffix> = {
+  camera: ';1',
+  album: ';2',
+  file: ';4',
+}
+
+const guideImages = [
+  'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/image/upload-guide1.png',
+  'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/image/upload-guide2-new.png',
+  'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/image/upload-guide3.png',
+  'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/image/upload-guide4.png',
+  'https://yy-cloud-oss-public.oss-cn-beijing.aliyuncs.com/image/upload-guide5.png',
+]
+
+const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
+const userStore = useUserStore()
+const instance = getCurrentInstance()
+
+const attachments = ref<Attachment[]>([])
+const selectorVisible = ref(false)
+const guideVisible = ref(false)
+const uploading = ref(false)
+const fileProgressVisible = ref(false)
+const uploadingName = ref('')
+const progress = ref(0)
+const watermarkAddress = ref('')
+const watermarkCanvasWidth = ref(1)
+const watermarkCanvasHeight = ref(1)
+let watermarkAddressPromise: Promise<void> | null = null
+let filePickerTimer: ReturnType<typeof setTimeout> | null = null
+let imageLoadingVisible = false
+
+const maxCount = computed(() => {
+  return toPositiveInteger(props.taskFieldConfig.taskFiledMaxsize, 10)
+})
+
+const canvasId = computed(() => `imgAndFileWatermarkCanvas-${props.taskFieldConfig.id}`)
+
+const watermarkCanvasStyle = computed<CSSProperties>(() => ({
+  width: `${watermarkCanvasWidth.value}px`,
+  height: `${watermarkCanvasHeight.value}px`,
+  position: 'fixed',
+  left: '-9999px',
+  top: '-9999px',
+  pointerEvents: 'none',
+}))
+
+const modelText = computed(() => normalizeValue(value.value) || normalizeValue(label.value))
+const isMax = computed(() => attachments.value.length >= maxCount.value)
+const canUpload = computed(() => !props.disabled && !uploading.value && !isMax.value)
+
+const initWatermarkAddress = (): Promise<void> => {
+  watermarkAddressPromise ??= (async () => {
+    try {
+      const location = await getLocation()
+      watermarkAddress.value = location.address ?? ''
+    } catch (error) {
+      watermarkAddressPromise = null
+      console.warn('[ImgAndFileUpload] 获取水印地址失败', error)
+    }
+  })()
+
+  return watermarkAddressPromise
+}
+
+const openSelector = (): void => {
+  if (props.disabled || uploading.value) return
+
+  if (isMax.value) {
+    showMessage(`最多上传${maxCount.value}个附件`)
+    return
+  }
+
+  selectorVisible.value = true
+}
+
+const chooseImage = (): void => {
+  selectorVisible.value = false
+
+  if (props.disabled || uploading.value || isMax.value) return
+
+  uni.showActionSheet({
+    itemList: ['拍摄', '从相册选择'],
+    success: (result) => {
+      const source: UploadSource = result.tapIndex === 0 ? 'camera' : 'album'
+      void selectAndUploadImages(source)
+    },
+  })
+}
+
+const selectAndUploadImages = async (source: 'camera' | 'album'): Promise<void> => {
+  try {
+    if (source === 'camera') {
+      await initWatermarkAddress()
+    }
+
+    const remainingCount = Math.max(maxCount.value - attachments.value.length, 0)
+    const files = await chooseImages(Math.min(remainingCount, 9), source)
+    const oversizedFiles = files.filter((file) => file.size > IMAGE_MAX_SIZE)
+    const validFiles = files.filter((file) => file.size <= IMAGE_MAX_SIZE)
+
+    if (oversizedFiles.length > 0) {
+      showMessage(`有${oversizedFiles.length}张图片超过2MB,已跳过`, 2500)
+    }
+
+    if (validFiles.length > 0) {
+      await uploadBatch(validFiles, source)
+    }
+  } catch (error) {
+    if (!isCancelError(error)) {
+      console.error('[ImgAndFileUpload] 选择图片失败', error)
+      showMessage('选择图片失败,请重试')
+    }
+  }
+}
+
+const chooseImages = (count: number, source: 'camera' | 'album'): Promise<SelectedFile[]> => {
+  if (count <= 0) return Promise.resolve([])
+
+  return new Promise((resolve, reject) => {
+    uni.chooseImage({
+      count,
+      sizeType: ['compressed'],
+      sourceType: [source],
+      success: (result) => {
+        const rawResult = result as unknown as {
+          tempFilePaths: string | string[]
+          tempFiles?: Array<{ path: string; size?: number }>
+        }
+        const paths = Array.isArray(rawResult.tempFilePaths)
+          ? rawResult.tempFilePaths
+          : [rawResult.tempFilePaths].filter(Boolean)
+        const files = rawResult.tempFiles?.length
+          ? rawResult.tempFiles.map((file) => ({
+              path: file.path,
+              name: extractFileName(file.path),
+              size: Number(file.size ?? 0),
+            }))
+          : paths.map((path) => ({
+              path,
+              name: extractFileName(path),
+              size: 0,
+            }))
+
+        resolve(files)
+      },
+      fail: reject,
+    })
+  })
+}
+
+const openGuide = (): void => {
+  selectorVisible.value = false
+
+  if (props.disabled || uploading.value || isMax.value) return
+
+  guideVisible.value = true
+}
+
+const confirmGuide = (): void => {
+  guideVisible.value = false
+
+  if (filePickerTimer) {
+    clearTimeout(filePickerTimer)
+  }
+
+  filePickerTimer = setTimeout(() => {
+    filePickerTimer = null
+    chooseMessageFiles()
+  }, 220)
+}
+
+const chooseMessageFiles = (): void => {
+  if (props.disabled || uploading.value || isMax.value) return
+
+  if (typeof uni.chooseMessageFile !== 'function') {
+    showMessage('当前环境不支持从聊天记录选择文件')
+    return
+  }
+
+  const remainingCount = Math.max(maxCount.value - attachments.value.length, 0)
+
+  uni.chooseMessageFile({
+    count: remainingCount,
+    type: 'file',
+    extension: FILE_EXTENSION_LIST,
+    success: (result) => {
+      const files: SelectedFile[] = (result.tempFiles ?? []).map((file) => ({
+        path: file.path,
+        name: file.name || extractFileName(file.path),
+        size: Number(file.size ?? 0),
+      }))
+
+      void uploadBatch(files, 'file')
+    },
+    fail: (error) => {
+      if (!isCancelError(error)) {
+        console.error('[ImgAndFileUpload] 选择聊天文件失败', error)
+        showMessage('选择文件失败,请重试')
+      }
+    },
+  })
+}
+
+const uploadBatch = async (files: SelectedFile[], source: UploadSource): Promise<void> => {
+  if (uploading.value || files.length === 0) return
+
+  const isFileUpload = source === 'file'
+  let uploadedCount = 0
+  let failedCount = 0
+
+  uploading.value = true
+
+  if (isFileUpload) {
+    fileProgressVisible.value = true
+  } else {
+    uni.showLoading({
+      title: '上传中',
+      mask: true,
+    })
+    imageLoadingVisible = true
+  }
+
+  try {
+    for (const file of files) {
+      if (attachments.value.length >= maxCount.value) {
+        showMessage(`最多上传${maxCount.value}个附件`)
+        break
+      }
+
+      const name = file.name || extractFileName(file.path)
+      const ext = getExtension(name)
+
+      if (source === 'file') {
+        if (!FILE_EXTENSIONS.has(ext)) {
+          showMessage('仅支持PPT、PPTX、PDF格式')
+          continue
+        }
+
+        if (file.size > FILE_MAX_SIZE) {
+          showMessage('文件不能超过50MB')
+          continue
+        }
+      } else if (file.size > IMAGE_MAX_SIZE) {
+        showMessage('图片不能超过2MB')
+        continue
+      }
+
+      uploadingName.value = name || (source === 'file' ? '文件' : '图片')
+      progress.value = 0
+
+      const pendingId = source === 'camera' ? pushPendingImage(file, name) : ''
+
+      if (pendingId) {
+        await nextTick()
+      }
+
+      try {
+        const uploadPath = source === 'camera' ? await addWatermark(file.path) : file.path
+        const originalFileName = isFileUpload ? name : ''
+        const uploadedUrl = await uploadFile(uploadPath, originalFileName, isFileUpload)
+
+        pushAttachment(uploadedUrl, name, source, pendingId)
+        uploadedCount += 1
+      } catch (error) {
+        failedCount += 1
+
+        if (pendingId) {
+          removePendingAttachment(pendingId)
+        }
+
+        console.error('[ImgAndFileUpload] 上传附件失败', error)
+
+        if (isFileUpload) {
+          showMessage(getErrorMessage(error) || '上传失败,请重试')
+        }
+      }
+    }
+  } finally {
+    hideImageLoading()
+    fileProgressVisible.value = false
+    uploading.value = false
+    uploadingName.value = ''
+    progress.value = 0
+  }
+
+  if (!isFileUpload && (uploadedCount > 0 || failedCount > 0)) {
+    const allUploaded = failedCount === 0
+    const title = allUploaded
+      ? '上传成功'
+      : uploadedCount > 0
+        ? `${uploadedCount}张成功,${failedCount}张失败`
+        : '上传失败'
+
+    uni.showToast({
+      title,
+      icon: allUploaded ? 'success' : 'none',
+    })
+  }
+}
+
+const uploadFile = (
+  filePath: string,
+  originalFileName = '',
+  trackProgress = false
+): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    const uploadTask = uni.uploadFile({
+      url: getUploadUrl(originalFileName),
+      filePath,
+      name: 'file',
+      header: {
+        Authorization: `Bearer ${userStore.access_token}`,
+      },
+      success: (result) => {
+        if (result.statusCode < 200 || result.statusCode >= 300) {
+          reject(new Error(`上传失败(${result.statusCode})`))
+          return
+        }
+
+        try {
+          const response = JSON.parse(result.data) as UploadResponse
+
+          if (String(response.code) !== '0' || !response.data?.url) {
+            const message = response.msg === 'duplicate' ? '上传文件重复' : response.msg
+            reject(new Error(message || '上传失败'))
+            return
+          }
+
+          resolve(response.data.url)
+        } catch {
+          reject(new Error('上传结果解析失败'))
+        }
+      },
+      fail: (error) => reject(new Error(error.errMsg || '上传失败')),
+    })
+
+    if (trackProgress) {
+      uploadTask.onProgressUpdate((result) => {
+        progress.value = Math.min(Math.max(Math.round(result.progress), 0), 100)
+      })
+    }
+  })
+}
+
+const hideImageLoading = (): void => {
+  if (!imageLoadingVisible) return
+
+  uni.hideLoading()
+  imageLoadingVisible = false
+}
+
+const getUploadUrl = (originalFileName: string): string => {
+  const actionUrl = `${baseUrl}/admin/api/file/upload/mobile`
+
+  if (!originalFileName) return actionUrl
+
+  return `${actionUrl}?fileName=${encodeURIComponent(originalFileName)}`
+}
+
+const pushPendingImage = (file: SelectedFile, name: string): string => {
+  const id = `pending-${Date.now()}-${Math.random()}`
+
+  attachments.value.push({
+    id,
+    rawUrl: file.path,
+    name: name || extractFileName(file.path),
+    ext: getExtension(name || file.path),
+    type: 'image',
+    suffix: '',
+    pending: true,
+  })
+
+  return id
+}
+
+const removePendingAttachment = (id: string): void => {
+  attachments.value = attachments.value.filter((attachment) => attachment.id !== id)
+}
+
+const pushAttachment = (
+  rawUrl: string,
+  name: string,
+  source: UploadSource,
+  pendingId = ''
+): void => {
+  const suffix = SOURCE_SUFFIX_MAP[source]
+  const attachment: Attachment = {
+    id: `${rawUrl}${suffix}-${Date.now()}`,
+    rawUrl: removeSourceSuffix(rawUrl),
+    name: name || extractFileName(rawUrl),
+    ext: getExtension(name || rawUrl),
+    type: source === 'file' ? 'file' : 'image',
+    suffix,
+  }
+  const pendingIndex = pendingId ? attachments.value.findIndex((item) => item.id === pendingId) : -1
+
+  if (pendingIndex >= 0) {
+    attachments.value.splice(pendingIndex, 1, attachment)
+  } else {
+    attachments.value.push(attachment)
+  }
+
+  syncModels()
+}
+
+const removeAttachment = (index: number): void => {
+  if (props.disabled || uploading.value) return
+
+  attachments.value.splice(index, 1)
+  syncModels()
+}
+
+const serializeAttachments = (): string => {
+  return attachments.value
+    .filter((attachment) => !attachment.pending)
+    .map((attachment) => `${attachment.rawUrl}${attachment.suffix}`)
+    .join(',')
+}
+
+const syncModels = (): void => {
+  const nextValue = serializeAttachments()
+
+  value.value = nextValue
+  label.value = nextValue
+}
+
+const parseAttachments = (input: string): Attachment[] => {
+  if (!input) return []
+
+  return input
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
+    .map((storedUrl, index) => {
+      const suffix = getSourceSuffix(storedUrl)
+      const rawUrl = removeSourceSuffix(storedUrl)
+      const name = extractFileName(rawUrl)
+      const ext = getExtension(name)
+      const type: AttachmentType = suffix === ';4' || FILE_EXTENSIONS.has(ext) ? 'file' : 'image'
+
+      return {
+        id: `${storedUrl}-${index}`,
+        rawUrl,
+        name,
+        ext,
+        type,
+        suffix: type === 'file' ? ';4' : suffix,
+      }
+    })
+}
+
+const normalizeValue = (input: unknown): string => {
+  if (typeof input !== 'string') return ''
+
+  return input
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)
+    .join(',')
+}
+
+const getSourceSuffix = (url: string): SourceSuffix => {
+  const match = url.match(/;([1234])(?=$|[?#])/)
+
+  return match ? (`;${match[1]}` as SourceSuffix) : ''
+}
+
+const removeSourceSuffix = (url: string): string => {
+  return String(url).replace(/;[1234](?=$|[?#])/, '')
+}
+
+const extractFileName = (url: string): string => {
+  if (!url) return ''
+
+  const cleanUrl = removeSourceSuffix(url).split(/[?#]/)[0]
+  const encodedName = cleanUrl.slice(cleanUrl.lastIndexOf('/') + 1)
+
+  try {
+    return decodeURIComponent(encodedName)
+  } catch {
+    return encodedName
+  }
+}
+
+const getExtension = (name: string): string => {
+  const cleanName = name.split(/[?#]/)[0]
+  const lastDotIndex = cleanName.lastIndexOf('.')
+
+  return lastDotIndex >= 0 ? cleanName.slice(lastDotIndex + 1).toLowerCase() : ''
+}
+
+const getAttachmentUrl = (attachment: Attachment): string => {
+  return attachment.pending ? attachment.rawUrl : toFullUrl(attachment.rawUrl)
+}
+
+const toFullUrl = (url: string): string => {
+  const cleanUrl = removeSourceSuffix(url).trim()
+
+  if (!cleanUrl || /^(https?:|wxfile:|blob:|data:)/i.test(cleanUrl)) {
+    return cleanUrl
+  }
+
+  return `${baseUrl}${cleanUrl.startsWith('/') ? cleanUrl : `/${cleanUrl}`}`
+}
+
+const previewImage = (currentAttachment: Attachment): void => {
+  const urls = attachments.value
+    .filter((attachment) => attachment.type === 'image')
+    .map(getAttachmentUrl)
+
+  if (urls.length === 0) return
+
+  uni.previewImage({
+    current: getAttachmentUrl(currentAttachment),
+    urls,
+  })
+}
+
+const previewFile = (attachment: Attachment): void => {
+  if (uploading.value) return
+
+  const fileUrl = toFullUrl(attachment.rawUrl)
+
+  // #ifdef H5
+  window.open(fileUrl, '_blank', 'noopener,noreferrer')
+  // #endif
+
+  // #ifndef H5
+  uni.showLoading({ title: '打开中...', mask: true })
+
+  uni.downloadFile({
+    url: fileUrl,
+    success: (result) => {
+      if (result.statusCode !== 200) {
+        uni.hideLoading()
+        showMessage('文件加载失败')
+        return
+      }
+
+      // 部分微信环境不会及时触发 openDocument 的 complete 回调。
+      // 下载完成后先关闭 Loading,再交给系统文档查看器。
+      uni.hideLoading()
+
+      uni.openDocument({
+        filePath: result.tempFilePath,
+        fileType: attachment.ext,
+        fail: (error) => {
+          console.error('[ImgAndFileUpload] 打开文件失败', error)
+          showMessage('无法打开该文件')
+        },
+      })
+    },
+    fail: (error) => {
+      uni.hideLoading()
+      console.error('[ImgAndFileUpload] 下载文件失败', error)
+      showMessage('文件下载失败')
+    },
+  })
+  // #endif
+}
+
+const addWatermark = (filePath: string): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    uni.getImageInfo({
+      src: filePath,
+      success: async (imageInfo) => {
+        const width = imageInfo.width
+        const height = imageInfo.height
+
+        watermarkCanvasWidth.value = width
+        watermarkCanvasHeight.value = height
+        await nextTick()
+
+        const componentInstance = instance?.proxy
+        const context = uni.createCanvasContext(
+          canvasId.value,
+          componentInstance
+        ) as UniApp.CanvasContext & {
+          measureText: (text: string) => { width: number }
+        }
+
+        const scaleRatio = width / 375
+        const radius = 10 * scaleRatio
+        const padding = 8 * scaleRatio
+        const iconSize = 10 * scaleRatio
+        const textSpacing = 4 * scaleRatio
+        const lineHeight = 14 * scaleRatio
+        const bottomSpacing = 14 * scaleRatio
+        const leftSpacing = 14 * scaleRatio
+        const textTop = 6 * scaleRatio
+        const fontSize = 10 * scaleRatio
+
+        context.drawImage(filePath, 0, 0, width, height)
+        context.setFontSize(fontSize)
+
+        const textRows = wrapText(
+          context,
+          watermarkAddress.value,
+          width - iconSize - textSpacing - padding * 3 - leftSpacing * 2
+        )
+        textRows.push(formatDate(new Date()))
+
+        const boxHeight = lineHeight * textRows.length + textTop
+        const textWidth = Math.max(...textRows.map((text) => context.measureText(text).width), 0)
+        const boxWidth = Math.min(
+          width - leftSpacing * 2,
+          Math.max(iconSize + textWidth + textSpacing * 3 + padding, padding * 6)
+        )
+        const boxX = leftSpacing
+        const boxY = Math.max(height - boxHeight - bottomSpacing, bottomSpacing)
+
+        drawRoundRect(context, boxX, boxY, boxWidth, boxHeight, radius)
+        context.drawImage(
+          '/static/images/task/watermarkIcon.png',
+          boxX + padding,
+          boxY + padding - 12,
+          iconSize,
+          iconSize
+        )
+        context.setFillStyle('#fff')
+        context.setFontSize(fontSize)
+
+        textRows.forEach((row, index) => {
+          context.fillText(
+            row,
+            boxX + iconSize + textSpacing + padding,
+            boxY + lineHeight * (index + 1)
+          )
+        })
+
+        context.draw(false, () => {
+          setTimeout(() => {
+            uni.canvasToTempFilePath(
+              {
+                canvasId: canvasId.value,
+                fileType: 'jpg',
+                success: (result) => resolve(result.tempFilePath),
+                fail: reject,
+              },
+              componentInstance
+            )
+          }, 300)
+        })
+      },
+      fail: reject,
+    })
+  })
+}
+
+const wrapText = (
+  context: UniApp.CanvasContext & { measureText: (text: string) => { width: number } },
+  text: string,
+  maxWidth: number
+): string[] => {
+  const rows: string[] = []
+  let currentRow = ''
+
+  for (const character of [...text]) {
+    const nextRow = `${currentRow}${character}`
+
+    if (context.measureText(nextRow).width > maxWidth && currentRow) {
+      rows.push(currentRow)
+      currentRow = character
+    } else {
+      currentRow = nextRow
+    }
+  }
+
+  if (currentRow) {
+    rows.push(currentRow)
+  }
+
+  return rows
+}
+
+const drawRoundRect = (
+  context: UniApp.CanvasContext,
+  x: number,
+  y: number,
+  width: number,
+  height: number,
+  radius: number
+): void => {
+  context.setFillStyle('rgba(0, 0, 0, 0.43)')
+  context.beginPath()
+  context.moveTo(x + radius, y)
+  context.lineTo(x + width - radius, y)
+  context.arc(x + width - radius, y + radius, radius, -Math.PI / 2, 0)
+  context.lineTo(x + width, y + height - radius)
+  context.arc(x + width - radius, y + height - radius, radius, 0, Math.PI / 2)
+  context.lineTo(x + radius, y + height)
+  context.arc(x + radius, y + height - radius, radius, Math.PI / 2, Math.PI)
+  context.lineTo(x, y + radius)
+  context.arc(x + radius, y + radius, radius, Math.PI, -Math.PI / 2)
+  context.closePath()
+  context.fill()
+}
+
+const formatDate = (date: Date): string => {
+  const values = [
+    date.getFullYear(),
+    date.getMonth() + 1,
+    date.getDate(),
+    date.getHours(),
+    date.getMinutes(),
+    date.getSeconds(),
+  ]
+  const [year, month, day, hour, minute, second] = values.map((item, index) => {
+    return index === 0 ? String(item) : String(item).padStart(2, '0')
+  })
+
+  return `${year}/${month}/${day} ${hour}:${minute}:${second}`
+}
+
+const toPositiveInteger = (input: unknown, fallback: number): number => {
+  const numberValue = Number(input)
+
+  return Number.isFinite(numberValue) && numberValue > 0 ? Math.floor(numberValue) : fallback
+}
+
+const getErrorMessage = (error: unknown): string => {
+  if (error instanceof Error) return error.message
+
+  if (error && typeof error === 'object') {
+    const rawError = error as { msg?: unknown; errMsg?: unknown }
+    const message = rawError.msg ?? rawError.errMsg
+
+    return typeof message === 'string' ? message : ''
+  }
+
+  return ''
+}
+
+const isCancelError = (error: unknown): boolean => {
+  return getErrorMessage(error).toLowerCase().includes('cancel')
+}
+
+const showMessage = (title: string, duration?: number): void => {
+  uni.showToast({
+    title,
+    icon: 'none',
+    ...(duration ? { duration } : {}),
+  })
+}
+
+/**
+ * immediate watch 会在注册时同步读取 modelText。
+ * 必须放在所有解析、序列化函数初始化之后,避免构建压缩后调用到尚未赋值的函数。
+ */
+watch(
+  modelText,
+  (nextValue) => {
+    if (nextValue === serializeAttachments()) return
+
+    attachments.value = parseAttachments(nextValue)
+  },
+  { immediate: true }
+)
+
+watch(uploading, (isUploading) => {
+  emit('uploading-change', isUploading)
+})
+
+onMounted(() => {
+  void initWatermarkAddress()
+})
+
+onBeforeUnmount(() => {
+  if (filePickerTimer) {
+    clearTimeout(filePickerTimer)
+    filePickerTimer = null
+  }
+
+  if (uploading.value) {
+    emit('uploading-change', false)
+  }
+
+  hideImageLoading()
+  fileProgressVisible.value = false
+})
+</script>
+
+<style lang="scss" scoped>
+.mixed-upload {
+  width: 100%;
+  background: #fff;
+}
+
+.attachment-grid {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 18rpx;
+  width: 100%;
+  padding: 8rpx 0 24rpx;
+  box-sizing: border-box;
+}
+
+.attachment-grid.is-disabled {
+  opacity: 0.66;
+}
+
+.attachment-item,
+.upload-trigger {
+  position: relative;
+  width: 154rpx;
+  height: 154rpx;
+  border-radius: 16rpx;
+  box-sizing: border-box;
+}
+
+.attachment-item {
+  background: #f5f7fa;
+}
+
+.attachment-image {
+  width: 100%;
+  height: 100%;
+  border-radius: 16rpx;
+}
+
+.file-card {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  padding: 10rpx;
+  border: 2rpx solid #edf0f5;
+  border-radius: 16rpx;
+  background: linear-gradient(145deg, #fff 0%, #f8fafc 100%);
+  box-sizing: border-box;
+}
+
+.file-icon {
+  width: 82rpx;
+  height: 82rpx;
+}
+
+.delete-button {
+  position: absolute;
+  top: -10rpx;
+  right: -10rpx;
+  z-index: 2;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 38rpx;
+  height: 38rpx;
+  border: 4rpx solid #fff;
+  border-radius: 50%;
+  background: #f05252;
+  box-sizing: border-box;
+  box-shadow: 0 4rpx 12rpx rgba(240, 82, 82, 0.3);
+}
+
+.upload-trigger {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  border: 2rpx dashed #9bcff8;
+  color: #3b9bed;
+  background: #f5faff;
+}
+
+.upload-trigger--hover,
+.selector-option--hover {
+  opacity: 0.78;
+}
+
+.upload-trigger__text {
+  margin-top: 8rpx;
+  font-size: 24rpx;
+  line-height: 32rpx;
+}
+
+.upload-trigger__count {
+  margin-top: 2rpx;
+  color: #8aa3ba;
+  font-size: 20rpx;
+  line-height: 28rpx;
+}
+
+.selector-panel,
+.guide-panel {
+  padding: 16rpx 28rpx calc(28rpx + env(safe-area-inset-bottom));
+  background: #fff;
+  box-sizing: border-box;
+}
+
+.popup-handle {
+  width: 72rpx;
+  height: 8rpx;
+  margin: 0 auto 26rpx;
+  border-radius: 4rpx;
+  background: #dfe3e8;
+}
+
+.selector-title,
+.guide-title {
+  color: #1f2937;
+  font-size: 34rpx;
+  font-weight: 650;
+  line-height: 48rpx;
+  text-align: center;
+}
+
+.selector-description,
+.guide-subtitle {
+  margin-top: 8rpx;
+  color: #8a94a3;
+  font-size: 24rpx;
+  line-height: 36rpx;
+  text-align: center;
+}
+
+.selector-options {
+  display: flex;
+  gap: 20rpx;
+  margin-top: 32rpx;
+}
+
+.selector-option {
+  display: flex;
+  flex: 1;
+  flex-direction: column;
+  align-items: center;
+  min-width: 0;
+  padding: 32rpx 16rpx 28rpx;
+  border: 2rpx solid #edf1f5;
+  border-radius: 20rpx;
+  background: #fafbfd;
+  box-sizing: border-box;
+}
+
+.selector-option__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 92rpx;
+  height: 92rpx;
+  border-radius: 28rpx;
+}
+
+.selector-option__icon--image {
+  background: #eaf5ff;
+}
+
+.selector-option__icon--file {
+  background: #fff5e5;
+}
+
+.selector-option__name {
+  margin-top: 20rpx;
+  color: #263445;
+  font-size: 28rpx;
+  font-weight: 600;
+  line-height: 40rpx;
+}
+
+.selector-option__description {
+  margin-top: 6rpx;
+  color: #98a2b3;
+  font-size: 22rpx;
+  line-height: 32rpx;
+}
+
+.popup-cancel,
+.guide-button {
+  height: 82rpx;
+  margin: 28rpx 0 0;
+  padding: 0;
+  border: 0;
+  border-radius: 16rpx;
+  font-size: 28rpx;
+  line-height: 82rpx;
+}
+
+.popup-cancel {
+  width: 100%;
+  color: #667085;
+  background: #f3f5f7;
+}
+
+.popup-cancel::after,
+.guide-button::after {
+  border: 0;
+}
+
+.guide-swiper {
+  width: 100%;
+  height: min(760rpx, 58vh);
+  margin-top: 24rpx;
+  overflow: hidden;
+  border: 2rpx solid #edf1f5;
+  border-radius: 20rpx;
+  background: #f7f9fb;
+}
+
+.guide-slide {
+  position: relative;
+  width: 100%;
+  height: 100%;
+}
+
+.guide-image {
+  width: 100%;
+  height: 100%;
+}
+
+.guide-step {
+  position: absolute;
+  top: 18rpx;
+  right: 18rpx;
+  height: 44rpx;
+  padding: 0 16rpx;
+  color: #fff;
+  font-size: 21rpx;
+  line-height: 44rpx;
+  border-radius: 22rpx;
+  background: rgba(31, 41, 55, 0.58);
+}
+
+.guide-tip {
+  margin-top: 18rpx;
+  padding: 18rpx 22rpx;
+  color: #6c7b8d;
+  font-size: 23rpx;
+  line-height: 34rpx;
+  text-align: center;
+  border-radius: 14rpx;
+  background: #f5f9fd;
+}
+
+.guide-actions {
+  display: flex;
+  gap: 18rpx;
+}
+
+.guide-button {
+  flex: 1;
+}
+
+.guide-button--cancel {
+  color: #667085;
+  background: #f3f5f7;
+}
+
+.guide-button--confirm {
+  color: #fff;
+  background: #3b9bed;
+}
+
+.upload-progress {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 40rpx 38rpx 36rpx;
+  background: #fff;
+  box-sizing: border-box;
+}
+
+.upload-progress__icon {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  width: 88rpx;
+  height: 88rpx;
+  border-radius: 28rpx;
+  background: #eaf5ff;
+}
+
+.upload-progress__title {
+  margin-top: 22rpx;
+  color: #263445;
+  font-size: 31rpx;
+  font-weight: 600;
+  line-height: 44rpx;
+}
+
+.upload-progress__name {
+  width: 100%;
+  margin-top: 8rpx;
+  overflow: hidden;
+  color: #8a94a3;
+  font-size: 23rpx;
+  line-height: 34rpx;
+  text-align: center;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.progress-track {
+  width: 100%;
+  height: 14rpx;
+  margin-top: 28rpx;
+  overflow: hidden;
+  border-radius: 7rpx;
+  background: #edf1f5;
+}
+
+.progress-value {
+  height: 100%;
+  border-radius: 7rpx;
+  background: linear-gradient(90deg, #65b9f6 0%, #3b9bed 100%);
+  transition: width 180ms ease;
+}
+
+.upload-progress__percent {
+  margin-top: 12rpx;
+  color: #3b9bed;
+  font-size: 25rpx;
+  font-weight: 600;
+}
+
+.upload-progress__tip {
+  margin-top: 8rpx;
+  color: #a1a9b5;
+  font-size: 21rpx;
+  line-height: 30rpx;
+}
+</style>

+ 209 - 0
src/pages-task/task-form/components/TaskFormSkeleton.vue

@@ -0,0 +1,209 @@
+<template>
+  <view class="form-skeleton">
+    <view class="form-skeleton__status">
+      <view class="form-skeleton__loading">
+        <wd-loading color="#3b9bed" size="38rpx" />
+      </view>
+      <view>
+        <view class="form-skeleton__title">正在准备任务表单</view>
+        <view class="form-skeleton__description">加载字段配置和可选内容,请稍候</view>
+      </view>
+    </view>
+
+    <view class="form-skeleton__notice skeleton-shimmer">
+      <view class="form-skeleton__notice-icon" />
+      <view class="form-skeleton__notice-line" />
+    </view>
+
+    <view class="form-skeleton__card">
+      <view v-for="index in 5" :key="index" class="form-skeleton__field">
+        <view
+          class="form-skeleton__label skeleton-shimmer"
+          :class="`form-skeleton__label--${(index % 3) + 1}`"
+        />
+        <view class="form-skeleton__value skeleton-shimmer" />
+      </view>
+    </view>
+
+    <view class="form-skeleton__upload-card">
+      <view class="form-skeleton__upload-title skeleton-shimmer" />
+      <view class="form-skeleton__upload-box skeleton-shimmer" />
+    </view>
+  </view>
+</template>
+
+<style lang="scss" scoped>
+.form-skeleton {
+  min-height: 100vh;
+  padding: 24rpx 20rpx 48rpx;
+  background: linear-gradient(180deg, #eef7ff 0, #f2f2f2 280rpx);
+  box-sizing: border-box;
+}
+
+.form-skeleton__status {
+  display: flex;
+  align-items: center;
+  padding: 28rpx 30rpx;
+  border: 2rpx solid rgba(59, 155, 237, 0.1);
+  border-radius: 20rpx;
+  background: rgba(255, 255, 255, 0.92);
+  box-shadow: 0 12rpx 34rpx rgba(65, 119, 166, 0.08);
+}
+
+.form-skeleton__loading {
+  display: flex;
+  flex-shrink: 0;
+  align-items: center;
+  justify-content: center;
+  width: 74rpx;
+  height: 74rpx;
+  margin-right: 22rpx;
+  border-radius: 24rpx;
+  background: #eaf5ff;
+}
+
+.form-skeleton__title {
+  color: #334155;
+  font-size: 29rpx;
+  font-weight: 600;
+  line-height: 42rpx;
+}
+
+.form-skeleton__description {
+  margin-top: 4rpx;
+  color: #8a97a8;
+  font-size: 23rpx;
+  line-height: 34rpx;
+}
+
+.form-skeleton__notice {
+  display: flex;
+  align-items: center;
+  height: 68rpx;
+  margin-top: 22rpx;
+  padding: 0 22rpx;
+  border-radius: 14rpx;
+  box-sizing: border-box;
+}
+
+.form-skeleton__notice-icon {
+  flex-shrink: 0;
+  width: 28rpx;
+  height: 28rpx;
+  border-radius: 50%;
+  background: rgba(255, 255, 255, 0.82);
+}
+
+.form-skeleton__notice-line {
+  width: 72%;
+  height: 18rpx;
+  margin-left: 18rpx;
+  border-radius: 9rpx;
+  background: rgba(255, 255, 255, 0.82);
+}
+
+.form-skeleton__card,
+.form-skeleton__upload-card {
+  margin-top: 20rpx;
+  overflow: hidden;
+  border-radius: 18rpx;
+  background: #fff;
+}
+
+.form-skeleton__field {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  min-height: 94rpx;
+  padding: 0 26rpx;
+  border-bottom: 2rpx solid #f3f5f7;
+  box-sizing: border-box;
+}
+
+.form-skeleton__field:last-child {
+  border-bottom: 0;
+}
+
+.form-skeleton__label,
+.form-skeleton__value,
+.form-skeleton__upload-title,
+.form-skeleton__upload-box {
+  border-radius: 10rpx;
+}
+
+.form-skeleton__label {
+  width: 150rpx;
+  height: 24rpx;
+}
+
+.form-skeleton__label--1 {
+  width: 122rpx;
+}
+
+.form-skeleton__label--2 {
+  width: 176rpx;
+}
+
+.form-skeleton__label--3 {
+  width: 146rpx;
+}
+
+.form-skeleton__value {
+  width: 230rpx;
+  height: 22rpx;
+}
+
+.form-skeleton__upload-card {
+  padding: 28rpx 26rpx 32rpx;
+}
+
+.form-skeleton__upload-title {
+  width: 210rpx;
+  height: 24rpx;
+}
+
+.form-skeleton__upload-box {
+  width: 154rpx;
+  height: 154rpx;
+  margin-top: 28rpx;
+  border-radius: 16rpx;
+}
+
+.skeleton-shimmer {
+  position: relative;
+  overflow: hidden;
+  background: #edf1f5;
+}
+
+.skeleton-shimmer::after {
+  position: absolute;
+  top: 0;
+  bottom: 0;
+  left: -70%;
+  width: 70%;
+  background: linear-gradient(
+    90deg,
+    rgba(255, 255, 255, 0) 0%,
+    rgba(255, 255, 255, 0.8) 50%,
+    rgba(255, 255, 255, 0) 100%
+  );
+  animation: skeleton-shimmer 1.45s ease-in-out infinite;
+  content: '';
+}
+
+@keyframes skeleton-shimmer {
+  0% {
+    transform: translateX(0);
+  }
+
+  100% {
+    transform: translateX(245%);
+  }
+}
+
+@media (prefers-reduced-motion: reduce) {
+  .skeleton-shimmer::after {
+    animation: none;
+  }
+}
+</style>

+ 22 - 13
src/pages-task/task-form/composables/useTaskFieldType.ts

@@ -4,6 +4,7 @@ const FIELD_TYPES = {
   dateTime: new Set<string>(['datetime']),
   location: new Set<string>(['map', 'mapwithimg', 'currPosi']),
   imageUpload: new Set<string>(['img']),
+  imageAndFileUpload: new Set<string>(['imgAndFile']),
   sign: new Set<string>(['sign']),
   multipleSelect: new Set<string>(['multiple_select']),
   inputAutoSelect: new Set<string>(['inputautoselect']),
@@ -14,20 +15,28 @@ const FIELD_TYPES = {
 
 const SUPPORTED_FIELD_TYPES = new Set(Object.values(FIELD_TYPES).flatMap((types) => [...types]))
 
+export const normalizeTaskFieldType = (type: unknown): string => {
+  const normalizedType = String(type ?? '').trim()
+  return normalizedType
+}
+
 const TASK_FIELD_TYPE_CHECKS = {
-  isSingleSelect: (type: string) => FIELD_TYPES.singleSelect.has(type),
-  isTextInput: (type: string) => FIELD_TYPES.textInput.has(type),
-  isReadonlyText: (type: string) => type === 'readonlytext',
-  isDateTime: (type: string) => FIELD_TYPES.dateTime.has(type),
-  isLocation: (type: string) => FIELD_TYPES.location.has(type),
-  isImgUpload: (type: string) => FIELD_TYPES.imageUpload.has(type),
-  isSign: (type: string) => FIELD_TYPES.sign.has(type),
-  isMultipleSelect: (type: string) => FIELD_TYPES.multipleSelect.has(type),
-  isInputAutoSelect: (type: string) => FIELD_TYPES.inputAutoSelect.has(type),
-  isDateTimeRange: (type: string) => FIELD_TYPES.dateTimeRange.has(type),
-  isLongText: (type: string) => FIELD_TYPES.longText.has(type),
-  isArea: (type: string) => FIELD_TYPES.area.has(type),
-  isSupported: (type: string) => SUPPORTED_FIELD_TYPES.has(type),
+  isSingleSelect: (type: string) => FIELD_TYPES.singleSelect.has(normalizeTaskFieldType(type)),
+  isTextInput: (type: string) => FIELD_TYPES.textInput.has(normalizeTaskFieldType(type)),
+  isReadonlyText: (type: string) => normalizeTaskFieldType(type) === 'readonlytext',
+  isDateTime: (type: string) => FIELD_TYPES.dateTime.has(normalizeTaskFieldType(type)),
+  isLocation: (type: string) => FIELD_TYPES.location.has(normalizeTaskFieldType(type)),
+  isImgUpload: (type: string) => FIELD_TYPES.imageUpload.has(normalizeTaskFieldType(type)),
+  isImgAndFileUpload: (type: string) =>
+    FIELD_TYPES.imageAndFileUpload.has(normalizeTaskFieldType(type)),
+  isSign: (type: string) => FIELD_TYPES.sign.has(normalizeTaskFieldType(type)),
+  isMultipleSelect: (type: string) => FIELD_TYPES.multipleSelect.has(normalizeTaskFieldType(type)),
+  isInputAutoSelect: (type: string) =>
+    FIELD_TYPES.inputAutoSelect.has(normalizeTaskFieldType(type)),
+  isDateTimeRange: (type: string) => FIELD_TYPES.dateTimeRange.has(normalizeTaskFieldType(type)),
+  isLongText: (type: string) => FIELD_TYPES.longText.has(normalizeTaskFieldType(type)),
+  isArea: (type: string) => FIELD_TYPES.area.has(normalizeTaskFieldType(type)),
+  isSupported: (type: string) => SUPPORTED_FIELD_TYPES.has(normalizeTaskFieldType(type)),
 }
 
 export const useTaskFieldType = () => TASK_FIELD_TYPE_CHECKS

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

@@ -13,6 +13,8 @@ import type {
 
 import { useUserStore } from '@/stores/modules/user'
 
+import { normalizeTaskFieldType } from './useTaskFieldType'
+
 export type FieldValue = string
 
 /**
@@ -164,7 +166,7 @@ export const isMultipleSelectDrugField = (
 const serializeTaskFieldType = (field: TaskFieldConfigItem): string => {
   if (isMultipleSelectDrugField(field)) return 'multiple_select'
 
-  return field.taskFiledType
+  return normalizeTaskFieldType(field.taskFiledType)
 }
 
 /**
@@ -282,7 +284,7 @@ const serializeTaskFieldConfig = (
   return config.map((field) => ({
     ...field,
     id: String(field.id),
-    originalTaskFiledType: field.taskFiledType,
+    originalTaskFiledType: normalizeTaskFieldType(field.taskFiledType),
     taskFiledType: serializeTaskFieldType(field),
     show: shouldShowField(field, deptId),
     readonly: shouldReadonlyField(field),

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

@@ -22,6 +22,8 @@ type FormAction = 'save' | 'submit'
 
 const PACKAGE_FIELD_ALIASES = ['scorePackage', 'approvalResult'] as const
 const IMAGE_FIELD_TYPE = 'img'
+const IMAGE_AND_FILE_FIELD_TYPE = 'imgAndFile'
+const SUPPORTED_DOCUMENT_EXTENSIONS = new Set(['pdf', 'ppt', 'pptx'])
 const TEXT_LENGTH_FIELD_TYPES = new Set(['text', 'readonlytext', 'longtext'])
 const SUCCESS_TOAST_DURATION = 3000
 const API_SUCCESS_CODE = 0
@@ -71,6 +73,39 @@ const getImageCount = (value: unknown): number => {
     .filter(Boolean).length
 }
 
+const getAttachmentNameExtension = (url: string): string => {
+  const cleanUrl = url.replace(/;[1234](?=$|[?#])/i, '').split(/[?#]/)[0]
+  const fileName = cleanUrl.slice(cleanUrl.lastIndexOf('/') + 1)
+  const lastDotIndex = fileName.lastIndexOf('.')
+
+  return lastDotIndex >= 0 ? fileName.slice(lastDotIndex + 1).toLowerCase() : ''
+}
+
+const getImageAndFileCount = (value: unknown): { imageCount: number; fileCount: number } => {
+  const result = { imageCount: 0, fileCount: 0 }
+
+  if (typeof value !== 'string') {
+    return result
+  }
+
+  for (const attachment of value
+    .split(',')
+    .map((item) => item.trim())
+    .filter(Boolean)) {
+    const isFile =
+      /;4(?=$|[?#])/.test(attachment) ||
+      SUPPORTED_DOCUMENT_EXTENSIONS.has(getAttachmentNameExtension(attachment))
+
+    if (isFile) {
+      result.fileCount += 1
+    } else {
+      result.imageCount += 1
+    }
+  }
+
+  return result
+}
+
 const showMessage = (title: string, duration?: number): void => {
   uni.showToast({
     title,
@@ -199,6 +234,18 @@ export const useTaskFormAction = ({
     return true
   }
 
+  const validateImageAndFileField = (field: TaskFieldConfigViewItem, value: unknown): boolean => {
+    const { imageCount, fileCount } = getImageAndFileCount(value)
+
+    if (fileCount >= 1 || imageCount >= 2) {
+      return true
+    }
+
+    showMessage(`${field.taskFiledValue}至少上传2张图片或1个PPT/PDF文件`)
+
+    return false
+  }
+
   const validateField = (field: TaskFieldConfigViewItem): boolean => {
     const value = form.value.value[field.id]?.value
 
@@ -206,6 +253,10 @@ export const useTaskFormAction = ({
       return validateImageField(field, value)
     }
 
+    if (field.taskFiledType === IMAGE_AND_FILE_FIELD_TYPE) {
+      return validateImageAndFileField(field, value)
+    }
+
     return validateContentField(field, value)
   }
 

+ 22 - 2
src/pages-task/task-form/index.vue

@@ -11,7 +11,7 @@
         <QuestionnaireTask v-if="isQuestionnaireTask" />
 
         <template v-else-if="formLoading">
-          <view class="task-form-state">正在加载表单...</view>
+          <TaskFormSkeleton />
         </template>
 
         <template v-else-if="formErrorMessage">
@@ -43,6 +43,7 @@
             :select-level="selectLevel"
             :disabled="isActionPending"
             @derived-change="handleDerivedFieldChange"
+            @uploading-change="handleFieldUploadingChange"
           />
         </template>
       </view>
@@ -89,6 +90,7 @@ import { onLoad } from '@dcloudio/uni-app'
 import DuplicateImageDialog from './components/DuplicateImageDialog.vue'
 import DynamicTaskFormFields from './components/DynamicTaskFormFields.vue'
 import QuestionnaireTask from './components/QuestionnaireTask.vue'
+import TaskFormSkeleton from './components/TaskFormSkeleton.vue'
 import { useTaskForm } from './composables/useTaskForm'
 import { useTaskFormAction } from './composables/useTaskFormAction'
 import { useTaskFormPageFeatures } from './composables/useTaskFormPageFeatures'
@@ -102,6 +104,7 @@ interface PageLoadOptions {
 const taskTypeId = ref('')
 const formLoading = ref(false)
 const formErrorMessage = ref('')
+const uploadingFieldIds = ref<Set<string>>(new Set())
 
 const {
   form,
@@ -113,7 +116,7 @@ const {
 } = useTaskForm()
 
 const {
-  isActionPending,
+  isActionPending: isActionRequestPending,
   duplicateImageVisible,
   duplicateImageList,
   clearDuplicateImages,
@@ -129,6 +132,22 @@ const { showNotice, noticeText, selectLevel, initializeTaskFormPage } = useTaskF
 const { contentStyle, footerStyle, initSafeArea } = useTaskFormSafeArea()
 
 const isQuestionnaireTask = computed(() => taskTypeId.value === '801')
+const isAttachmentUploading = computed(() => uploadingFieldIds.value.size > 0)
+const isActionPending = computed(() => {
+  return isActionRequestPending.value || isAttachmentUploading.value
+})
+
+const handleFieldUploadingChange = (fieldId: string, uploading: boolean): void => {
+  const nextUploadingFieldIds = new Set(uploadingFieldIds.value)
+
+  if (uploading) {
+    nextUploadingFieldIds.add(fieldId)
+  } else {
+    nextUploadingFieldIds.delete(fieldId)
+  }
+
+  uploadingFieldIds.value = nextUploadingFieldIds
+}
 
 const decodeRouteParam = (value?: string): string => {
   if (!value) return ''
@@ -145,6 +164,7 @@ const loadDynamicTaskForm = async (pageInitialization?: Promise<void>): Promise<
 
   formLoading.value = true
   formErrorMessage.value = ''
+  uploadingFieldIds.value = new Set()
 
   try {
     await Promise.all([pageInitialization ?? Promise.resolve(), loadTaskForm(taskTypeId.value)])

+ 1 - 0
src/static/images/icon/pdf.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1787728030797" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="8929" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M981.333333 276.053333V981.333333a42.666667 42.666667 0 0 1-42.666666 42.666667H85.333333a42.666667 42.666667 0 0 1-42.666666-42.666667V42.666667a42.666667 42.666667 0 0 1 42.666666-42.666667h619.946667z" fill="#FC5A5A" p-id="8930"></path><path d="M705.28 233.386667V0L981.333333 276.053333H747.946667a42.666667 42.666667 0 0 1-42.666667-42.666666z" fill="#FD9796" p-id="8931"></path><path d="M252.586667 715.52H224A10.666667 10.666667 0 0 1 213.333333 704V490.666667a10.666667 10.666667 0 0 1 10.666667-10.666667h62.293333q95.786667-1.28 95.786667 72.746667-2.56 69.12-81.706667 72.96h-37.12V704a10.666667 10.666667 0 0 1-10.666666 11.52z m10.666666-196.906667v66.56q3.84 0 15.36 1.28c33.92 3.413333 50.56-8.106667 49.706667-34.56 0-23.04-16.426667-34.133333-49.706667-33.28a34.986667 34.986667 0 0 1-15.36 0zM417.493333 704V490.666667a10.666667 10.666667 0 0 1 10.666667-10.666667h69.76c78.506667 0 117.973333 40.533333 119.04 118.826667s-40.533333 117.546667-119.04 117.546666h-69.76a10.666667 10.666667 0 0 1-10.666667-12.373333z m49.706667-186.24v157.44h26.88q70.4 2.56 69.12-78.08t-69.12-79.36zM697.813333 715.52h-28.586666a10.666667 10.666667 0 0 1-10.666667-10.666667V490.666667a10.666667 10.666667 0 0 1 10.666667-10.666667h130.773333a10.666667 10.666667 0 0 1 10.666667 10.666667v18.133333a10.666667 10.666667 0 0 1-10.666667 10.666667h-91.52V576h85.333333a10.666667 10.666667 0 0 1 10.666667 10.666667v18.346666a10.666667 10.666667 0 0 1-10.666667 10.666667h-85.333333V704a10.666667 10.666667 0 0 1-10.666667 11.52z" fill="#FFFFFF" p-id="8932"></path></svg>

+ 1 - 0
src/static/images/icon/ppt.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1787727925238" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="2911" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M145.6 0C100.8 0 64 36.8 64 81.6v860.8C64 987.2 100.8 1024 145.6 1024h732.8c44.8 0 81.6-36.8 81.6-81.6V324.8L657.6 0h-512z" fill="#E34221" p-id="2912"></path><path d="M960 326.4v16H755.2s-100.8-20.8-99.2-108.8c0 0 4.8 92.8 97.6 92.8H960z" fill="#DC3119" p-id="2913"></path><path d="M657.6 0v233.6c0 25.6 17.6 92.8 97.6 92.8H960L657.6 0z" fill="#FFFFFF" opacity=".5" p-id="2914"></path><path d="M304 784h-54.4v67.2c0 6.4-4.8 11.2-11.2 11.2-6.4 0-12.8-4.8-12.8-11.2V686.4c0-9.6 8-17.6 17.6-17.6H304c38.4 0 59.2 25.6 59.2 57.6S340.8 784 304 784z m-3.2-94.4h-51.2v73.6h51.2c22.4 0 38.4-16 38.4-36.8 0-22.4-16-36.8-38.4-36.8zM480 784h-54.4v67.2c0 6.4-4.8 11.2-11.2 11.2-6.4 0-11.2-4.8-11.2-11.2V686.4c0-9.6 6.4-17.6 16-17.6H480c38.4 0 59.2 25.6 59.2 57.6S518.4 784 480 784z m-3.2-94.4h-49.6v73.6h49.6c22.4 0 38.4-16 38.4-36.8 0-22.4-16-36.8-38.4-36.8z m225.6 0h-52.8v161.6c0 6.4-4.8 11.2-11.2 11.2-6.4 0-12.8-4.8-12.8-11.2V689.6h-51.2c-6.4 0-11.2-4.8-11.2-11.2 0-4.8 4.8-9.6 11.2-9.6h128c6.4 0 11.2 4.8 11.2 11.2 0 4.8-4.8 9.6-11.2 9.6z" fill="#FFFFFF" p-id="2915"></path></svg>