|
@@ -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>
|