Bläddra i källkod

完成表单bug修改

yuanmingze 1 vecka sedan
förälder
incheckning
fbab14b6b8

+ 6 - 0
src/pages-task/task-form/components/DynamicTaskFormFields.vue

@@ -105,6 +105,9 @@
             v-model:value="form.value[item.id].value"
             :task-field-config="item"
             :disabled="isFieldDisabled(item)"
+            @keyboard-focus="emit('long-text-keyboard-focus', item.id)"
+            @keyboard-blur="emit('long-text-keyboard-blur', item.id)"
+            @keyboard-height-change="emit('long-text-keyboard-height-change', item.id, $event)"
           />
 
           <view class="line" />
@@ -177,6 +180,9 @@ const props = withDefaults(
 const emit = defineEmits<{
   (event: 'derived-change', field: TaskFieldConfigViewItem, change: DerivedFieldChangeEvent): void
   (event: 'uploading-change', fieldId: string, uploading: boolean): void
+  (event: 'long-text-keyboard-focus', fieldId: string): void
+  (event: 'long-text-keyboard-blur', fieldId: string): void
+  (event: 'long-text-keyboard-height-change', fieldId: string, result: { height?: number }): void
 }>()
 
 const form = defineModel<DynamicFormState>({ required: true })

+ 86 - 131
src/pages-task/task-form/components/ImgAndFileUpload.vue

@@ -154,7 +154,7 @@
       </view>
     </wd-popup>
 
-    <canvas :canvas-id="canvasId" :style="watermarkCanvasStyle" />
+    <canvas :id="canvasId" :canvas-id="canvasId" :style="watermarkCanvasStyle" />
   </view>
 </template>
 
@@ -176,6 +176,8 @@ import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
 import { useUserStore } from '@/stores/modules/user'
 
+import { renderTaskWatermark } from '../composables/taskWatermark'
+
 type AttachmentType = 'image' | 'file'
 type UploadSource = 'camera' | 'album' | 'file'
 type SourceSuffix = '' | ';1' | ';2' | ';3' | ';4'
@@ -225,9 +227,6 @@ 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 MAX_WATERMARK_LONG_EDGE = 1920
-const WATERMARK_JPEG_QUALITY = 0.82
-const WATERMARK_TIMEOUT_MS = 20000
 const UPLOAD_TIMEOUT_MS = 30000
 const uploadLogEnabled = import.meta.env.DEV || import.meta.env.VITE_REQUEST_LOG === 'true'
 const SOURCE_SUFFIX_MAP: Record<UploadSource, SourceSuffix> = {
@@ -266,7 +265,7 @@ const maxCount = computed(() => {
   return toPositiveInteger(props.taskFieldConfig.taskFiledMaxsize, 10)
 })
 
-const canvasId = computed(() => `imgAndFileWatermarkCanvas-${props.taskFieldConfig.id}`)
+const canvasId = computed(() => `imgAndFileWatermarkCanvas-${instance?.uid}`)
 
 const watermarkCanvasStyle = computed<CSSProperties>(() => ({
   width: `${watermarkCanvasWidth.value}px`,
@@ -327,9 +326,10 @@ const selectAndUploadImages = async (source: 'camera' | 'album'): Promise<void>
     }
 
     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)
+    const files = await chooseImages(source === 'camera' ? 1 : Math.min(remainingCount, 9), source)
+    // 拍照原图先缩放并加水印,再检查最终上传文件;iOS 原图可能超过 2MB。
+    const oversizedFiles = files.filter((file) => source === 'album' && file.size > IMAGE_MAX_SIZE)
+    const validFiles = files.filter((file) => source === 'camera' || file.size <= IMAGE_MAX_SIZE)
 
     if (oversizedFiles.length > 0) {
       showMessage(`有${oversizedFiles.length}张图片超过2MB,已跳过`, 2500)
@@ -440,6 +440,7 @@ const uploadBatch = async (files: SelectedFile[], source: UploadSource): Promise
   const isFileUpload = source === 'file'
   let uploadedCount = 0
   let failedCount = 0
+  let firstFailureMessage = ''
 
   uploading.value = true
 
@@ -473,7 +474,7 @@ const uploadBatch = async (files: SelectedFile[], source: UploadSource): Promise
           showMessage('文件不能超过50MB')
           continue
         }
-      } else if (file.size > IMAGE_MAX_SIZE) {
+      } else if (source === 'album' && file.size > IMAGE_MAX_SIZE) {
         showMessage('图片不能超过2MB')
         continue
       }
@@ -488,10 +489,22 @@ const uploadBatch = async (files: SelectedFile[], source: UploadSource): Promise
       }
 
       try {
-        const uploadPath =
-          source === 'camera'
-            ? await withTimeout(addWatermark(file.path), WATERMARK_TIMEOUT_MS, '图片处理超时')
-            : file.path
+        const uploadPath = source === 'camera' ? await addWatermark(file.path) : file.path
+        if (source === 'camera') {
+          await new Promise<void>((resolve, reject) => {
+            uni.getFileInfo({
+              filePath: uploadPath,
+              success: (result) => {
+                if (result.size > IMAGE_MAX_SIZE) {
+                  reject(new Error('图片处理后仍超过2MB,请重新拍摄'))
+                  return
+                }
+                resolve()
+              },
+              fail: reject,
+            })
+          })
+        }
         const originalFileName = isFileUpload ? name : ''
         const uploadedUrl = await uploadFile(uploadPath, originalFileName, isFileUpload)
 
@@ -499,6 +512,7 @@ const uploadBatch = async (files: SelectedFile[], source: UploadSource): Promise
         uploadedCount += 1
       } catch (error) {
         failedCount += 1
+        firstFailureMessage ||= getErrorMessage(error)
 
         if (pendingId) {
           removePendingAttachment(pendingId)
@@ -525,7 +539,7 @@ const uploadBatch = async (files: SelectedFile[], source: UploadSource): Promise
       ? '上传成功'
       : uploadedCount > 0
         ? `${uploadedCount}张成功,${failedCount}张失败`
-        : '上传失败'
+        : firstFailureMessage || '上传失败'
 
     uni.showToast({
       title,
@@ -834,107 +848,65 @@ const previewFile = (attachment: Attachment): void => {
 }
 
 const addWatermark = (filePath: string): Promise<string> => {
-  return new Promise((resolve, reject) => {
-    uni.getImageInfo({
-      src: filePath,
-      success: async (imageInfo) => {
-        const sourceWidth = Number(imageInfo.width)
-        const sourceHeight = Number(imageInfo.height)
-
-        if (!sourceWidth || !sourceHeight) {
-          reject(new Error('无法读取图片尺寸'))
-          return
-        }
-
-        const canvasScale = Math.min(
-          1,
-          MAX_WATERMARK_LONG_EDGE / Math.max(sourceWidth, sourceHeight)
-        )
-        const width = Math.max(1, Math.round(sourceWidth * canvasScale))
-        const height = Math.max(1, Math.round(sourceHeight * canvasScale))
-
-        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(imageInfo.path || 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
+  return renderTaskWatermark({
+    filePath,
+    canvasId: canvasId.value,
+    componentInstance: instance?.proxy,
+    resize: async (width, height) => {
+      watermarkCanvasWidth.value = width
+      watermarkCanvasHeight.value = height
+      await nextTick()
+    },
+    draw: (context, width, height, imagePath) => {
+      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(imagePath, 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.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',
-                width,
-                height,
-                destWidth: width,
-                destHeight: height,
-                quality: WATERMARK_JPEG_QUALITY,
-                success: (result) => resolve(result.tempFilePath),
-                fail: reject,
-              },
-              componentInstance
-            )
-          }, 300)
-        })
-      },
-      fail: reject,
-    })
+      })
+    },
   })
 }
 
@@ -1026,23 +998,6 @@ const isCancelError = (error: unknown): boolean => {
   return getErrorMessage(error).toLowerCase().includes('cancel')
 }
 
-const withTimeout = <T,>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> => {
-  return new Promise((resolve, reject) => {
-    const timer = setTimeout(() => reject(new Error(message)), timeoutMs)
-
-    promise.then(
-      (result) => {
-        clearTimeout(timer)
-        resolve(result)
-      },
-      (error) => {
-        clearTimeout(timer)
-        reject(error)
-      }
-    )
-  })
-}
-
 const showMessage = (title: string, duration?: number): void => {
   uni.showToast({
     title,

+ 65 - 135
src/pages-task/task-form/components/ImgUpload.vue

@@ -35,7 +35,7 @@
       </view>
     </FormField>
 
-    <canvas :canvas-id="canvasId" :style="watermarkCanvasStyle" />
+    <canvas :id="watermarkCanvasId" :canvas-id="watermarkCanvasId" :style="watermarkCanvasStyle" />
   </view>
 </template>
 
@@ -57,6 +57,8 @@ import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
 import { useUserStore } from '@/stores/modules/user'
 
+import { renderTaskWatermark } from '../composables/taskWatermark'
+
 type FieldValue = string
 type UploadSource = 'camera' | 'album'
 
@@ -69,12 +71,6 @@ interface UploadResponse {
   } | null
 }
 
-interface LocationResult {
-  result?: {
-    address?: string
-  }
-}
-
 interface Props {
   taskFieldConfig: TaskFieldConfigItem
   disabled?: boolean
@@ -102,6 +98,7 @@ const label = defineModel<FieldValue>('label', {
 
 const userStore = useUserStore()
 const instance = getCurrentInstance()
+const watermarkCanvasId = computed(() => `${props.canvasId}-${instance?.uid}`)
 
 const uploading = ref(false)
 const watermarkAddress = ref('')
@@ -110,9 +107,6 @@ const watermarkCanvasHeight = ref(1)
 let watermarkAddressPromise: Promise<void> | null = null
 
 const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
-const MAX_WATERMARK_LONG_EDGE = 1920
-const WATERMARK_JPEG_QUALITY = 0.82
-const WATERMARK_TIMEOUT_MS = 20000
 const UPLOAD_TIMEOUT_MS = 30000
 const uploadLogEnabled = import.meta.env.DEV || import.meta.env.VITE_REQUEST_LOG === 'true'
 
@@ -174,8 +168,8 @@ const initWatermarkAddress = (): Promise<void> => {
 
   watermarkAddressPromise ??= (async () => {
     try {
-      const result = (await getLocation()) as LocationResult
-      watermarkAddress.value = result.result?.address ?? ''
+      const result = await getLocation()
+      watermarkAddress.value = result.address ?? ''
     } catch (error) {
       watermarkAddressPromise = null
       console.warn('[ImgUpload] get location failed:', error)
@@ -229,10 +223,7 @@ const chooseImage = async () => {
 
     for (const filePath of filePaths) {
       try {
-        const uploadFilePath =
-          source === 'camera'
-            ? await withTimeout(addWatermark(filePath), WATERMARK_TIMEOUT_MS, '图片处理超时')
-            : filePath
+        const uploadFilePath = source === 'camera' ? await addWatermark(filePath) : filePath
         const uploadedUrl = await upload(uploadFilePath)
 
         uploadedUrls.push(appendUploadSourceSuffix(uploadedUrl, source))
@@ -269,7 +260,7 @@ const chooseImage = async () => {
     hideUploadLoading()
 
     uni.showToast({
-      title: '上传失败',
+      title: getErrorMessage(error) || '上传失败',
       icon: 'none',
     })
   } finally {
@@ -391,108 +382,64 @@ const upload = (filePath: string): Promise<string> => {
 }
 
 const addWatermark = (filePath: string): Promise<string> => {
-  return new Promise((resolve, reject) => {
-    uni.getImageInfo({
-      src: filePath,
-      success: async (imageInfo) => {
-        const sourceWidth = Number(imageInfo.width)
-        const sourceHeight = Number(imageInfo.height)
-
-        if (!sourceWidth || !sourceHeight) {
-          reject(new Error('无法读取图片尺寸'))
-          return
-        }
-
-        const canvasScale = Math.min(
-          1,
-          MAX_WATERMARK_LONG_EDGE / Math.max(sourceWidth, sourceHeight)
+  return renderTaskWatermark({
+    filePath,
+    canvasId: watermarkCanvasId.value,
+    componentInstance: instance?.proxy,
+    resize: async (width, height) => {
+      watermarkCanvasWidth.value = width
+      watermarkCanvasHeight.value = height
+      await nextTick()
+    },
+    draw: (ctx, width, height, imagePath) => {
+      const designWidth = 375
+      const scaleRatio = width / designWidth
+
+      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
+
+      ctx.drawImage(imagePath, 0, 0, width, height)
+      ctx.setFontSize(fontSize)
+
+      const text = getWatermarkText()
+      const maxTextWidth = width - iconSize - textSpacing - padding * 3 - leftSpacing * 2
+      const textRows = wrapText(ctx, text, maxTextWidth)
+
+      textRows.push(formatDate(new Date()))
+
+      const totalRows = textRows.length
+      const boxHeight = lineHeight * totalRows + textTop
+      const textWidth = Math.max(...textRows.map((row) => ctx.measureText(row).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(ctx, boxX, boxY, boxWidth, boxHeight, radius)
+
+      const iconPath = '/static/images/task/watermarkIcon.png'
+      ctx.drawImage(iconPath, boxX + padding, boxY + padding - 12, iconSize, iconSize)
+
+      ctx.setFontSize(fontSize)
+      ctx.setFillStyle('#ffffff')
+
+      for (let index = 0; index < textRows.length; index += 1) {
+        ctx.fillText(
+          textRows[index],
+          boxX + iconSize + textSpacing + padding,
+          boxY + lineHeight * (index + 1)
         )
-        const width = Math.max(1, Math.round(sourceWidth * canvasScale))
-        const height = Math.max(1, Math.round(sourceHeight * canvasScale))
-
-        watermarkCanvasWidth.value = width
-        watermarkCanvasHeight.value = height
-        await nextTick()
-
-        const componentInstance = instance?.proxy
-        const ctx = uni.createCanvasContext(
-          props.canvasId,
-          componentInstance
-        ) as UniApp.CanvasContext & {
-          measureText: (text: string) => { width: number }
-        }
-
-        const designWidth = 375
-        const scaleRatio = width / designWidth
-
-        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
-
-        ctx.drawImage(imageInfo.path || filePath, 0, 0, width, height)
-        ctx.setFontSize(fontSize)
-
-        const text = getWatermarkText()
-        const maxTextWidth = width - iconSize - textSpacing - padding * 3 - leftSpacing * 2
-        const textRows = wrapText(ctx, text, maxTextWidth)
-
-        textRows.push(formatDate(new Date()))
-
-        const totalRows = textRows.length
-        const boxHeight = lineHeight * totalRows + textTop
-        const textWidth = Math.max(...textRows.map((row) => ctx.measureText(row).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(ctx, boxX, boxY, boxWidth, boxHeight, radius)
-
-        const iconPath = '/static/images/task/watermarkIcon.png'
-        ctx.drawImage(iconPath, boxX + padding, boxY + padding - 12, iconSize, iconSize)
-
-        ctx.setFontSize(fontSize)
-        ctx.setFillStyle('#ffffff')
-
-        for (let index = 0; index < textRows.length; index += 1) {
-          ctx.fillText(
-            textRows[index],
-            boxX + iconSize + textSpacing + padding,
-            boxY + lineHeight * (index + 1)
-          )
-        }
-
-        ctx.draw(false, () => {
-          setTimeout(() => {
-            uni.canvasToTempFilePath(
-              {
-                canvasId: props.canvasId,
-                fileType: 'jpg',
-                width,
-                height,
-                destWidth: width,
-                destHeight: height,
-                quality: WATERMARK_JPEG_QUALITY,
-                success: (res) => {
-                  resolve(res.tempFilePath)
-                },
-                fail: reject,
-              },
-              componentInstance
-            )
-          }, 300)
-        })
-      },
-      fail: reject,
-    })
+      }
+    },
   })
 }
 
@@ -677,23 +624,6 @@ const getErrorMessage = (error: unknown): string => {
   return ''
 }
 
-const withTimeout = <T,>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> => {
-  return new Promise((resolve, reject) => {
-    const timer = setTimeout(() => reject(new Error(message)), timeoutMs)
-
-    promise.then(
-      (result) => {
-        clearTimeout(timer)
-        resolve(result)
-      },
-      (error) => {
-        clearTimeout(timer)
-        reject(error)
-      }
-    )
-  })
-}
-
 watch(uploading, (isUploading) => {
   emit('uploading-change', isUploading)
 })

+ 63 - 5
src/pages-task/task-form/components/LongText.vue

@@ -5,18 +5,26 @@
       :required="taskFieldConfig.isMustfill === '1'"
       layout="vertical"
     >
-      <view class="textarea-wrapper">
-        <wd-textarea
+      <view class="textarea-wrapper" @tap="focusTextarea">
+        <textarea
+          class="long-text-input"
           v-model="textareaValue"
+          :focus="focused"
           :maxlength="maxLength"
           :placeholder="'请输入' + taskFieldConfig.taskFiledValue"
           :disabled="props.disabled"
           :adjust-position="true"
           :cursor-spacing="120"
-          clearable
-          custom-style="--wot-textarea-inner-min-height: 220rpx; min-height: 300rpx; width: 100%; padding-bottom: 56rpx; box-sizing: border-box;"
+          :show-confirm-bar="true"
+          :disable-default-padding="true"
+          @focus="handleFocus"
+          @blur="handleBlur"
+          @keyboardheightchange="handleKeyboardHeightChange"
         />
 
+        <view v-if="!props.disabled && textareaValue" class="clear-button" @tap.stop="clearText">
+          <wd-icon name="close-circle" size="36rpx" color="#8a8f99" />
+        </view>
         <text class="word-limit"> {{ currentLength }}/{{ maxLength }} </text>
       </view>
     </FormField>
@@ -24,7 +32,7 @@
 </template>
 
 <script setup lang="ts">
-import { computed } from 'vue'
+import { computed, ref } from 'vue'
 
 import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
 
@@ -39,6 +47,40 @@ const props = withDefaults(defineProps<Props>(), {
   disabled: false,
 })
 
+const emit = defineEmits<{
+  (event: 'keyboard-focus'): void
+  (event: 'keyboard-blur'): void
+  (event: 'keyboard-height-change', result: { height?: number }): void
+}>()
+
+const focused = ref(false)
+
+const focusTextarea = () => {
+  if (!props.disabled) focused.value = true
+}
+
+const handleFocus = () => {
+  focused.value = true
+  emit('keyboard-focus')
+}
+
+const handleBlur = (event: { detail: { value: string } }) => {
+  // iOS 收起键盘时再同步一次最终文本,包括输入法最后确认的内容。
+  textareaValue.value = event.detail.value
+  focused.value = false
+  emit('keyboard-blur')
+}
+
+const handleKeyboardHeightChange = (event: { detail: { height?: number } }) => {
+  if (!event.detail.height) focused.value = false
+  emit('keyboard-height-change', event.detail)
+}
+
+const clearText = () => {
+  textareaValue.value = ''
+  focusTextarea()
+}
+
 const value = defineModel<FieldValue>('value', {
   default: '',
 })
@@ -74,6 +116,22 @@ const currentLength = computed(() => Array.from(textareaValue.value).length)
     position: relative;
     min-height: 300rpx;
     width: 100%;
+    padding: 24rpx 56rpx 56rpx 24rpx;
+    box-sizing: border-box;
+  }
+
+  .long-text-input {
+    width: 100%;
+    height: 220rpx;
+    color: #333;
+    font-size: 28rpx;
+    line-height: 44rpx;
+  }
+
+  .clear-button {
+    position: absolute;
+    top: 24rpx;
+    right: 12rpx;
   }
 
   .word-limit {

+ 123 - 0
src/pages-task/task-form/composables/taskWatermark.ts

@@ -0,0 +1,123 @@
+import type { ComponentPublicInstance } from 'vue'
+
+export type WatermarkCanvasContext = UniApp.CanvasContext & {
+  measureText: (text: string) => { width: number }
+}
+
+interface WatermarkOptions {
+  filePath: string
+  canvasId: string
+  componentInstance: ComponentPublicInstance | null | undefined
+  resize: (width: number, height: number) => Promise<void>
+  draw: (context: WatermarkCanvasContext, width: number, height: number, imagePath: string) => void
+}
+
+const MAX_WATERMARK_LONG_EDGE = 1920
+const WATERMARK_JPEG_QUALITY = 0.82
+
+// 每个原生回调单独收口,超时后不会再进入下一步。
+const runCanvasStep = <T>(
+  stage: string,
+  start: (resolve: (value: T) => void, reject: (error: unknown) => void) => void
+): Promise<T> => {
+  return new Promise((resolve, reject) => {
+    let settled = false
+    const fail = (error: unknown) => {
+      if (settled) return
+      settled = true
+      clearTimeout(timer)
+      const rawError = error as { message?: unknown; errMsg?: unknown } | null
+      const message = rawError?.message ?? rawError?.errMsg
+      const detail = typeof message === 'string' ? message : ''
+      reject(new Error(`${stage}失败${detail ? `:${detail}` : ''}`))
+    }
+    const timer = setTimeout(() => fail(new Error('超时,请重试')), 5000)
+
+    try {
+      start((result) => {
+        if (settled) return
+        settled = true
+        clearTimeout(timer)
+        resolve(result)
+      }, fail)
+    } catch (error) {
+      fail(error)
+    }
+  })
+}
+
+export const renderTaskWatermark = async (options: WatermarkOptions): Promise<string> => {
+  const { filePath, canvasId, componentInstance, resize, draw } = options
+  const imageInfo = await runCanvasStep<UniApp.GetImageInfoSuccessData>(
+    '读取图片',
+    (resolve, reject) => {
+      uni.getImageInfo({ src: filePath, success: resolve, fail: reject })
+    }
+  )
+  const sourceWidth = Number(imageInfo.width)
+  const sourceHeight = Number(imageInfo.height)
+  if (
+    !Number.isFinite(sourceWidth) ||
+    !Number.isFinite(sourceHeight) ||
+    sourceWidth <= 0 ||
+    sourceHeight <= 0
+  ) {
+    throw new Error('无法读取图片尺寸')
+  }
+
+  const scale = Math.min(1, MAX_WATERMARK_LONG_EDGE / Math.max(sourceWidth, sourceHeight))
+  const width = Math.max(1, Math.round(sourceWidth * scale))
+  const height = Math.max(1, Math.round(sourceHeight * scale))
+
+  try {
+    await resize(width, height)
+    // 等待小程序视图层确认尺寸,不能只等待 Vue 的响应式更新。
+    await runCanvasStep<void>('水印画布布局', (resolve, reject) => {
+      const query = uni.createSelectorQuery()
+      if (componentInstance) query.in(componentInstance)
+      query
+        .select(`#${canvasId}`)
+        .boundingClientRect((rect) => {
+          if (
+            !rect ||
+            Array.isArray(rect) ||
+            typeof rect.width !== 'number' ||
+            typeof rect.height !== 'number' ||
+            Math.abs(rect.width - width) > 1 ||
+            Math.abs(rect.height - height) > 1
+          ) {
+            reject(new Error('画布尺寸未就绪,请重试'))
+            return
+          }
+          resolve()
+        })
+        .exec()
+    })
+
+    await runCanvasStep<void>('水印绘制', (resolve) => {
+      const context = uni.createCanvasContext(canvasId, componentInstance) as WatermarkCanvasContext
+      draw(context, width, height, imageInfo.path || filePath)
+      context.draw(false, resolve)
+    })
+
+    return await runCanvasStep<string>('水印导出', (resolve, reject) => {
+      uni.canvasToTempFilePath(
+        {
+          canvasId,
+          fileType: 'jpg',
+          width,
+          height,
+          destWidth: width,
+          destHeight: height,
+          quality: WATERMARK_JPEG_QUALITY,
+          success: (result) => resolve(result.tempFilePath),
+          fail: reject,
+        },
+        componentInstance
+      )
+    })
+  } finally {
+    // 导出后释放大画布,避免多个拍照字段持续占用 iOS 图像内存。
+    await resize(1, 1)
+  }
+}

+ 49 - 6
src/pages-task/task-form/composables/useTaskFormSafeArea.ts

@@ -1,6 +1,6 @@
 import { computed, type CSSProperties, ref } from 'vue'
 
-import { onUnload } from '@dcloudio/uni-app'
+import { onHide, onShow, onUnload } from '@dcloudio/uni-app'
 
 type KeyboardHeightChangeResult = {
   height?: number
@@ -26,6 +26,7 @@ const FOOTER_TOP_PADDING_RPX = 20
 const FOOTER_BOTTOM_PADDING_RPX = 20
 const FOOTER_BUTTON_HEIGHT_RPX = 88
 const CONTENT_EXTRA_BOTTOM_RPX = 24
+const KEYBOARD_BLUR_FALLBACK_DELAY_MS = 350
 
 export const useTaskFormSafeArea = () => {
   const keyboardApi = uni as unknown as MiniProgramKeyboardApi
@@ -33,6 +34,8 @@ export const useTaskFormSafeArea = () => {
   const windowWidth = ref(375)
   const safeAreaBottom = ref(0)
   const keyboardHeight = ref(0)
+  const focusedLongTextFieldId = ref('')
+  let keyboardBlurFallbackTimer: ReturnType<typeof setTimeout> | undefined
   let keyboardListenerRegistered = false
 
   const rpxToPx = (rpx: number) => {
@@ -43,7 +46,9 @@ export const useTaskFormSafeArea = () => {
     return keyboardHeight.value > 0 ? 0 : safeAreaBottom.value
   })
 
-  const keyboardVisible = computed(() => keyboardHeight.value > 0)
+  const keyboardVisible = computed(() => {
+    return keyboardHeight.value > 0 || Boolean(focusedLongTextFieldId.value)
+  })
 
   const footerHeightPx = computed(() => {
     return (
@@ -53,8 +58,7 @@ export const useTaskFormSafeArea = () => {
   })
 
   const contentStyle = computed<CSSProperties>(() => {
-    const paddingBottom =
-      footerHeightPx.value + keyboardHeight.value + rpxToPx(CONTENT_EXTRA_BOTTOM_RPX)
+    const paddingBottom = footerHeightPx.value + rpxToPx(CONTENT_EXTRA_BOTTOM_RPX)
 
     return {
       paddingBottom: `${paddingBottom}px`,
@@ -63,7 +67,7 @@ export const useTaskFormSafeArea = () => {
 
   const footerStyle = computed<CSSProperties>(() => {
     return {
-      bottom: `${keyboardHeight.value}px`,
+      bottom: '0px',
       paddingBottom: `${rpxToPx(FOOTER_BOTTOM_PADDING_RPX) + activeSafeAreaBottom.value}px`,
     }
   })
@@ -93,6 +97,39 @@ export const useTaskFormSafeArea = () => {
     const height = Number(result.height ?? 0)
 
     keyboardHeight.value = Number.isFinite(height) && height > 0 ? height : 0
+    if (keyboardHeight.value === 0) resetKeyboardState()
+  }
+
+  const clearKeyboardBlurFallback = () => {
+    if (keyboardBlurFallbackTimer === undefined) return
+    clearTimeout(keyboardBlurFallbackTimer)
+    keyboardBlurFallbackTimer = undefined
+  }
+
+  const resetKeyboardState = () => {
+    clearKeyboardBlurFallback()
+    keyboardHeight.value = 0
+    focusedLongTextFieldId.value = ''
+  }
+
+  const handleLongTextKeyboardFocus = (fieldId: string) => {
+    clearKeyboardBlurFallback()
+    focusedLongTextFieldId.value = fieldId
+  }
+
+  const handleLongTextKeyboardBlur = (fieldId: string) => {
+    if (focusedLongTextFieldId.value !== fieldId) return
+    clearKeyboardBlurFallback()
+    // 字段切换时先 blur 再 focus,延迟恢复,避免底部按钮闪现。
+    keyboardBlurFallbackTimer = setTimeout(resetKeyboardState, KEYBOARD_BLUR_FALLBACK_DELAY_MS)
+  }
+
+  const handleLongTextKeyboardHeightChange = (
+    fieldId: string,
+    result: KeyboardHeightChangeResult
+  ) => {
+    if (focusedLongTextFieldId.value && focusedLongTextFieldId.value !== fieldId) return
+    handleKeyboardHeightChange(result)
   }
 
   const initSafeArea = () => {
@@ -111,15 +148,18 @@ export const useTaskFormSafeArea = () => {
   }
 
   const disposeSafeArea = () => {
+    resetKeyboardState()
     if (!keyboardListenerRegistered) {
       return
     }
 
     keyboardApi.offKeyboardHeightChange?.(handleKeyboardHeightChange)
     keyboardListenerRegistered = false
-    keyboardHeight.value = 0
   }
 
+  // 从拍照、图片预览或后台返回时,不保留旧键盘高度。
+  onHide(resetKeyboardState)
+  onShow(resetKeyboardState)
   onUnload(disposeSafeArea)
 
   return {
@@ -127,5 +167,8 @@ export const useTaskFormSafeArea = () => {
     footerStyle,
     keyboardVisible,
     initSafeArea,
+    handleLongTextKeyboardFocus,
+    handleLongTextKeyboardBlur,
+    handleLongTextKeyboardHeightChange,
   }
 }

+ 15 - 15
src/pages-task/task-form/index.vue

@@ -1,12 +1,7 @@
 <template>
   <view class="task-form">
-    <scroll-view
-      class="task-form__scroll"
-      scroll-y
-      enhanced
-      :show-scrollbar="false"
-      :bounces="false"
-    >
+    <!-- 原生 textarea 和水印 canvas 使用页面滚动,避免嵌入 scroll-view。 -->
+    <view>
       <view class="task-form__content" :style="isQuestionnaireTask ? undefined : contentStyle">
         <QuestionnaireTask v-if="isQuestionnaireTask" />
 
@@ -44,10 +39,13 @@
             :disabled="isActionPending"
             @derived-change="handleDerivedFieldChange"
             @uploading-change="handleFieldUploadingChange"
+            @long-text-keyboard-focus="handleLongTextKeyboardFocus"
+            @long-text-keyboard-blur="handleLongTextKeyboardBlur"
+            @long-text-keyboard-height-change="handleLongTextKeyboardHeightChange"
           />
         </template>
       </view>
-    </scroll-view>
+    </view>
 
     <DuplicateImageDialog
       v-if="!isQuestionnaireTask"
@@ -134,7 +132,15 @@ const {
 })
 const { showNotice, noticeText, selectLevel, initializeTaskFormPage } = useTaskFormPageFeatures()
 
-const { contentStyle, footerStyle, keyboardVisible, initSafeArea } = useTaskFormSafeArea()
+const {
+  contentStyle,
+  footerStyle,
+  keyboardVisible,
+  initSafeArea,
+  handleLongTextKeyboardFocus,
+  handleLongTextKeyboardBlur,
+  handleLongTextKeyboardHeightChange,
+} = useTaskFormSafeArea()
 
 const isQuestionnaireTask = computed(() => taskTypeId.value === '801')
 const isAttachmentUploading = computed(() => uploadingFieldIds.value.size > 0)
@@ -242,15 +248,9 @@ onLoad(async (options: PageLoadOptions = {}) => {
   position: relative;
   width: 100%;
   min-height: 100vh;
-  overflow: hidden;
   background: #f2f2f2;
 }
 
-.task-form__scroll {
-  width: 100%;
-  height: 100vh;
-}
-
 .task-form__content {
   min-height: 100vh;
   box-sizing: border-box;