645 lines
17 KiB
Vue
645 lines
17 KiB
Vue
<template>
|
||
<view class="simple-image-annotator">
|
||
<view class="fixedheader">
|
||
<headers :showBack="true" :themeType="true">
|
||
<view class="headerName">
|
||
{{form.drawingName}}
|
||
</view>
|
||
</headers>
|
||
</view>
|
||
<view class="controls" :style="{paddingTop: mobileTopHeight + 44 + 'px'}">
|
||
<!-- 只有在编辑模式下才显示操作按钮 -->
|
||
<!-- <view v-if="viewMode === 'edit'">
|
||
<button class="control-btn" :class="{ active: mode === 'add' }" @click="setAddMode">
|
||
新增点位
|
||
</button>
|
||
<button class="control-btn delete-btn" :class="{ active: mode === 'delete' }" @click="setDeleteMode">
|
||
删除点位
|
||
</button>
|
||
</view> -->
|
||
</view>
|
||
<view class="image-container" ref="imageContainer">
|
||
<image class="my-image" ref="image" :src="imagePath" alt="标注图片" mode="aspectFit" @load="handleImageLoad"
|
||
@tap="handleImageClick" />
|
||
<view class="annotation-layer" ref="annotationLayer">
|
||
<view v-for="point in points" :key="point.id" class="annotation-point" :style="{
|
||
left: calculatePointPosition(point, 'x') + 'px',
|
||
top: calculatePointPosition(point, 'y') + 'px'
|
||
}" @tap.stop="handlePointClick(point.id)" @longpress.stop="handleRightClick(point.id)">
|
||
<view class="bubble" @click="deletePoint(point.id)" v-if="point.isShow">
|
||
删除
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
<view class="text-tip" v-if="viewMode === 'edit'">
|
||
如需修改检查位置需先点击此位置删除,然后重新定位位置
|
||
</view>
|
||
<view class="confrim-btn" v-if="viewMode === 'edit'">
|
||
<view @click="onCancelClick">取消</view>
|
||
<view @click="onSubmitType">保存</view>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script>
|
||
export default {
|
||
name: 'SimpleImageAnnotator',
|
||
data() {
|
||
return {
|
||
points: [],
|
||
imageNaturalWidth: 0,
|
||
imageNaturalHeight: 0,
|
||
containerWidth: 0,
|
||
containerHeight: 0,
|
||
scale: 1,
|
||
mode: 'add', // 默认模式为新增点位
|
||
form: {},
|
||
imagePath: "",
|
||
viewMode: "edit",
|
||
mobileTopHeight: 0,
|
||
}
|
||
},
|
||
mounted() {
|
||
this.initContainerSize()
|
||
window.addEventListener('resize', this.handleResize)
|
||
// 初始化时根据viewMode设置mode
|
||
if (this.viewMode === 'detail') {
|
||
this.mode = 'detail'
|
||
}
|
||
},
|
||
onLoad(opts) {
|
||
console.log(opts);
|
||
this.points = JSON.parse(opts.drawingPointData).map(item => {
|
||
// 确保点位对象包含必要的属性,为旧数据添加默认值
|
||
return {
|
||
...item,
|
||
isShow: false,
|
||
platform: item.platform || 'unknown',
|
||
originalImageWidth: item.originalImageWidth || 0,
|
||
originalImageHeight: item.originalImageHeight || 0,
|
||
// 保存原始坐标用于后续计算
|
||
originalX: item.x,
|
||
originalY: item.y
|
||
};
|
||
});
|
||
if (opts.viewMode) {
|
||
this.viewMode = opts.viewMode;
|
||
}
|
||
this.getQualityRegionConstructionDrawById(opts.regionDrawId);
|
||
},
|
||
mounted() {
|
||
const that = this;
|
||
uni.getSystemInfo({
|
||
success(res) {
|
||
that.mobileTopHeight = res.statusBarHeight ? res.statusBarHeight : 0;
|
||
}
|
||
})
|
||
this.initContainerSize()
|
||
window.addEventListener('resize', this.handleResize)
|
||
},
|
||
beforeDestroy() {
|
||
window.removeEventListener('resize', this.handleResize)
|
||
},
|
||
methods: {
|
||
onCancelClick() {
|
||
uni.navigateBack({
|
||
delta: 1
|
||
})
|
||
},
|
||
onSubmitType() {
|
||
if(this.points.length == 0) {
|
||
uni.showToast({
|
||
title: '请至少选择一个点位!',
|
||
icon: "none",
|
||
});
|
||
return
|
||
}
|
||
uni.$emit('updateData', JSON.stringify(this.points))
|
||
uni.navigateBack({
|
||
delta: 1
|
||
})
|
||
},
|
||
getQualityRegionConstructionDrawById(regionDrawId) {
|
||
this.sendRequest({
|
||
url: 'xmgl/qualityRegionConstructionDraw/queryById',
|
||
method: 'get',
|
||
data: {
|
||
projectSn: this.projectSn,
|
||
id: regionDrawId,
|
||
},
|
||
success: res => {
|
||
if (res.code == 200) {
|
||
const data = res.result;
|
||
this.imagePath = this.url_config + 'image/' + data.fileUrl;
|
||
this.form.drawingName = data.drawingName;
|
||
this.resetViewer()
|
||
}
|
||
}
|
||
})
|
||
},
|
||
// 初始化容器大小
|
||
initContainerSize() {
|
||
if (this.$refs.imageContainer) {
|
||
// 在uni-app中使用uni.createSelectorQuery获取元素尺寸
|
||
uni.createSelectorQuery().in(this)
|
||
.select('.image-container')
|
||
.boundingClientRect(res => {
|
||
if (res) {
|
||
this.containerWidth = res.width
|
||
this.containerHeight = res.height
|
||
|
||
// 如果图片已经加载,重新计算缩放
|
||
if (this.imageNaturalWidth && this.imageNaturalHeight) {
|
||
this.calculateScale()
|
||
}
|
||
}
|
||
}).exec()
|
||
}
|
||
},
|
||
|
||
// 处理窗口大小变化
|
||
handleResize() {
|
||
this.initContainerSize()
|
||
},
|
||
|
||
// 处理图片加载
|
||
handleImageLoad(event) {
|
||
// 在uni-app中使用宽高比计算
|
||
const {
|
||
width,
|
||
height
|
||
} = event.detail;
|
||
|
||
// 不使用原始宽度,而是获取图片的实际渲染尺寸
|
||
uni.createSelectorQuery().in(this)
|
||
.select('.image-container .my-image')
|
||
.boundingClientRect(imgRes => {
|
||
console.log(6666, imgRes)
|
||
if (imgRes) {
|
||
// 使用图片的实际渲染宽度和高度
|
||
this.imageNaturalWidth = imgRes.width
|
||
this.imageNaturalHeight = imgRes.height
|
||
console.log(`使用实际渲染尺寸: ${this.imageNaturalWidth}x${this.imageNaturalHeight}`)
|
||
this.calculateScale()
|
||
} else {
|
||
// 降级方案,如果获取不到实际渲染尺寸,则使用原始尺寸
|
||
this.imageNaturalWidth = width
|
||
this.imageNaturalHeight = height
|
||
this.calculateScale()
|
||
}
|
||
})
|
||
.exec()
|
||
},
|
||
|
||
// 计算缩放比例
|
||
calculateScale() {
|
||
if (!this.imageNaturalWidth || !this.imageNaturalHeight || !this.$refs.image || !this.$refs
|
||
.annotationLayer) {
|
||
return
|
||
}
|
||
|
||
// 在uni-app中使用动态计算
|
||
uni.createSelectorQuery().in(this)
|
||
.select('.image-container')
|
||
.boundingClientRect(containerRes => {
|
||
if (!containerRes) return
|
||
|
||
uni.createSelectorQuery().in(this)
|
||
.select('.image-container image')
|
||
.boundingClientRect(imgRes => {
|
||
if (!imgRes) return
|
||
|
||
// 计算实际显示的缩放比例
|
||
this.scale = imgRes.width / this.imageNaturalWidth
|
||
console.log(`计算缩放比例: 图片尺寸(${this.imageNaturalWidth}x${this.imageNaturalHeight}), 显示尺寸(${imgRes.width}x${imgRes.height}), 缩放比例: ${this.scale}`)
|
||
|
||
// 直接将标注层与图片完全重叠
|
||
const layer = this.$refs.annotationLayer
|
||
layer.style.width = imgRes.width + 'px'
|
||
layer.style.height = imgRes.height + 'px'
|
||
layer.style.left = (containerRes.width - imgRes.width) / 2 + 'px'
|
||
layer.style.top = (containerRes.height - imgRes.height) / 2 + 'px'
|
||
|
||
// 更新所有点位位置
|
||
this.$nextTick(() => {
|
||
this.updateAllPointsPosition();
|
||
});
|
||
})
|
||
.exec()
|
||
}).exec()
|
||
},
|
||
|
||
// 处理图片点击事件
|
||
handleImageClick(event) {
|
||
// 只有在编辑模式且为新增模式时才允许添加点位
|
||
if (this.viewMode === 'edit' && this.mode === 'add') {
|
||
// 在uni-app中获取点击位置
|
||
const {
|
||
x,
|
||
y
|
||
} = event.detail
|
||
|
||
// 获取标注层位置信息
|
||
uni.createSelectorQuery().in(this)
|
||
.select('.annotation-layer')
|
||
.boundingClientRect(layerRes => {
|
||
if (!layerRes) return
|
||
|
||
// 计算相对位置
|
||
const relX = (x - layerRes.left) / this.scale
|
||
const relY = (y - layerRes.top) / this.scale
|
||
this.addPoint(relX, relY)
|
||
})
|
||
.exec()
|
||
}
|
||
// 删除模式或详情模式下,点击图片空白区域不执行操作
|
||
},
|
||
|
||
// 生成唯一ID
|
||
generateUniqueId() {
|
||
return Date.now() + '_' + Math.floor(Math.random() * 10000)
|
||
},
|
||
|
||
// 添加标注点
|
||
addPoint(x, y) {
|
||
console.log('Adding point at:', x, y)
|
||
console.log(this.imageNaturalWidth, this.imageNaturalHeight)
|
||
// 确保坐标在有效范围内
|
||
const validX = Math.max(0, Math.min(x, this.imageNaturalWidth))
|
||
const validY = Math.max(0, Math.min(y, this.imageNaturalHeight))
|
||
console.log('Valid X, Y:', validX, validY)
|
||
const newPoint = {
|
||
id: this.generateUniqueId(),
|
||
x: validX,
|
||
y: validY,
|
||
isShow: false,
|
||
originalImageWidth: this.imageNaturalWidth, // 记录点位创建时的图片宽度
|
||
originalImageHeight: this.imageNaturalHeight // 记录点位创建时的图片高度
|
||
}
|
||
|
||
this.points.push(newPoint)
|
||
this.$emit('pointsChanged', this.points)
|
||
this.$emit('pointAdded', newPoint)
|
||
},
|
||
|
||
// 处理标注点点击
|
||
handlePointClick(pointId) {
|
||
// 只有在编辑模式且为删除模式时才允许删除点位
|
||
if (this.viewMode === 'edit' && this.mode === 'delete') {
|
||
this.deletePoint(pointId)
|
||
} else if (this.viewMode === 'edit') {
|
||
// 新增模式或详情模式下,触发点位选中事件
|
||
const point = this.points.find(p => p.id === pointId)
|
||
if (point) {
|
||
point.isShow = !point.isShow
|
||
}
|
||
}
|
||
},
|
||
|
||
// 设置为新增模式
|
||
setAddMode() {
|
||
if (this.viewMode === 'edit') {
|
||
this.mode = 'add'
|
||
// uni-app中不直接操作cursor样式
|
||
this.$emit('modeChanged', 'add')
|
||
}
|
||
},
|
||
|
||
// 设置为删除模式
|
||
setDeleteMode() {
|
||
if (this.viewMode === 'edit') {
|
||
this.mode = 'delete'
|
||
this.$emit('modeChanged', 'delete')
|
||
}
|
||
},
|
||
|
||
// 设置为详情模式
|
||
setDetailMode() {
|
||
this.mode = 'detail'
|
||
if (this.$refs.image) {
|
||
this.$refs.image.style.cursor = 'default'
|
||
}
|
||
this.$emit('modeChanged', 'detail')
|
||
},
|
||
|
||
// 处理右键点击
|
||
handleRightClick(pointId) {
|
||
// 只有在编辑模式下才允许右键删除点位
|
||
if (this.viewMode === 'edit') {
|
||
this.deletePoint(pointId)
|
||
}
|
||
},
|
||
|
||
// 删除标注点
|
||
deletePoint(pointId) {
|
||
const pointIndex = this.points.findIndex(p => p.id === pointId)
|
||
if (pointIndex !== -1) {
|
||
const deletedPoint = this.points[pointIndex]
|
||
this.points.splice(pointIndex, 1)
|
||
this.$emit('pointsChanged', this.points)
|
||
this.$emit('pointDeleted', deletedPoint)
|
||
}
|
||
},
|
||
|
||
// 清空所有标注点
|
||
clearAllPoints() {
|
||
this.points = []
|
||
this.$emit('allPointsCleared')
|
||
},
|
||
|
||
// 重置查看器
|
||
resetViewer() {
|
||
// this.points = []
|
||
this.imageNaturalWidth = 0
|
||
this.imageNaturalHeight = 0
|
||
this.scale = 1
|
||
// 根据视图模式重置mode
|
||
if (this.viewMode === 'detail') {
|
||
this.mode = 'detail'
|
||
} else {
|
||
this.mode = 'add' // 重置为新增模式
|
||
}
|
||
if (this.$refs.image) {
|
||
this.$refs.image.style.cursor = this.mode === 'add' ? 'crosshair' : 'default'
|
||
}
|
||
// 确保在下一次图片加载后更新点位位置
|
||
this.$nextTick(() => {
|
||
if (this.imageNaturalWidth && this.imageNaturalHeight) {
|
||
this.calculateScale();
|
||
}
|
||
});
|
||
},
|
||
resetPosit() {
|
||
this.points = JSON.parse(JSON.stringify(this.drawingPointData));
|
||
this.mode = 'add' // 重置为新增模式
|
||
if (this.$refs.image) {
|
||
this.$refs.image.style.cursor = 'crosshair'
|
||
}
|
||
},
|
||
// 获取所有点位
|
||
getAllPoints() {
|
||
// 返回点位数组的深拷贝,避免外部直接修改原始数据
|
||
return JSON.parse(JSON.stringify(this.points))
|
||
},
|
||
|
||
// 获取当前模式
|
||
getCurrentMode() {
|
||
return this.mode
|
||
},
|
||
|
||
// 计算点位在当前图片上的实际位置
|
||
calculatePointPosition(point, axis) {
|
||
// 如果点位没有记录原始图片尺寸,使用默认缩放
|
||
if (!point.originalImageWidth || !point.originalImageHeight || point.originalImageWidth === 0 || point.originalImageHeight === 0) {
|
||
return axis === 'x' ? point.x * this.scale : point.y * this.scale;
|
||
}
|
||
|
||
// 计算原始图片到当前图片的缩放比例
|
||
const widthRatio = this.imageNaturalWidth / point.originalImageWidth;
|
||
const heightRatio = this.imageNaturalHeight / point.originalImageHeight;
|
||
// 使用平均比例确保点位位置正确
|
||
const avgRatio = (widthRatio + heightRatio) / 2;
|
||
|
||
// 获取原始坐标,如果存在则使用原始坐标进行计算
|
||
const originalCoord = point[axis === 'x' ? 'originalX' : 'originalY'] || point[axis];
|
||
console.log(888888888, originalCoord * avgRatio * this.scale)
|
||
// 返回计算后的位置
|
||
return originalCoord * avgRatio * this.scale;
|
||
},
|
||
|
||
// 更新所有点位位置
|
||
updateAllPointsPosition() {
|
||
// 根据当前平台和图片尺寸重新计算所有点位位置
|
||
if (!this.scale || this.points.length === 0) return;
|
||
|
||
console.log('更新点位位置,缩放比例:', this.scale);
|
||
|
||
// 为每个点位计算基于当前图片尺寸的实际位置
|
||
this.points.forEach(point => {
|
||
// 如果点位记录了原始图片尺寸,则进行尺寸适配计算
|
||
if (point.originalImageWidth && point.originalImageHeight && point.originalImageWidth !== 0 && point.originalImageHeight !== 0) {
|
||
// 计算原始图片到当前图片的缩放比例
|
||
const widthRatio = this.imageNaturalWidth / point.originalImageWidth;
|
||
const heightRatio = this.imageNaturalHeight / point.originalImageHeight;
|
||
|
||
// 存储原始坐标用于调试
|
||
if (!point.originalX) {
|
||
point.originalX = point.x;
|
||
point.originalY = point.y;
|
||
}
|
||
|
||
// 使用平均比例进行缩放,确保点位比例正确
|
||
const avgRatio = (widthRatio + heightRatio) / 2;
|
||
console.log(`点位 ${point.id} 缩放: 原图(${point.originalImageWidth}x${point.originalImageHeight}) -> 当前图(${this.imageNaturalWidth}x${this.imageNaturalHeight}), 比例: ${avgRatio}`);
|
||
}
|
||
});
|
||
|
||
this.$forceUpdate();
|
||
},
|
||
}
|
||
}
|
||
</script>
|
||
|
||
<style scoped lang="scss">
|
||
.text-tip {
|
||
font-size: 24rpx;
|
||
color: #B3B3B3;
|
||
display: flex;
|
||
justify-content: center;
|
||
padding-bottom: 26rpx;
|
||
}
|
||
.confrim-btn {
|
||
padding: 18rpx 26rpx;
|
||
background-color: #FFFFFF;
|
||
box-shadow: 0rpx -8rpx 8rpx 0rpx rgba(0, 0, 0, 0.05);
|
||
display: flex;
|
||
|
||
>view {
|
||
width: 50%;
|
||
height: 76rpx;
|
||
font-weight: 500;
|
||
font-size: 28rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
>view:first-child {
|
||
background-color: rgba(81, 129, 246, 0.1);
|
||
border-radius: 6rpx 0rpx 0rpx 6rpx;
|
||
color: #5181F6;
|
||
}
|
||
|
||
>view:last-child {
|
||
background-color: #5181F6;
|
||
border-radius: 0rpx 6rpx 6rpx 0rpx;
|
||
color: #FFFFFF;
|
||
}
|
||
}
|
||
|
||
.simple-image-annotator {
|
||
display: flex;
|
||
flex-direction: column;
|
||
height: 100%;
|
||
width: 100%;
|
||
}
|
||
|
||
.image-container {
|
||
position: relative;
|
||
// flex: 1;
|
||
overflow: hidden;
|
||
// background-color: #f5f5f5;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
margin: auto 0;
|
||
}
|
||
|
||
.image-container image {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
transition: transform 0.2s;
|
||
width: 100%;
|
||
}
|
||
|
||
/* 详情模式下图片指针样式 */
|
||
.detail-mode img {
|
||
cursor: default !important;
|
||
}
|
||
|
||
.annotation-layer {
|
||
position: absolute;
|
||
top: 0;
|
||
left: 0;
|
||
pointer-events: none;
|
||
transform-origin: top left;
|
||
}
|
||
|
||
.annotation-point {
|
||
position: absolute;
|
||
width: 20rpx;
|
||
height: 30rpx;
|
||
/* background-color: rgba(255, 69, 0, 0.8); */
|
||
/* border-radius: 50%; */
|
||
background-image: url("@/static/draw-point.png");
|
||
background-repeat: no-repeat;
|
||
background-size: 100% 100%;
|
||
transform: translate(-50%, -50%);
|
||
pointer-events: all;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: white;
|
||
font-weight: bold;
|
||
/* box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); */
|
||
}
|
||
|
||
.bubble {
|
||
width: calc(120rpx);
|
||
height: 70rpx;
|
||
padding: 10rpx 32rpx;
|
||
background-image: url("@/static/bubble.png");
|
||
background-repeat: no-repeat;
|
||
background-size: 100% 100%;
|
||
position: absolute;
|
||
bottom: 100%;
|
||
font-weight: 500;
|
||
font-size: 28rpx;
|
||
color: #000000;
|
||
}
|
||
|
||
.annotation-point:hover {
|
||
/* background-color: rgba(255, 0, 0, 0.9); */
|
||
transform: translate(-50%, -50%) scale(1.1);
|
||
}
|
||
|
||
.annotation-point .delete-btn {
|
||
position: absolute;
|
||
top: -16rpx;
|
||
right: -16rpx;
|
||
width: 32rpx;
|
||
height: 32rpx;
|
||
background-color: white;
|
||
border-radius: 50%;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
color: #ff4500;
|
||
font-size: 24rpx;
|
||
cursor: pointer;
|
||
border: 2rpx solid #ff4500;
|
||
}
|
||
|
||
.annotation-point .delete-btn:hover {
|
||
background-color: #ff4500;
|
||
color: white;
|
||
}
|
||
|
||
.point-index {
|
||
font-size: 20rpx;
|
||
}
|
||
|
||
.controls {
|
||
padding: 0 20rpx 20rpx;
|
||
background-color: white;
|
||
/* border-top: 1px solid #e0e0e0; */
|
||
display: flex;
|
||
gap: 20rpx;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.control-btn {
|
||
padding: 12rpx 32rpx;
|
||
background-color: #4CAF50;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8rpx;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
}
|
||
|
||
.control-btn:hover {
|
||
background-color: #45a049;
|
||
}
|
||
|
||
.control-btn.active {
|
||
background-color: #2196F3;
|
||
}
|
||
|
||
.clear-btn {
|
||
padding: 12rpx 32rpx;
|
||
background-color: #ff4500;
|
||
color: white;
|
||
border: none;
|
||
border-radius: 8rpx;
|
||
cursor: pointer;
|
||
transition: background-color 0.2s;
|
||
margin-left: auto;
|
||
}
|
||
|
||
.clear-btn:hover {
|
||
background-color: #ff6347;
|
||
}
|
||
|
||
.point-count {
|
||
color: #666;
|
||
font-size: 28rpx;
|
||
margin-left: 20rpx;
|
||
}
|
||
|
||
.fixedheader {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
width: 100%;
|
||
z-index: 2;
|
||
|
||
.headerName {
|
||
z-index: 1;
|
||
}
|
||
}
|
||
</style> |