<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 getAdaptiveWatermarkStyle = (width, height) => {
|
const w = Math.max(1, Number(width) || 1);
|
const h = Math.max(1, Number(height) || 1);
|
const shortSide = Math.min(w, h);
|
const longSide = Math.max(w, h);
|
// 分段适配:小图减小字号,大图保持当前视觉效果
|
const isSmallImage = shortSide <= 720;
|
const fontSize = isSmallImage
|
? Math.max(
|
10,
|
Math.min(28, Math.floor(shortSide * 0.016 + longSide * 0.004))
|
)
|
: Math.max(
|
14,
|
Math.min(56, Math.floor(shortSide * 0.022 + longSide * 0.006))
|
);
|
return {
|
fontSize,
|
padding: Math.max(isSmallImage ? 4 : 5, Math.floor(fontSize * 0.4)),
|
lineGap: Math.max(isSmallImage ? 1 : 2, Math.floor(fontSize * 0.16)),
|
edgeGap: 0,
|
};
|
};
|
|
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, padding, lineGap, edgeGap } =
|
getAdaptiveWatermarkStyle(w, h);
|
|
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, padding, lineGap, edgeGap } =
|
getAdaptiveWatermarkStyle(w, h);
|
|
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>
|