<template>
<view>
<!-- 拍照区域 - 多张照片 -->
<view class="photo-area">
<view class="photo-grid">
<!-- 已上传的照片 -->
<view class="photo-item" v-for="(photo, index) in photoList" :key="index">
<image :src="domain + photo" mode="aspectFill" class="photo-image"></image>
<view class="photo-delete" @click.stop="deletePhoto(index)">
<text>✕</text>
</view>
</view>
<!-- 添加照片按钮 -->
<view class="photo-add" @click="takePhoto" v-if="photoList.length < maxPhotoCount">
<text class="add-icon">+</text>
<text class="add-text">添加照片</text>
<text class="add-count">{{ photoList.length }}/{{ maxPhotoCount }}</text>
</view>
</view>
<view class="photo-tip" v-if="photoList.length === 0">
<text>请拍摄现场工作照片作为凭证(最多{{ maxPhotoCount }}张)</text>
</view>
</view>
<!-- Canvas 尺寸动态绑定 -->
<canvas
canvas-id="watermarkCanvas"
style="position: absolute; left: -9999px; top: -9999px;"
:style="{
width: canvasWidth + 'px',
height: canvasHeight + 'px'
}"
></canvas>
</view>
</template>
<script>
import CommonUtils from '../../utils/common.js'
import http from '@/utils/http.js'
import config from "@/config.js"
export default {
data() {
return {
photoList: [], // 改为数组存储多张照片
domain: config.domain,
maxPhotoCount: 6, // 最多上传6张照片
canvasWidth: 300,
canvasHeight: 300,
}
},
methods: {
// 拍摄照片 - 支持多张
takePhoto() {
if (this.photoList.length >= this.maxPhotoCount) {
uni.showToast({
title: `最多上传${this.maxPhotoCount}张照片`,
icon: 'none'
})
return
}
uni.chooseImage({
count: this.maxPhotoCount - this.photoList.length, // 剩余可上传数量
sizeType: ['original', 'compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
let tempFilePaths = res.tempFilePaths
uni.showLoading({ title: '上传中...' })
try {
console.log(44545, tempFilePaths)
// ✅ 调用时传入回调函数,更新 Canvas 尺寸
const watermarkedPath = await CommonUtils.addSingleWatermark(
tempFilePaths[0],
{
text: '现场签到',
time: new Date().toLocaleString(),
address: this.address || '未知地址',
},
(width, height) => {
// ✅ 回调函数:更新 Canvas 尺寸
console.log('???? 更新 Canvas 尺寸:', width, 'x', height)
this.canvasWidth = width
this.canvasHeight = height
}
)
tempFilePaths[0] = watermarkedPath
// 批量上传
const uploadPromises = tempFilePaths.map(filePath =>
CommonUtils.uploadImage(this.action, filePath)
)
const results = await Promise.all(uploadPromises)
let successCount = 0
results.forEach(data => {
if (data.code == 0) {
let image = data.data || data.data.img || data.data.url
this.photoList.push(image)
successCount++
}
})
uni.hideLoading()
uni.showToast({
title: `成功上传${successCount}张照片`,
icon: 'success'
})
} catch (err) {
uni.hideLoading()
uni.showToast({
title: err.message || '上传失败,请重试',
icon: 'none'
})
}
},
fail: (err) => {
console.log('选择图片失败:', err.errMsg)
}
})
},
// 删除照片
deletePhoto(index) {
uni.showModal({
title: '提示',
content: '确定要删除这张照片吗?',
success: (res) => {
if (res.confirm) {
this.photoList.splice(index, 1)
}
}
})
},
}
}
</script>
<style>
/* 拍照区域 */
.photo-area {
background: white;
border-radius: 24rpx;
overflow: hidden;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.04);
}
.photo-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 20rpx;
min-height: 300rpx;
background: #f8fafa;
border: 2rpx dashed #d0dbd8;
}
.photo-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.photo-text {
font-size: 28rpx;
color: #2d3e3a;
margin-bottom: 8rpx;
}
.photo-tip {
font-size: 22rpx;
color: #b0c4be;
}
.photo-preview {
position: relative;
}
.photo-image {
width: 100%;
height: 400rpx;
display: block;
}
.photo-actions {
position: absolute;
bottom: 20rpx;
right: 20rpx;
}
.retake-btn {
background: rgba(0, 0, 0, 0.6);
color: white;
padding: 12rpx 28rpx;
border-radius: 40rpx;
font-size: 24rpx;
backdrop-filter: blur(10rpx);
}
/* 照片网格布局 */
.photo-area {
background: white;
border-radius: 24rpx;
padding: 24rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.04);
}
.photo-grid {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.photo-item {
position: relative;
width: 200rpx;
height: 200rpx;
border-radius: 16rpx;
overflow: hidden;
background: #f0f4f3;
}
.photo-item .photo-image {
width: 100%;
height: 100%;
}
.photo-delete {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 44rpx;
height: 44rpx;
background: rgba(0, 0, 0, 0.6);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 28rpx;
backdrop-filter: blur(4rpx);
}
.photo-add {
width: 200rpx;
height: 200rpx;
border-radius: 16rpx;
border: 2rpx dashed #d0dbd8;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: #fafcfc;
}
.add-icon {
font-size: 60rpx;
color: #b0c4be;
}
.add-text {
font-size: 24rpx;
color: #8aa39b;
margin-top: 8rpx;
}
.add-count {
font-size: 20rpx;
color: #b0c4be;
margin-top: 4rpx;
}
.photo-tip {
margin-top: 16rpx;
font-size: 22rpx;
color: #b0c4be;
text-align: center;
}
/* 原有样式保持不变 */
.checkin-page {
min-height: 100vh;
background: #f5f8fa;
padding-bottom: 40rpx;
}
</style>
common.js方法
/**
* 通用工具库
* 包含所有公共方法,按功能模块分类
*/
const CommonUtils = {
// 上传照片
uploadImage(action, filePath) {
return new Promise((resolve, reject) => {
uni.showLoading({
title: '上传中...',
mask: true
});
// 压缩图片
const fileMaxSize = 0.3 * 1024 * 1024
const fileSize = filePath.size;
if (fileSize > fileMaxSize && filePath.type == 'image') {
const compressionRatio = this.getCompressionRatio(fileSize);
filePath = this.compressImg(filePath, compressionRatio)
}
uni.uploadFile({
url: action,
filePath: filePath,
name: 'file',
header: {
'x-csrf-token': uni.getStorageSync("TOKEN"),
},
success: (res) => {
uni.hideLoading();
try {
const data = JSON.parse(res.data);
console.log(33, data)
resolve(data); // 成功时 resolve
// if (data.code === 0) {
// // 根据类型设置对应的表单字段
// this.formData.diplomaPhoto = data.data.img || data.data.url || data.data;
// uni.showToast({
// title: '上传成功',
// icon: 'success'
// });
// } else {
// throw new Error(data.message || '上传失败');
// }
} catch (error) {
console.log('解析响应失败', error);
uni.showToast({
title: '上传失败,请重试',
icon: 'none'
});
}
},
fail: (err) => {
uni.hideLoading();
console.log('上传失败', err);
uni.showToast({
title: '上传失败',
icon: 'none'
});
}
});
})
},
// 裁剪图片到指定尺寸(自动居中裁剪)
getImageInfoWithBlob(filePath) {
return new Promise((resolve, reject) => {
// 如果是 blob 路径,需要特殊处理
if (filePath.startsWith('blob:')) {
const img = new Image();
img.onload = () => {
resolve({
width: img.width,
height: img.height,
path: filePath,
type: 'blob'
});
};
img.onerror = () => {
reject(new Error('图片加载失败'));
};
img.src = filePath;
} else {
// 正常路径,使用 uni.getImageInfo
uni.getImageInfo({
src: filePath,
success: (info) => {
resolve({
width: info.width,
height: info.height,
path: filePath,
type: 'normal'
});
},
fail: (err) => {
reject(new Error('获取图片信息失败:' + JSON.stringify(err)));
}
});
}
});
},
// 裁剪图片到指定尺寸(支持 blob 路径)
// 裁剪图片到指定尺寸(支持 blob 路径)
async cropImageToSize(filePath, targetWidth, targetHeight) {
return new Promise(async (resolve, reject) => {
try {
// 1. 获取图片信息
const imageInfo = await this.getImageInfoWithBlob(filePath);
const { width, height, path } = imageInfo;
console.log('原始图片尺寸:', width, 'x', height);
// 2. 计算裁剪区域(居中裁剪)
let cropX = 0, cropY = 0, cropWidth = width, cropHeight = height;
const targetRatio = targetWidth / targetHeight;
const currentRatio = width / height;
if (currentRatio > targetRatio) {
// 原图更宽,裁剪左右
cropHeight = height;
cropWidth = height * targetRatio;
cropX = (width - cropWidth) / 2;
} else {
// 原图更高,裁剪上下
cropWidth = width;
cropHeight = width / targetRatio;
cropY = (height - cropHeight) / 2;
}
// 3. 创建 Canvas 并绘制图片
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
// 设置画布尺寸
canvas.width = targetWidth;
canvas.height = targetHeight;
// 创建图片对象
const img = new Image();
img.crossOrigin = 'Anonymous'; // 处理跨域问题
img.onload = () => {
// 绘制图片(居中裁剪并缩放)
ctx.drawImage(
img,
cropX, cropY, cropWidth, cropHeight, // 源图裁剪区域
0, 0, targetWidth, targetHeight // 目标画布位置和大小
);
// 导出为 base64 或 blob
canvas.toBlob((blob) => {
// 将 blob 转换为临时文件路径
const url = URL.createObjectURL(blob);
resolve(url);
}, 'image/jpeg', 0.9);
};
img.onerror = () => {
reject(new Error('图片加载失败'));
};
// 处理不同类型的图片路径
if (path.startsWith('blob:')) {
img.src = path;
} else {
// 如果是本地文件路径,需要转换
uni.getFileSystemManager().readFile({
filePath: path,
success: (res) => {
const blob = new Blob([res.data], { type: 'image/jpeg' });
img.src = URL.createObjectURL(blob);
},
fail: (err) => {
reject(new Error('读取文件失败:' + JSON.stringify(err)));
}
});
}
} catch (err) {
reject(err);
}
});
},
// 压缩图片
getCompressionRatio(fileSize) {
let fileMaxSize = 0.3 * 1024 * 1024
console.log('fileSize',fileSize)
const multiple = (fileSize / fileMaxSize).toFixed(2);
let compressionRatio = 1;
if (multiple > 5) {
compressionRatio = 0.5
} else if (multiple > 4) {
compressionRatio = 0.6
} else if (multiple > 3) {
compressionRatio = 0.7
} else if (multiple > 2) {
compressionRatio = 0.8
} else if (multiple > 1) {
compressionRatio = 0.9
} else {
compressionRatio = 2
}
return compressionRatio;
},
compressImg(source, compressionRatio) {
let that = this;
return new Promise((resolve, reject) => {
image.compressImg(source.url, compressionRatio, source.type, compressRes => {
resolve(compressRes);
})
}).then((res) => {
source.size = res.size
// window.URL.revokeObjectURL(source.url) // 删除被压缩的缓存文件,这里注意,如果是相册选择上传,可能会删除相册的图片
source.url = res.source
source.thumb = res.source
return source
}).catch(err => {
console.log('图片压缩失败', err)
})
},
// ==================== 时间处理模块 ====================
/**
* 格式化时间
* @param {Date|string} date - 日期
* @param {string} format - 格式
*/
formatTime(date, format = 'YYYY-MM-DD HH:mm:ss') {
if (!date) return ''
const d = new Date(date)
const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0')
const day = String(d.getDate()).padStart(2, '0')
const hour = String(d.getHours()).padStart(2, '0')
const minute = String(d.getMinutes()).padStart(2, '0')
const second = String(d.getSeconds()).padStart(2, '0')
return format
.replace('YYYY', year)
.replace('MM', month)
.replace('DD', day)
.replace('HH', hour)
.replace('mm', minute)
.replace('ss', second)
},
// ==================== 其他工具模块 ====================
/**
* 生成随机ID
* @param {number} length - 长度
*/
generateId(length = 8) {
return Math.random().toString(36).substr(2, length)
},
// ==================== 表单验证模块 ====================
/**
* 手机号验证
* @param {string} phone - 手机号
*/
validatePhone(phone) {
const reg = /^1[3-9]\d{9}$/
return reg.test(phone)
},
/**
* 邮箱验证
* @param {string} email - 邮箱
*/
validateEmail(email) {
const reg = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
return reg.test(email)
},
/**
* 身份证验证
* @param {string} idCard - 身份证号
*/
validateIdCard(idCard) {
const reg = /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/
return reg.test(idCard)
},
/**
* 密码强度验证
* @param {string} password - 密码
*/
validatePassword(password) {
// 至少8位,包含字母和数字
const reg = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d]{8,}$/
return reg.test(password)
},
/**
* 图片压缩
* @param {string} filePath - 图片路径
* @param {number} quality - 压缩质量 0-100
*/
compressImage(filePath, quality = 80) {
return new Promise((resolve, reject) => {
uni.compressImage({
src: filePath,
quality: quality,
success: (res) => {
resolve(res.tempFilePath)
},
fail: (error) => {
reject(error)
}
})
})
},
/**
* 给单张图片添加水印
*/
// utils/common.js
/**
* 给单张图片添加水印
* @param {String} imagePath - 图片本地路径
* @param {Object} options - 水印配置
* @param {Function} onSizeReady - 尺寸就绪回调,用于更新 Canvas 尺寸
* @returns {Promise<String>} 返回处理后的图片路径
*/
addSingleWatermark(imagePath, options = {}, onSizeReady = null) {
console.log('========== addSingleWatermark 开始 ==========')
console.log('图片路径:', imagePath)
return new Promise((resolve, reject) => {
if (!imagePath) {
reject(new Error('图片路径无效'))
return
}
uni.getImageInfo({
src: imagePath,
success: (imageInfo) => {
console.log('✅ 图片信息:', imageInfo.width, 'x', imageInfo.height)
const width = imageInfo.width
const height = imageInfo.height
// ✅ 通过回调通知父组件更新 Canvas 尺寸
if (typeof onSizeReady === 'function') {
onSizeReady(width, height)
}
// 等待 DOM 更新后再绘制
setTimeout(() => {
const ctx = uni.createCanvasContext('watermarkCanvas', this)
// 清空并绘制原图
ctx.clearRect(0, 0, width, height)
ctx.drawImage(imagePath, 0, 0, width, height)
// ... 水印绘制逻辑(同之前)
const fontSize = Math.max(30, Math.min(width, height) / 25)
const padding = 30
const timeText = options.time || new Date().toLocaleString()
const addressText = options.address || '未知地址'
let watermarkText = `${timeText}\n${addressText}`
if (options.text) {
watermarkText = `${watermarkText}`
// watermarkText = `${options.text}\n${watermarkText}`
}
ctx.setFontSize(fontSize)
ctx.setFillStyle('rgba(255, 255, 255, 0.9)')
ctx.setTextAlign('right')
ctx.setTextBaseline('bottom')
const lines = watermarkText.split('\n')
const lineHeight = fontSize * 1.6
let maxWidth = 0
lines.forEach(line => {
const metrics = ctx.measureText(line)
if (metrics.width > maxWidth) {
maxWidth = metrics.width
}
})
const totalHeight = lines.length * lineHeight
const x = width - padding
const y = height - padding
const bgPadding = 20
ctx.setFillStyle('rgba(0, 0, 0, 0.5)')
ctx.fillRect(
x - maxWidth - bgPadding,
y - totalHeight - bgPadding,
maxWidth + bgPadding * 2,
totalHeight + bgPadding * 2
)
ctx.setFillStyle('rgba(255, 255, 255, 0.95)')
lines.forEach((line, index) => {
const textY = y - (lines.length - 1 - index) * lineHeight
ctx.fillText(line, x, textY)
})
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: 'watermarkCanvas',
width: width,
height: height,
destWidth: width,
destHeight: height,
fileType: 'jpg',
quality: 1,
success: (res) => {
console.log('✅ 水印导出成功')
resolve(res.tempFilePath)
},
fail: (err) => {
console.error('❌ 水印导出失败:', err)
resolve(imagePath)
}
}, this)
}, 800)
})
}, 100) // 等待 DOM 更新
},
fail: (err) => {
console.error('❌ 获取图片信息失败:', err)
resolve(imagePath)
}
})
})
}
}
export default CommonUtils