|
@@ -0,0 +1,637 @@
|
|
|
|
|
+<template>
|
|
|
|
|
+ <view class="img-upload">
|
|
|
|
|
+ <FormField
|
|
|
|
|
+ :title="taskFieldConfig.taskFiledValue"
|
|
|
|
|
+ :required="taskFieldConfig.isMustfill === '1'"
|
|
|
|
|
+ :description="description"
|
|
|
|
|
+ layout="vertical"
|
|
|
|
|
+ >
|
|
|
|
|
+ <view class="upload-container" :class="{ 'is-disabled': isDisabled }">
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-for="(url, index) in imageList"
|
|
|
|
|
+ :key="`${url}-${index}`"
|
|
|
|
|
+ class="image-box"
|
|
|
|
|
+ @click.stop="previewImage(index)"
|
|
|
|
|
+ >
|
|
|
|
|
+ <image class="upload-image" :src="toFullUrl(url)" mode="aspectFill" />
|
|
|
|
|
+
|
|
|
|
|
+ <view v-if="!isDisabled" class="delete-btn" @click.stop="removeImage(index)">
|
|
|
|
|
+ <wd-icon name="close" size="24rpx" color="#fff" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+
|
|
|
|
|
+ <view
|
|
|
|
|
+ v-if="canUpload"
|
|
|
|
|
+ class="upload-box"
|
|
|
|
|
+ :class="{ 'is-uploading': uploading }"
|
|
|
|
|
+ @click.stop="chooseImage"
|
|
|
|
|
+ >
|
|
|
|
|
+ <text class="upload-plus">+</text>
|
|
|
|
|
+ <text class="upload-text">
|
|
|
|
|
+ {{ uploading ? '上传中' : '选择图片' }}
|
|
|
|
|
+ </text>
|
|
|
|
|
+ <text class="upload-count"> {{ imageList.length }}/{{ maxCount }} </text>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </view>
|
|
|
|
|
+ </FormField>
|
|
|
|
|
+
|
|
|
|
|
+ <canvas canvas-id="imgUploadWatermarkCanvas" :style="watermarkCanvasStyle" />
|
|
|
|
|
+ </view>
|
|
|
|
|
+</template>
|
|
|
|
|
+
|
|
|
|
|
+<script setup lang="ts">
|
|
|
|
|
+import { computed, getCurrentInstance, onMounted, ref } from 'vue'
|
|
|
|
|
+
|
|
|
|
|
+import { getLocation } from '@/lib/location'
|
|
|
|
|
+
|
|
|
|
|
+import type { TaskFieldConfigItem } from '@/services/modules/task/taskFrom/type'
|
|
|
|
|
+
|
|
|
|
|
+import { useUserStore } from '@/stores/modules/user'
|
|
|
|
|
+
|
|
|
|
|
+type FieldValue = string | null
|
|
|
|
|
+type UploadSource = 'camera' | 'album'
|
|
|
|
|
+
|
|
|
|
|
+interface UploadResponse {
|
|
|
|
|
+ code: number
|
|
|
|
|
+ success: boolean
|
|
|
|
|
+ msg: string | null
|
|
|
|
|
+ data: {
|
|
|
|
|
+ url: string
|
|
|
|
|
+ } | null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface LocationResult {
|
|
|
|
|
+ result?: {
|
|
|
|
|
+ address?: string
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Props {
|
|
|
|
|
+ taskFieldConfig: TaskFieldConfigItem
|
|
|
|
|
+ disabled?: boolean
|
|
|
|
|
+
|
|
|
|
|
+ watermarkText?: string
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const props = withDefaults(defineProps<Props>(), {
|
|
|
|
|
+ disabled: false,
|
|
|
|
|
+ watermarkText: '',
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const value = defineModel<FieldValue>('value', {
|
|
|
|
|
+ default: null,
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const label = defineModel<FieldValue>('label', {
|
|
|
|
|
+ default: null,
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const userStore = useUserStore()
|
|
|
|
|
+const instance = getCurrentInstance()
|
|
|
|
|
+
|
|
|
|
|
+const uploading = ref(false)
|
|
|
|
|
+const watermarkAddress = ref('')
|
|
|
|
|
+const watermarkCanvasWidth = ref(1)
|
|
|
|
|
+const watermarkCanvasHeight = ref(1)
|
|
|
|
|
+
|
|
|
|
|
+const baseUrl = String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')
|
|
|
|
|
+
|
|
|
|
|
+const isDisabled = computed(() => props.disabled)
|
|
|
|
|
+
|
|
|
|
|
+const watermarkCanvasStyle = computed(() => {
|
|
|
|
|
+ return {
|
|
|
|
|
+ width: `${watermarkCanvasWidth.value}px`,
|
|
|
|
|
+ height: `${watermarkCanvasHeight.value}px`,
|
|
|
|
|
+ position: 'fixed',
|
|
|
|
|
+ left: '-9999px',
|
|
|
|
|
+ top: '-9999px',
|
|
|
|
|
+ pointerEvents: 'none',
|
|
|
|
|
+ }
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const minCount = computed(() => {
|
|
|
|
|
+ return toSafeNumber(props.taskFieldConfig.taskFiledMinsize, 0)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const maxCount = computed(() => {
|
|
|
|
|
+ const config = props.taskFieldConfig as TaskFieldConfigItem & {
|
|
|
|
|
+ taskFiledMaxsize?: string | number | null
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return toSafeNumber(config.taskFiledMaxsize, 20)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const description = computed(() => {
|
|
|
|
|
+ return minCount.value > 0 ? `(至少上传${minCount.value}张图片)` : ''
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const modelText = computed(() => {
|
|
|
|
|
+ return normalizeImageValue(value.value) ?? normalizeImageValue(label.value)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const imageList = computed<string[]>({
|
|
|
|
|
+ get: () => {
|
|
|
|
|
+ return parseImageValue(modelText.value)
|
|
|
|
|
+ },
|
|
|
|
|
+ set: (urls) => {
|
|
|
|
|
+ const nextValue = stringifyImageList(urls)
|
|
|
|
|
+
|
|
|
|
|
+ value.value = nextValue
|
|
|
|
|
+ label.value = nextValue
|
|
|
|
|
+ },
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const remainingCount = computed(() => {
|
|
|
|
|
+ return Math.max(maxCount.value - imageList.value.length, 0)
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const canUpload = computed(() => {
|
|
|
|
|
+ return !isDisabled.value && imageList.value.length < maxCount.value
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+onMounted(() => {
|
|
|
|
|
+ initWatermarkAddress()
|
|
|
|
|
+})
|
|
|
|
|
+
|
|
|
|
|
+const initWatermarkAddress = async () => {
|
|
|
|
|
+ if (props.watermarkText) return
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const result = (await getLocation()) as LocationResult
|
|
|
|
|
+ watermarkAddress.value = result.result?.address ?? ''
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ console.warn('[ImgUpload] get location failed:', error)
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const chooseImage = async () => {
|
|
|
|
|
+ if (isDisabled.value || uploading.value) return
|
|
|
|
|
+
|
|
|
|
|
+ if (remainingCount.value <= 0) {
|
|
|
|
|
+ uni.showToast({
|
|
|
|
|
+ title: `最多上传${maxCount.value}张图片`,
|
|
|
|
|
+ icon: 'none',
|
|
|
|
|
+ })
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const source = await chooseUploadSource()
|
|
|
|
|
+ const count = source === 'camera' ? 1 : Math.min(remainingCount.value, 9)
|
|
|
|
|
+ const filePaths = await chooseImages(count, source)
|
|
|
|
|
+
|
|
|
|
|
+ if (filePaths.length === 0) return
|
|
|
|
|
+
|
|
|
|
|
+ uploading.value = true
|
|
|
|
|
+
|
|
|
|
|
+ uni.showLoading({
|
|
|
|
|
+ title: '上传中',
|
|
|
|
|
+ mask: true,
|
|
|
|
|
+ })
|
|
|
|
|
+
|
|
|
|
|
+ const uploadedUrls: string[] = []
|
|
|
|
|
+
|
|
|
|
|
+ for (const filePath of filePaths) {
|
|
|
|
|
+ const uploadFilePath = source === 'camera' ? await addWatermark(filePath) : filePath
|
|
|
|
|
+ const uploadedUrl = await upload(uploadFilePath)
|
|
|
|
|
+ uploadedUrls.push(appendUploadSourceSuffix(uploadedUrl, source))
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ imageList.value = [...imageList.value, ...uploadedUrls].slice(0, maxCount.value)
|
|
|
|
|
+
|
|
|
|
|
+ uni.showToast({
|
|
|
|
|
+ title: '上传成功',
|
|
|
|
|
+ icon: 'success',
|
|
|
|
|
+ })
|
|
|
|
|
+ } catch (error) {
|
|
|
|
|
+ if (isCancelError(error)) return
|
|
|
|
|
+
|
|
|
|
|
+ console.error('[ImgUpload] upload failed:', error)
|
|
|
|
|
+
|
|
|
|
|
+ uni.showToast({
|
|
|
|
|
+ title: '上传失败',
|
|
|
|
|
+ icon: 'none',
|
|
|
|
|
+ })
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ uploading.value = false
|
|
|
|
|
+ uni.hideLoading()
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const chooseUploadSource = (): Promise<UploadSource> => {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ uni.showActionSheet({
|
|
|
|
|
+ itemList: ['拍摄', '从相册选择'],
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ resolve(res.tapIndex === 0 ? 'camera' : 'album')
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (error) => {
|
|
|
|
|
+ if (isCancelError(error)) {
|
|
|
|
|
+ reject(new Error('cancel'))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ reject(error)
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const chooseImages = (count: number, source: UploadSource): Promise<string[]> => {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ uni.chooseImage({
|
|
|
|
|
+ count,
|
|
|
|
|
+ sizeType: ['compressed'],
|
|
|
|
|
+ sourceType: [source],
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ const filePaths = Array.isArray(res.tempFilePaths)
|
|
|
|
|
+ ? res.tempFilePaths
|
|
|
|
|
+ : [res.tempFilePaths].filter(Boolean)
|
|
|
|
|
+
|
|
|
|
|
+ resolve(filePaths)
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: (error) => {
|
|
|
|
|
+ if (isCancelError(error)) {
|
|
|
|
|
+ reject(new Error('cancel'))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ reject(error)
|
|
|
|
|
+ },
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const upload = (filePath: string): Promise<string> => {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ uni.uploadFile({
|
|
|
|
|
+ url: `${baseUrl}/admin/api/file/upload/mobile`,
|
|
|
|
|
+ filePath,
|
|
|
|
|
+ name: 'file',
|
|
|
|
|
+ header: {
|
|
|
|
|
+ Authorization: `Bearer ${userStore.access_token}`,
|
|
|
|
|
+ },
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const parsed = JSON.parse(res.data) as UploadResponse
|
|
|
|
|
+
|
|
|
|
|
+ if (
|
|
|
|
|
+ res.statusCode < 200 ||
|
|
|
|
|
+ res.statusCode >= 300 ||
|
|
|
|
|
+ !parsed.success ||
|
|
|
|
|
+ parsed.code !== 0 ||
|
|
|
|
|
+ !parsed.data?.url
|
|
|
|
|
+ ) {
|
|
|
|
|
+ reject(new Error(parsed.msg || 'upload failed'))
|
|
|
|
|
+ return
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ resolve(parsed.data.url)
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ reject(new Error('parse upload response failed'))
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: reject,
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const addWatermark = (filePath: string): Promise<string> => {
|
|
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
|
|
+ uni.getImageInfo({
|
|
|
|
|
+ src: filePath,
|
|
|
|
|
+ success: (imageInfo) => {
|
|
|
|
|
+ const width = imageInfo.width
|
|
|
|
|
+ const height = imageInfo.height
|
|
|
|
|
+
|
|
|
|
|
+ watermarkCanvasWidth.value = width
|
|
|
|
|
+ watermarkCanvasHeight.value = height
|
|
|
|
|
+
|
|
|
|
|
+ const componentInstance = instance?.proxy
|
|
|
|
|
+ const ctx = uni.createCanvasContext(
|
|
|
|
|
+ 'imgUploadWatermarkCanvas',
|
|
|
|
|
+ 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(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: 'imgUploadWatermarkCanvas',
|
|
|
|
|
+ fileType: 'jpg',
|
|
|
|
|
+ success: (res) => {
|
|
|
|
|
+ resolve(res.tempFilePath)
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: reject,
|
|
|
|
|
+ },
|
|
|
|
|
+ componentInstance
|
|
|
|
|
+ )
|
|
|
|
|
+ }, 300)
|
|
|
|
|
+ })
|
|
|
|
|
+ },
|
|
|
|
|
+ fail: reject,
|
|
|
|
|
+ })
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const previewImage = (index: number) => {
|
|
|
|
|
+ const urls = imageList.value.map(toFullUrl)
|
|
|
|
|
+
|
|
|
|
|
+ if (urls.length === 0) return
|
|
|
|
|
+
|
|
|
|
|
+ uni.previewImage({
|
|
|
|
|
+ urls,
|
|
|
|
|
+ current: urls[index],
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const removeImage = (index: number) => {
|
|
|
|
|
+ if (isDisabled.value || uploading.value) return
|
|
|
|
|
+
|
|
|
|
|
+ imageList.value = imageList.value.filter((_, currentIndex) => {
|
|
|
|
|
+ return currentIndex !== index
|
|
|
|
|
+ })
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const parseImageValue = (input: unknown): string[] => {
|
|
|
|
|
+ if (Array.isArray(input)) {
|
|
|
|
|
+ return input.map((item) => String(item).trim()).filter(Boolean)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (typeof input !== 'string') return []
|
|
|
|
|
+
|
|
|
|
|
+ return input
|
|
|
|
|
+ .split(',')
|
|
|
|
|
+ .map((item) => item.trim())
|
|
|
|
|
+ .filter(Boolean)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const stringifyImageList = (urls: string[]): FieldValue => {
|
|
|
|
|
+ const normalizedUrls = urls.map((url) => String(url).trim()).filter(Boolean)
|
|
|
|
|
+
|
|
|
|
|
+ return normalizedUrls.length > 0 ? normalizedUrls.join(',') : null
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const normalizeImageValue = (input: unknown): FieldValue => {
|
|
|
|
|
+ return stringifyImageList(parseImageValue(input))
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const appendUploadSourceSuffix = (url: string, source: UploadSource) => {
|
|
|
|
|
+ const normalizedUrl = String(url).trim()
|
|
|
|
|
+
|
|
|
|
|
+ if (!normalizedUrl) return ''
|
|
|
|
|
+
|
|
|
|
|
+ if (hasUploadSourceSuffix(normalizedUrl)) {
|
|
|
|
|
+ return normalizedUrl
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return `${normalizedUrl};${source === 'camera' ? '1' : '2'}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const hasUploadSourceSuffix = (url: string) => {
|
|
|
|
|
+ return /;(1|2|3)$/.test(url)
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const removeUploadSourceSuffix = (url: string) => {
|
|
|
|
|
+ return url.replace(/;(1|2|3)$/, '')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const toFullUrl = (url: string) => {
|
|
|
|
|
+ const cleanUrl = removeUploadSourceSuffix(url).trim()
|
|
|
|
|
+
|
|
|
|
|
+ if (!cleanUrl) return ''
|
|
|
|
|
+
|
|
|
|
|
+ if (/^(https?:|wxfile:|blob:|data:)/.test(cleanUrl)) {
|
|
|
|
|
+ return cleanUrl
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return `${baseUrl}${cleanUrl.startsWith('/') ? cleanUrl : `/${cleanUrl}`}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const getWatermarkText = () => {
|
|
|
|
|
+ return props.watermarkText || watermarkAddress.value || ''
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const wrapText = (
|
|
|
|
|
+ ctx: UniApp.CanvasContext & { measureText: (text: string) => { width: number } },
|
|
|
|
|
+ text: string,
|
|
|
|
|
+ maxWidth: number
|
|
|
|
|
+) => {
|
|
|
|
|
+ const rows: string[] = []
|
|
|
|
|
+ let currentText = ''
|
|
|
|
|
+
|
|
|
|
|
+ for (const char of [...text]) {
|
|
|
|
|
+ const nextText = `${currentText}${char}`
|
|
|
|
|
+
|
|
|
|
|
+ if (ctx.measureText(nextText).width > maxWidth && currentText) {
|
|
|
|
|
+ rows.push(currentText)
|
|
|
|
|
+ currentText = char
|
|
|
|
|
+ continue
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ currentText = nextText
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (currentText) {
|
|
|
|
|
+ rows.push(currentText)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return rows
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const drawRoundRect = (
|
|
|
|
|
+ ctx: UniApp.CanvasContext,
|
|
|
|
|
+ x: number,
|
|
|
|
|
+ y: number,
|
|
|
|
|
+ width: number,
|
|
|
|
|
+ height: number,
|
|
|
|
|
+ radius: number
|
|
|
|
|
+) => {
|
|
|
|
|
+ ctx.setFillStyle('rgba(0, 0, 0, 0.43)')
|
|
|
|
|
+ ctx.beginPath()
|
|
|
|
|
+ ctx.moveTo(x + radius, y)
|
|
|
|
|
+ ctx.lineTo(x + width - radius, y)
|
|
|
|
|
+ ctx.arc(x + width - radius, y + radius, radius, -Math.PI / 2, 0)
|
|
|
|
|
+ ctx.lineTo(x + width, y + height - radius)
|
|
|
|
|
+ ctx.arc(x + width - radius, y + height - radius, radius, 0, Math.PI / 2)
|
|
|
|
|
+ ctx.lineTo(x + radius, y + height)
|
|
|
|
|
+ ctx.arc(x + radius, y + height - radius, radius, Math.PI / 2, Math.PI)
|
|
|
|
|
+ ctx.lineTo(x, y + radius)
|
|
|
|
|
+ ctx.arc(x + radius, y + radius, radius, Math.PI, -Math.PI / 2)
|
|
|
|
|
+ ctx.closePath()
|
|
|
|
|
+ ctx.fill()
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const formatDate = (date: Date) => {
|
|
|
|
|
+ const year = date.getFullYear()
|
|
|
|
|
+ const month = padNumber(date.getMonth() + 1)
|
|
|
|
|
+ const day = padNumber(date.getDate())
|
|
|
|
|
+ const hour = padNumber(date.getHours())
|
|
|
|
|
+ const minute = padNumber(date.getMinutes())
|
|
|
|
|
+ const second = padNumber(date.getSeconds())
|
|
|
|
|
+
|
|
|
|
|
+ return `${year}/${month}/${day} ${hour}:${minute}:${second}`
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const padNumber = (value: number) => {
|
|
|
|
|
+ return String(value).padStart(2, '0')
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const toSafeNumber = (value: unknown, fallback: number) => {
|
|
|
|
|
+ const numberValue = Number(value)
|
|
|
|
|
+
|
|
|
|
|
+ if (!Number.isFinite(numberValue) || numberValue < 0) {
|
|
|
|
|
+ return fallback
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return numberValue
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const isCancelError = (error: unknown) => {
|
|
|
|
|
+ if (error instanceof Error && error.message === 'cancel') return true
|
|
|
|
|
+
|
|
|
|
|
+ const errMsg =
|
|
|
|
|
+ typeof (error as { errMsg?: unknown })?.errMsg === 'string'
|
|
|
|
|
+ ? String((error as { errMsg?: string }).errMsg)
|
|
|
|
|
+ : ''
|
|
|
|
|
+
|
|
|
|
|
+ return errMsg.includes('cancel')
|
|
|
|
|
+}
|
|
|
|
|
+</script>
|
|
|
|
|
+
|
|
|
|
|
+<style lang="scss" scoped>
|
|
|
|
|
+.img-upload {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ background: #fff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-container {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-wrap: wrap;
|
|
|
|
|
+ gap: 16rpx;
|
|
|
|
|
+ padding: 20rpx 0 18rpx;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-container.is-disabled {
|
|
|
|
|
+ opacity: 0.65;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.image-box,
|
|
|
|
|
+.upload-box {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ width: 164rpx;
|
|
|
|
|
+ height: 164rpx;
|
|
|
|
|
+ border-radius: 12rpx;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.image-box {
|
|
|
|
|
+ background: #f5f7fa;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-image {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-box {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ border: 2rpx dashed #d2d8e3;
|
|
|
|
|
+ background: #f8faff;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-box.is-uploading {
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ opacity: 0.7;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-plus {
|
|
|
|
|
+ font-size: 58rpx;
|
|
|
|
|
+ line-height: 58rpx;
|
|
|
|
|
+ font-weight: 300;
|
|
|
|
|
+ color: #60656f;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-text {
|
|
|
|
|
+ margin-top: 10rpx;
|
|
|
|
|
+ font-size: 28rpx;
|
|
|
|
|
+ line-height: 36rpx;
|
|
|
|
|
+ color: #60656f;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.upload-count {
|
|
|
|
|
+ margin-top: 2rpx;
|
|
|
|
|
+ font-size: 26rpx;
|
|
|
|
|
+ line-height: 32rpx;
|
|
|
|
|
+ color: #9aa1ad;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+.delete-btn {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 8rpx;
|
|
|
|
|
+ right: 8rpx;
|
|
|
|
|
+ z-index: 2;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ width: 36rpx;
|
|
|
|
|
+ height: 36rpx;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ background: rgba(0, 0, 0, 0.55);
|
|
|
|
|
+}
|
|
|
|
|
+</style>
|