代码
javascript
<template>
<view class="camera-upload">
<!-- 用于绘制水印的canvas(隐藏) -->
<canvas
v-if="canvasShow"
canvas-id="watermarkCanvas"
:width="canvasW"
:height="canvasH"
:style="{ width: `${canvasW}px`, height: `${canvasH}px` }"
style="position: absolute; left: 0; top: 0; opacity: 0; pointer-events: none;"
></canvas>
<!-- 已上传图片列表 -->
<view class="image-list">
<view
v-for="(image, index) in fileList"
:key="index"
class="image-item"
>
<image :src="image.url" mode="aspectFill"></image>
<view class="delete-btn" @tap.stop="deleteImage(index)">
<up-icon name="close" color="white" size="7"></up-icon>
</view>
<view class="preview-mask" @tap="previewImage(index)"></view>
</view>
<!-- 上传按钮 -->
<view
v-if="fileList.length < maxCount && !disabledUpload"
class="upload-btn"
@tap="chooseImage"
>
<text class="upload-icon">+</text>
<text class="upload-text">拍照上传</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { defHttp } from '@/utils/http/defHttp'
interface FileItem {
type?: string
uid?: string
name?: string
status: 'success' | 'uploading'
thumb?: string
url: string
message: string
response: { message: string }
}
interface Props {
maxCount?: number
modelValue?: string | string[]
disabledUpload?: boolean
location?: Record<string, any>
compressQuality?: number // 压缩质量,0-1
maxWidth?: number // 最大宽度
maxHeight?: number // 最大高度
}
const props = withDefaults(defineProps<Props>(), {
maxCount: 5,
modelValue: '',
disabledUpload: false,
location: undefined,
compressQuality: 0.8,
maxWidth: 1920,
maxHeight: 1080
})
// 定义组件事件
const emit = defineEmits<{
'update:modelValue': [val: string]
}>()
// 图片列表
const fileList = ref<FileItem[]>([])
const getFileName = (path: string): string =>
path.replace(/\\/g, '/').split('/').pop() || ''
const initFileList = (paths: string | string[]) => {
const pathStr = Array.isArray(paths) ? paths.join(',') : (paths || '')
if (!pathStr) {
fileList.value = []
return
}
fileList.value = pathStr.split(',').map(p => {
const url = defHttp.getFileAccessHttpUrl(p)
return {
type: 'image',
uid: generateId(8),
name: getFileName(p),
status: 'success' as const,
thumb: url,
url,
message: '',
response: { message: p }
}
})
}
watch(() => props.modelValue, val => initFileList(val || ''), { immediate: true })
// 更新modelValue
const handlePathChange = () => {
const arr = fileList.value
.filter(f => f.status === 'success')
.map(f => f.response.message)
emit('update:modelValue', arr.join(','))
}
// 用于绘制水印的canvas是否显示
const canvasShow = ref(false)
let canvasW = ref(0); // 画布宽度
let canvasH = ref(0); // 画布高度
const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
// 添加水印的函数
// 添加水印
const addWatermark = async (imagePath: string): Promise<string> => {
try {
const info = await getImageInfoWithRetry(imagePath)
const sourceWidth = Number(info.width) || 0
const sourceHeight = Number(info.height) || 0
if (sourceWidth <= 0 || sourceHeight <= 0) {
return imagePath
}
const { outputWidth, outputHeight } = calculateOutputSize(
sourceWidth,
sourceHeight,
props.maxWidth,
props.maxHeight
)
if (outputWidth <= 0 || outputHeight <= 0) {
return imagePath
}
canvasShow.value = true
canvasW.value = outputWidth
canvasH.value = outputHeight
const quality = Math.min(1, Math.max(0, props.compressQuality ?? 0.7))
await nextTick()
await sleep(32)
const ctx = uni.createCanvasContext('watermarkCanvas', getCurrentInstance())
ctx.clearRect(0, 0, outputWidth, outputHeight)
const sourcePath =
typeof info.path === 'string' && info.path.length > 0 ? info.path : imagePath
if (!sourcePath) {
return imagePath
}
ctx.drawImage(sourcePath, 0, 0, outputWidth, outputHeight)
// ===== 水印配置 =====
const currentTime = getCurrentTime()
const fontSize = getAdaptiveFontSize(outputWidth)
const lineHeight = fontSize * 1.3
const startX = lineHeight
let startY = outputHeight - 3 * lineHeight
ctx.setFontSize(fontSize)
ctx.setFillStyle('#ffffff')
ctx.setGlobalAlpha(0.85)
// 时间
ctx.fillText(`时间: ${currentTime}`, startX, startY)
// 经纬度
if (props.location?.longitude && props.location?.latitude) {
startY += lineHeight
ctx.fillText(
`经纬度: ${props.location.longitude}, ${props.location.latitude}`,
startX,
startY
)
}
// 地址(自动换行)
if (props.location?.address) {
startY += lineHeight
drawMultilineText(
ctx,
`位置: ${props.location.address}`,
startX,
startY,
outputWidth - startX * 2,
lineHeight
)
}
// 执行绘制
await drawAsync(ctx)
const tempFile = await canvasToTempFileAsync(outputWidth, outputHeight, quality)
return tempFile
} catch (err) {
return Promise.reject(err)
} finally {
canvasShow.value = false
}
}
const getImageInfoAsync = (src: string) => {
return new Promise<UniApp.GetImageInfoSuccessData>((resolve, reject) => {
uni.getImageInfo({
src,
success: resolve,
fail: reject
})
})
}
const getImageInfoWithRetry = async (src: string, retries = 2) => {
let info = await getImageInfoAsync(src)
let count = 0
while (count < retries && (!info.width || !info.height)) {
await sleep(50)
info = await getImageInfoAsync(src)
count += 1
}
return info
}
const drawAsync = (ctx: UniApp.CanvasContext) => {
return new Promise<void>((resolve) => {
ctx.draw(true, resolve)
})
}
const canvasToTempFileAsync = (
width: number,
height: number,
quality: number
) => {
return new Promise<string>((resolve, reject) => {
uni.canvasToTempFilePath({
canvasId: 'watermarkCanvas',
fileType: 'jpg',
destWidth: width,
destHeight: height,
quality,
success: res => resolve(res.tempFilePath),
fail: reject
})
})
}
const calculateOutputSize = (
width: number,
height: number,
maxWidth: number,
maxHeight: number
) => {
if (width <= maxWidth && height <= maxHeight) {
return { outputWidth: width, outputHeight: height }
}
const ratio = Math.min(maxWidth / width, maxHeight / height)
return {
outputWidth: width * ratio,
outputHeight: height * ratio
}
}
const getAdaptiveFontSize = (canvasWidth: number) => {
const baseWidth = 961
const baseFontSize = 35
return baseFontSize * (canvasWidth / baseWidth)
}
const drawMultilineText = (
ctx: UniApp.CanvasContext,
text: string,
x: number,
y: number,
maxWidth: number,
lineHeight: number
) => {
let line = ''
let currentY = y
for (let i = 0; i < text.length; i++) {
const testLine = line + text[i]
const { width } = ctx.measureText(testLine)
if (width > maxWidth && i > 0) {
ctx.fillText(line, x, currentY)
line = text[i]
currentY += lineHeight
} else {
line = testLine
}
}
ctx.fillText(line, x, currentY)
}
// 上传图片到服务器
const uploadImage = async (tempFilePath: string, name: string) => {
// 创建上传中的文件对象
const uploadFileItem: FileItem = {
name,
uid: generateId(8),
type: 'image',
status: 'uploading',
thumb: tempFilePath,
url: tempFilePath,
message: '上传中',
response: { message: '' }
}
// 添加到文件列表
fileList.value.push(uploadFileItem)
try {
// 调用上传接口
const res = await defHttp.uploadFileAction(tempFilePath)
// 更新文件状态为成功
const index = fileList.value.findIndex(item => item.uid === uploadFileItem.uid)
if (index !== -1) {
fileList.value[index] = {
...fileList.value[index],
status: 'success',
message: '',
url: res,
response: { message: res }
}
}
// 更新modelValue
handlePathChange()
} catch (error) {
// 更新文件状态为失败
const index = fileList.value.findIndex(item => item.uid === uploadFileItem.uid)
if (index !== -1) {
fileList.value[index] = {
...fileList.value[index],
status: 'success', // 即使失败也保留图片,实际项目中可能需要处理失败状态
message: '上传失败',
response: { message: '' }
}
}
console.error('上传图片失败:', error)
}
}
// 拍照选择图片
const chooseImage = async () => {
// 计算剩余可上传数量
const remainingCount = props.maxCount - fileList.value.length
uni.chooseImage({
count: remainingCount, // 可选择的图片数量
sizeType: ['compressed'], // 原图或压缩图
sourceType: ['camera'], // 仅从相机拍照
success: async function (res) {
console.log('uploadPicture', res)
// 为每张图片添加水印并上传
for (let index = 0; index < res.tempFilePaths.length; index++) {
const imagePath = res.tempFilePaths[index];
const name = (Array.isArray(res.tempFiles) && res.tempFiles[index] && 'name' in res.tempFiles[index] ? (res.tempFiles[index] as File).name : '');
try {
// // 添加水印
const watermarkedImage = await addWatermark(imagePath)
await uploadImage(watermarkedImage as string, name)
} catch (error) {
console.error('添加水印失败:', error)
// 如果添加水印失败,使用原图上传
await uploadImage(imagePath, name)
}
}
}
})
}
// 预览图片
const previewImage = (index: number) => {
const urls = fileList.value.map(item => item.url)
uni.previewImage({
current: urls[index],
urls
})
}
// 删除图片
const deleteImage = (index: number) => {
fileList.value.splice(index, 1)
// 更新modelValue
handlePathChange()
}
getCurrentTime = (): string => {
const now = new Date()
const year = now.getFullYear()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
const hours = String(now.getHours()).padStart(2, '0')
const minutes = String(now.getMinutes()).padStart(2, '0')
const seconds = String(now.getSeconds()).padStart(2, '0')
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`
}
generateId(length = 8): string {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let id = '';
for (let i = 0; i < length; i++) {
id += chars[Math.floor(Math.random() * chars.length)];
}
return id;
}
</script>
<style scoped>
.camera-upload {
width: 100%;
}
.image-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
padding: 10px 0;
}
.image-item {
width: 150rpx;
aspect-ratio: 1;
overflow: hidden;
background-color: #f5f5f5;
position: relative;
}
.image-item image {
width: 100%;
height: 100%;
}
.delete-btn {
position: absolute;
top: 0;
right: 0;
background-color: rgb(55, 55, 55);
height: 14px;
width: 14px;
display: flex;
flex-direction: row;
border-bottom-left-radius: 100px;
align-items: center;
justify-content: center;
z-index: 3;
}
.preview-mask {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 1;
}
.upload-btn {
width: 150rpx;
aspect-ratio: 1;
border: 1px dashed #d9d9d9;
border-radius: 8px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fafafa;
}
.upload-icon {
font-size: 24px;
color: #999;
margin-bottom: 4px;
}
.upload-text {
font-size: 12px;
color: #999;
}
</style>
说明:目前水印字体大小会根据图片宽高动态计算,可以修改getAdaptiveFontSize方法中的baseWidth、baseFontSize来调整初始大小。定位可以调用高德或百度接口通过经纬度获取具体地址。