| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <template> |
| | | <view class="album-image-upload"> |
| | | <view class="actions" v-if="!readonly"> |
| | | <u-button |
| | | type="primary" |
| | | size="small" |
| | | :loading="uploading" |
| | | :disabled="uploading || innerList.length >= limit" |
| | | @click="chooseFromAlbum" |
| | | > |
| | | {{ buttonText }} |
| | | </u-button> |
| | | <view class="tip" v-if="tipText">{{ tipText }}</view> |
| | | </view> |
| | | |
| | | <view v-if="innerList.length" class="list"> |
| | | <view |
| | | v-for="(img, idx) in innerList" |
| | | :key="img.id || img.url || idx" |
| | | class="item" |
| | | > |
| | | <image |
| | | class="img" |
| | | :src="img.previewUrl || img.url" |
| | | mode="aspectFill" |
| | | @click="preview(idx)" |
| | | /> |
| | | <view v-if="!readonly" class="del" @click.stop="remove(idx)">Ã</view> |
| | | </view> |
| | | </view> |
| | | |
| | | <!-- éèç»å¸ï¼ç¨äºå¾çå æ°´å° --> |
| | | <canvas |
| | | v-if="enableWatermark" |
| | | :canvas-id="canvasId" |
| | | :id="canvasId" |
| | | :style="{ |
| | | position: 'absolute', |
| | | left: '-9999px', |
| | | top: '-9999px', |
| | | width: canvasWidth + 'px', |
| | | height: canvasHeight + 'px' |
| | | }" |
| | | :width="canvasWidth" |
| | | :height="canvasHeight" |
| | | /> |
| | | </view> |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { computed, getCurrentInstance, ref, watch } from "vue"; |
| | | import config from "@/config.js"; |
| | | import { getToken } from "@/utils/auth"; |
| | | import { normalizeFileUrl } from "@/utils/filePreview"; |
| | | |
| | | const props = defineProps({ |
| | | modelValue: { type: Array, default: () => [] }, |
| | | // æå¤§å¼ æ° |
| | | limit: { type: Number, default: 6 }, |
| | | // ä¸ä¼ æ¥å£ pathï¼ä¼æ¼æ¥ config.baseUrlï¼ |
| | | action: { type: String, default: "/invoiceLedger/upload" }, |
| | | // ä¸ä¼ åæ®µå |
| | | name: { type: String, default: "file" }, |
| | | // ä»
ç¸åï¼åºå®ï¼ï¼æ´é²åºæ¥æ¹ä¾¿æªæ¥æ©å± |
| | | sourceType: { type: Array, default: () => ["album"] }, |
| | | // éæ©å缩/åå¾ |
| | | sizeType: { type: Array, default: () => ["compressed", "original"] }, |
| | | // ææ¡ |
| | | buttonText: { type: String, default: "ä»ç¸åéæ©" }, |
| | | tipText: { type: String, default: "ä»
æ¯æä»ç¸åéæ©" }, |
| | | // åªè¯» |
| | | readonly: { type: Boolean, default: false }, |
| | | |
| | | // æ°´å° |
| | | enableWatermark: { type: Boolean, default: true }, |
| | | watermarkText: { type: [String, Function], default: "" }, |
| | | |
| | | // ä¸ä¼ åä¸å¡æ ¡éª/æ¦æªï¼è¿å false ç´æ¥æ¦æª |
| | | beforeUpload: { type: Function, default: null }, |
| | | }); |
| | | |
| | | const emit = defineEmits(["update:modelValue", "change", "error"]); |
| | | |
| | | const instance = getCurrentInstance(); |
| | | const uploading = ref(false); |
| | | const innerList = ref([]); |
| | | |
| | | watch( |
| | | () => props.modelValue, |
| | | val => { |
| | | innerList.value = Array.isArray(val) ? val.filter(v => v?.url) : []; |
| | | }, |
| | | { immediate: true, deep: true } |
| | | ); |
| | | |
| | | const canvasId = `album_wm_canvas_${Date.now()}_${Math.random() |
| | | .toString(16) |
| | | .slice(2)}`; |
| | | const canvasWidth = ref(1); |
| | | const canvasHeight = ref(1); |
| | | |
| | | const uploadUrl = computed(() => { |
| | | const base = config.baseUrl || ""; |
| | | return base + props.action; |
| | | }); |
| | | |
| | | const resolveWatermarkText = () => { |
| | | try { |
| | | return typeof props.watermarkText === "function" |
| | | ? props.watermarkText() |
| | | : props.watermarkText; |
| | | } catch (e) { |
| | | return ""; |
| | | } |
| | | }; |
| | | |
| | | const normalizeUploadResultToFile = data => { |
| | | if (!data) return null; |
| | | if (Array.isArray(data)) return normalizeUploadResultToFile(data[0]); |
| | | if (typeof data === "string") { |
| | | const url = normalizeFileUrl(data); |
| | | return url ? { url, downloadUrl: url } : null; |
| | | } |
| | | if (typeof data === "object") { |
| | | const url = normalizeFileUrl( |
| | | data.url || data.downloadUrl || data.tempPath || data.path |
| | | ); |
| | | if (url) return { ...data, url, downloadUrl: url }; |
| | | return null; |
| | | } |
| | | return null; |
| | | }; |
| | | |
| | | const addWatermarkByBrowserCanvas = (tempFilePath, text) => { |
| | | return new Promise((resolve, reject) => { |
| | | try { |
| | | const wmText = String(text || "").trim(); |
| | | if (!wmText) return resolve(tempFilePath); |
| | | |
| | | const img = new Image(); |
| | | img.onload = () => { |
| | | try { |
| | | const w = img.naturalWidth || img.width || 1; |
| | | const h = img.naturalHeight || img.height || 1; |
| | | const canvas = document.createElement("canvas"); |
| | | canvas.width = w; |
| | | canvas.height = h; |
| | | const ctx = canvas.getContext("2d"); |
| | | if (!ctx) return resolve(tempFilePath); |
| | | |
| | | ctx.drawImage(img, 0, 0, w, h); |
| | | |
| | | const lines = wmText.split(/\r?\n/).filter(Boolean); |
| | | const fontSize = Math.max(8, Math.floor(Math.min(w, h) * 0.014)); |
| | | const padding = Math.max(3, Math.floor(fontSize * 0.35)); |
| | | const lineGap = Math.max(1, Math.floor(fontSize * 0.1)); |
| | | const edgeGap = 0; |
| | | |
| | | ctx.font = `${fontSize}px sans-serif`; |
| | | const maxChars = Math.max(...lines.map(t => (t || "").length), 0); |
| | | const approxTextWidth = Math.min( |
| | | w * 0.55, |
| | | Math.max(fontSize * maxChars * 0.5, fontSize * 4) |
| | | ); |
| | | const blockW = approxTextWidth + padding * 2; |
| | | const blockH = |
| | | lines.length * fontSize + (lines.length - 1) * lineGap + padding * 2; |
| | | const blockX = Math.max(0, w - blockW - edgeGap); |
| | | const blockY = Math.max(0, h - blockH - edgeGap); |
| | | |
| | | ctx.fillStyle = "rgba(0,0,0,0.2)"; |
| | | ctx.fillRect(blockX, blockY, blockW, blockH); |
| | | ctx.fillStyle = "rgba(255,255,255,0.95)"; |
| | | lines.forEach((t, idx) => { |
| | | const textWidth = ctx.measureText(t).width; |
| | | const x = Math.max(blockX + padding, blockX + blockW - padding - textWidth); |
| | | const y = blockY + padding + fontSize + idx * (fontSize + lineGap); |
| | | ctx.fillText(t, x, y); |
| | | }); |
| | | |
| | | canvas.toBlob( |
| | | blob => { |
| | | if (!blob) return resolve(tempFilePath); |
| | | resolve(URL.createObjectURL(blob)); |
| | | }, |
| | | "image/jpeg", |
| | | 0.92 |
| | | ); |
| | | } catch (e) { |
| | | resolve(tempFilePath); |
| | | } |
| | | }; |
| | | img.onerror = () => resolve(tempFilePath); |
| | | img.src = tempFilePath; |
| | | } catch (e) { |
| | | reject(e); |
| | | } |
| | | }); |
| | | }; |
| | | |
| | | const addWatermarkToImage = (tempFilePath, text) => { |
| | | return new Promise((resolve, reject) => { |
| | | if (!tempFilePath) return reject(new Error("å¾çè·¯å¾ä¸åå¨")); |
| | | const wmText = String(text || "").trim(); |
| | | if (!props.enableWatermark || !wmText) return resolve(tempFilePath); |
| | | // H5 端ä¼å
ä½¿ç¨æµè§å¨åç canvasï¼é¿å
uni canvas çæç©ºç½å¾ |
| | | if (typeof window !== "undefined" && typeof document !== "undefined") { |
| | | addWatermarkByBrowserCanvas(tempFilePath, wmText) |
| | | .then(resolve) |
| | | .catch(() => resolve(tempFilePath)); |
| | | return; |
| | | } |
| | | |
| | | uni.getImageInfo({ |
| | | src: tempFilePath, |
| | | success: info => { |
| | | try { |
| | | const w = info.width || 1; |
| | | const h = info.height || 1; |
| | | canvasWidth.value = w; |
| | | canvasHeight.value = h; |
| | | |
| | | const ctx = uni.createCanvasContext(canvasId, instance); |
| | | ctx.drawImage(tempFilePath, 0, 0, w, h); |
| | | |
| | | const lines = wmText.split(/\r?\n/).filter(Boolean); |
| | | const fontSize = Math.max(8, Math.floor(Math.min(w, h) * 0.014)); |
| | | const padding = Math.max(3, Math.floor(fontSize * 0.35)); |
| | | const lineGap = Math.max(1, Math.floor(fontSize * 0.1)); |
| | | const edgeGap = 0; |
| | | |
| | | ctx.setFontSize(fontSize); |
| | | ctx.setFillStyle("rgba(0,0,0,0.2)"); |
| | | |
| | | const maxChars = Math.max(...lines.map(t => (t || "").length), 0); |
| | | const approxTextWidth = Math.min( |
| | | w * 0.55, |
| | | Math.max(fontSize * maxChars * 0.5, fontSize * 4) |
| | | ); |
| | | const blockW = approxTextWidth + padding * 2; |
| | | const blockH = |
| | | lines.length * fontSize + (lines.length - 1) * lineGap + padding * 2; |
| | | const blockX = Math.max(0, w - blockW - edgeGap); |
| | | const blockY = Math.max(0, h - blockH - edgeGap); |
| | | |
| | | ctx.fillRect(blockX, blockY, blockW, blockH); |
| | | ctx.setFillStyle("rgba(255,255,255,0.95)"); |
| | | lines.forEach((t, idx) => { |
| | | const textWidth = fontSize * String(t || "").length * 0.55; |
| | | const x = Math.max(blockX + padding, blockX + blockW - padding - textWidth); |
| | | const y = blockY + padding + fontSize + idx * (fontSize + lineGap); |
| | | ctx.fillText(t, x, y); |
| | | }); |
| | | |
| | | ctx.draw(false, () => { |
| | | uni.canvasToTempFilePath( |
| | | { |
| | | canvasId, |
| | | width: w, |
| | | height: h, |
| | | destWidth: w, |
| | | destHeight: h, |
| | | fileType: "jpg", |
| | | quality: 0.92, |
| | | success: r => resolve(r.tempFilePath), |
| | | fail: err => reject(err), |
| | | }, |
| | | instance |
| | | ); |
| | | }); |
| | | } catch (e) { |
| | | reject(e); |
| | | } |
| | | }, |
| | | fail: err => reject(err), |
| | | }); |
| | | }); |
| | | }; |
| | | |
| | | const uploadOneByH5FormData = (filePath, extraFormData = {}) => { |
| | | return new Promise(async (resolve, reject) => { |
| | | try { |
| | | const resp = await fetch(filePath); |
| | | const blob = await resp.blob(); |
| | | const formData = new FormData(); |
| | | const explicitName = String( |
| | | extraFormData?.fileName || extraFormData?.originalName || "" |
| | | ).trim(); |
| | | const pathExt = getFileExtFromPath(filePath); |
| | | const mimeExt = String(blob?.type || "") |
| | | .toLowerCase() |
| | | .split("/") |
| | | .pop(); |
| | | const uploadFileName = |
| | | explicitName || `file_${Date.now()}.${pathExt || mimeExt || "jpg"}`; |
| | | formData.append(props.name, blob, uploadFileName); |
| | | |
| | | Object.keys(extraFormData || {}).forEach(key => { |
| | | if (key === "fileName" || key === "originalName") return; |
| | | const val = extraFormData[key]; |
| | | if (val === undefined || val === null) return; |
| | | formData.append(key, String(val)); |
| | | }); |
| | | |
| | | const xhr = new XMLHttpRequest(); |
| | | xhr.open("POST", uploadUrl.value, true); |
| | | xhr.setRequestHeader("Authorization", "Bearer " + getToken()); |
| | | xhr.onload = () => { |
| | | try { |
| | | const body = JSON.parse(xhr.responseText || "{}"); |
| | | if (body.code === 200) { |
| | | const file = normalizeUploadResultToFile(body.data); |
| | | if (!file) return reject(new Error("ä¸ä¼ æå使ªè¿åæä»¶ä¿¡æ¯")); |
| | | resolve(file); |
| | | } else { |
| | | reject(new Error(body.msg || "ä¸ä¼ 失败")); |
| | | } |
| | | } catch (e) { |
| | | reject(e); |
| | | } |
| | | }; |
| | | xhr.onerror = () => reject(new Error("ä¸ä¼ 失败")); |
| | | xhr.send(formData); |
| | | } catch (e) { |
| | | reject(e); |
| | | } |
| | | }); |
| | | }; |
| | | |
| | | const uploadOne = (filePath, extraFormData = {}) => { |
| | | const isH5BlobPath = |
| | | typeof window !== "undefined" && |
| | | typeof FormData !== "undefined" && |
| | | String(filePath || "").startsWith("blob:"); |
| | | if (isH5BlobPath) { |
| | | return uploadOneByH5FormData(filePath, extraFormData); |
| | | } |
| | | return new Promise((resolve, reject) => { |
| | | uni.uploadFile({ |
| | | url: uploadUrl.value, |
| | | filePath, |
| | | name: props.name, |
| | | formData: extraFormData, |
| | | header: { Authorization: "Bearer " + getToken() }, |
| | | success: res => { |
| | | try { |
| | | const body = JSON.parse(res.data || "{}"); |
| | | if (body.code === 200) { |
| | | const file = normalizeUploadResultToFile(body.data); |
| | | if (!file) return reject(new Error("ä¸ä¼ æå使ªè¿åæä»¶ä¿¡æ¯")); |
| | | resolve(file); |
| | | } else { |
| | | reject(new Error(body.msg || "ä¸ä¼ 失败")); |
| | | } |
| | | } catch (e) { |
| | | reject(e); |
| | | } |
| | | }, |
| | | fail: err => reject(err), |
| | | }); |
| | | }); |
| | | }; |
| | | |
| | | const getFileExtFromPath = filePath => { |
| | | const raw = String(filePath || "").split("?")[0].split("#")[0]; |
| | | const match = raw.match(/\.([a-zA-Z0-9]+)$/); |
| | | return (match?.[1] || "").toLowerCase(); |
| | | }; |
| | | |
| | | const getFileNameFromPath = filePath => { |
| | | const raw = String(filePath || "").split("?")[0].split("#")[0]; |
| | | const seg = raw.split("/").pop() || ""; |
| | | return seg.split("\\").pop() || ""; |
| | | }; |
| | | |
| | | const buildUploadMeta = (originalPath, rawOriginalName = "") => { |
| | | const cleanOriginalName = String(rawOriginalName || "").trim(); |
| | | const pathName = getFileNameFromPath(originalPath); |
| | | const fallbackName = cleanOriginalName || pathName; |
| | | const ext = |
| | | getFileExtFromPath(fallbackName) || getFileExtFromPath(originalPath) || "jpg"; |
| | | const fileName = fallbackName || `${Date.now()}.${ext}`; |
| | | return { |
| | | ext, |
| | | fileName, |
| | | formData: { fileName }, |
| | | }; |
| | | }; |
| | | |
| | | const resolvePreviewPath = (wmPath, originPath) => { |
| | | return new Promise(resolve => { |
| | | if (!wmPath) return resolve(originPath || ""); |
| | | uni.getImageInfo({ |
| | | src: wmPath, |
| | | success: () => resolve(wmPath), |
| | | fail: () => resolve(originPath || wmPath || ""), |
| | | }); |
| | | }); |
| | | }; |
| | | |
| | | const emitList = list => { |
| | | emit("update:modelValue", list); |
| | | emit("change", list); |
| | | }; |
| | | |
| | | const chooseFromAlbum = () => { |
| | | if (props.readonly) return; |
| | | if (typeof props.beforeUpload === "function") { |
| | | const ok = props.beforeUpload(); |
| | | if (ok === false) return; |
| | | } |
| | | |
| | | const remaining = Math.max(0, props.limit - innerList.value.length); |
| | | if (remaining <= 0) { |
| | | uni.showToast({ title: `æå¤åªè½ä¸ä¼ ${props.limit}å¼ `, icon: "none" }); |
| | | return; |
| | | } |
| | | |
| | | uni.chooseImage({ |
| | | count: remaining, |
| | | sizeType: props.sizeType, |
| | | sourceType: props.sourceType, |
| | | success: async res => { |
| | | const paths = res?.tempFilePaths || []; |
| | | const tempFiles = Array.isArray(res?.tempFiles) ? res.tempFiles : []; |
| | | if (!paths.length) return; |
| | | |
| | | uploading.value = true; |
| | | uni.showLoading({ title: "æ£å¨ä¸ä¼ ...", mask: true }); |
| | | try { |
| | | const wmText = resolveWatermarkText(); |
| | | for (let idx = 0; idx < paths.length; idx++) { |
| | | const p = paths[idx]; |
| | | const picked = tempFiles[idx] || {}; |
| | | const originalName = picked.name || getFileNameFromPath(p); |
| | | const wmPath = await addWatermarkToImage(p, wmText); |
| | | const uploadMeta = buildUploadMeta(p, originalName); |
| | | const uploaded = await uploadOne(wmPath, uploadMeta.formData); |
| | | // ä¿çå端è¿åå°åç¨äºåç»åæ¾/æäº¤ï¼ç«å³é¢è§ä¼å
å±ç¤ºå æ°´å°åçæ¬å°å¾ |
| | | uploaded.previewUrl = await resolvePreviewPath(wmPath, p); |
| | | uploaded.originalName = uploaded.originalName || uploadMeta.fileName; |
| | | uploaded.suffix = uploaded.suffix || uploadMeta.ext; |
| | | innerList.value.push(uploaded); |
| | | emitList(innerList.value.slice()); |
| | | } |
| | | uni.showToast({ title: "ä¸ä¼ æå", icon: "none" }); |
| | | } catch (e) { |
| | | emit("error", e); |
| | | uni.showToast({ title: e?.message || "ä¸ä¼ 失败", icon: "none" }); |
| | | } finally { |
| | | uploading.value = false; |
| | | uni.hideLoading(); |
| | | } |
| | | }, |
| | | }); |
| | | }; |
| | | |
| | | const remove = idx => { |
| | | const list = innerList.value.slice(); |
| | | list.splice(idx, 1); |
| | | innerList.value = list; |
| | | emitList(list); |
| | | }; |
| | | |
| | | const preview = idx => { |
| | | const urls = innerList.value |
| | | .map(i => i?.previewUrl || i?.url || i?.downloadUrl) |
| | | .filter(Boolean); |
| | | if (!urls.length) return; |
| | | uni.previewImage({ urls, current: urls[idx] || urls[0] }); |
| | | }; |
| | | </script> |
| | | |
| | | <style scoped lang="scss"> |
| | | .album-image-upload { |
| | | width: 100%; |
| | | } |
| | | |
| | | .actions { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | padding: 6px 0; |
| | | } |
| | | |
| | | .tip { |
| | | font-size: 12px; |
| | | color: #999; |
| | | } |
| | | |
| | | .list { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | padding: 8px 0 4px; |
| | | } |
| | | |
| | | .item { |
| | | position: relative; |
| | | width: 76px; |
| | | height: 76px; |
| | | border-radius: 8px; |
| | | overflow: hidden; |
| | | background: #f5f5f5; |
| | | border: 1px solid #eee; |
| | | } |
| | | |
| | | .img { |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | |
| | | .del { |
| | | position: absolute; |
| | | right: 4px; |
| | | top: 4px; |
| | | width: 18px; |
| | | height: 18px; |
| | | border-radius: 50%; |
| | | background: rgba(0, 0, 0, 0.55); |
| | | color: #fff; |
| | | font-size: 14px; |
| | | line-height: 18px; |
| | | text-align: center; |
| | | } |
| | | </style> |
| | | |