页面修改

just-order
LAPTOP-S9HJSOEB\昊天 4 months ago
parent 9cd06f4792
commit b9c59dace2

52
package-lock.json generated

@ -1307,6 +1307,11 @@
"integrity": "sha512-T7VNNlYVM1SgQ+VsMYhnDkcGmWhQdL0bDyGm5TlQ3GBXnJscEClUUOKduWTmm2zCnvNLC1hc3JpuXjs/nFOc5w==",
"dev": true
},
"@techstark/opencv-js": {
"version": "4.12.0-release.1",
"resolved": "https://registry.npmmirror.com/@techstark/opencv-js/-/opencv-js-4.12.0-release.1.tgz",
"integrity": "sha512-LtTaph9v/HqLPXEg3m1xs2h7QJh10pUpuDT0nj8g77lelWnTwwQrehtd+fXElLOdrkqc4Fea6Z/sJBvEJLYPfw=="
},
"@types/anymatch": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@types/anymatch/-/anymatch-1.3.1.tgz",
@ -4757,8 +4762,7 @@
"core-util-is": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz",
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=",
"dev": true
"integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac="
},
"cosmiconfig": {
"version": "5.2.1",
@ -7592,6 +7596,11 @@
"dev": true,
"optional": true
},
"immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
},
"import-cwd": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/import-cwd/-/import-cwd-2.1.0.tgz",
@ -8177,8 +8186,7 @@
"isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
"dev": true
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE="
},
"isexe": {
"version": "2.0.0",
@ -8373,6 +8381,17 @@
"verror": "1.10.0"
}
},
"jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"requires": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"keycode": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.0.tgz",
@ -8662,6 +8681,14 @@
"type-check": "~0.3.2"
}
},
"lie": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"requires": {
"immediate": "~3.0.5"
}
},
"lines-and-columns": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz",
@ -10075,8 +10102,7 @@
"pako": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"dev": true
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
},
"parallel-transform": {
"version": "1.2.0",
@ -10967,8 +10993,7 @@
"process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
},
"progress": {
"version": "2.0.3",
@ -11262,7 +11287,6 @@
"version": "2.3.7",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.7.tgz",
"integrity": "sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==",
"dev": true,
"requires": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
@ -11665,8 +11689,7 @@
"safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
},
"safe-json-parse": {
"version": "4.0.0",
@ -12028,8 +12051,7 @@
"setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=",
"dev": true
"integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU="
},
"setprototypeof": {
"version": "1.1.1",
@ -12648,7 +12670,6 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true,
"requires": {
"safe-buffer": "~5.1.0"
}
@ -13459,8 +13480,7 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=",
"dev": true
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
},
"util.promisify": {
"version": "1.0.1",

@ -9,6 +9,7 @@
"start": "node antdTheme.js && npm run serve"
},
"dependencies": {
"@techstark/opencv-js": "^4.12.0-release.1",
"ant-design-vue": "^1.6.3",
"antd-theme-generator": "^1.2.8",
"axios": "^0.19.2",
@ -18,6 +19,7 @@
"echarts": "^5.4.3",
"echarts-gl": "^2.0.9",
"element-ui": "^2.15.6",
"jszip": "^3.10.1",
"path-to-regexp": "^6.2.0",
"style-loader": "^2.0.0",
"v-viewer": "^1.5.1",

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

@ -15,6 +15,12 @@ export default {
name: "获取历史监控列表"
},
getStockErrorUrls: {
method: "POST",
url: "/stock/getStockErrorUrls",
name: "获取报错图片列表"
},
getStreetList: {
method: "POST",
url: "/street/page",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 87 KiB

@ -128,6 +128,15 @@ const routes = [{
},
component: () => import('@/views/roadwayManage/index')
},
{
path: 'test',
name: 'test',
meta: {
icon: 'build',
name: '前端截图'
},
component: () => import('@/views/test/index')
},
// {
// path: 'cameraManage',
// name: 'cameraManage',

File diff suppressed because one or more lines are too long

@ -8,18 +8,8 @@
</template>
<script>
import * as echarts from "echarts/core";
import { DatasetComponent } from "echarts/components";
import { CanvasRenderer } from "echarts/renderers";
import { Scatter3DChart } from "echarts-gl/charts";
import { Grid3DComponent } from "echarts-gl/components";
echarts.use([
DatasetComponent,
Grid3DComponent,
Scatter3DChart,
CanvasRenderer,
]);
import * as echarts from "echarts";
import "echarts-gl";
export default {
props: ["src"],
name: "pointCloud",

@ -0,0 +1,363 @@
<template>
<div class="image-card">
<div class="card-header">
<span class="card-title">{{ keyPrefix }}</span>
<a-tag color="purple" size="small">{{ image.code }}</a-tag>
</div>
<div class="card-image" ref="imageContainer" @mouseenter="startCrop" @mouseleave="cancelSelection">
<img
ref="image"
:src="image.url"
:alt="keyPrefix"
crossorigin="anonymous"
@error="handleImageError"
/>
<canvas
ref="canvas"
v-show="canvasVisible"
></canvas>
</div>
<div class="card-info">
<span class="info-text">{{ cropInfo }}</span>
</div>
<div class="card-actions">
<a-button size="small" @click="resetCrop">
<a-icon type="redo" /> 重置
</a-button>
<a-button size="small" type="primary" @click="matchTemplate" :disabled="!croppedImage">
<a-icon type="search" /> 匹配并裁剪
</a-button>
</div>
<div v-if="croppedImage" class="card-preview">
<div class="preview-label">裁剪预览:</div>
<img :src="croppedImage" alt="裁剪结果" />
</div>
<div v-if="matchResult && matchResult.matched" class="match-status">
<a-tag color="green">
<a-icon type="check-circle" /> 已识别 (相似度: {{ (matchResult.score * 100).toFixed(1) }}%)
</a-tag>
</div>
</div>
</template>
<script>
import { Tag, Button, Icon } from 'ant-design-vue';
export default {
name: 'ImageCropCard',
components: {
'a-tag': Tag,
'a-button': Button,
'a-icon': Icon
},
props: ['image', 'keyPrefix', 'code', 'street', 'opencv', 'matchResult'],
inject: ['templateData', 'setTemplate'],
data() {
return {
isSelecting: false,
startX: 0,
startY: 0,
endX: 0,
endY: 0,
cropping: false,
croppedImage: null,
cropInfo: '鼠标悬停开始裁剪',
canvasVisible: false
};
},
mounted() {
const canvas = this.$refs.canvas;
if (canvas) {
canvas.style.position = 'absolute';
canvas.style.top = '0';
canvas.style.left = '0';
canvas.style.cursor = 'crosshair';
canvas.addEventListener('mousedown', this.startSelection);
canvas.addEventListener('mousemove', this.updateSelection);
canvas.addEventListener('mouseup', this.endSelection);
}
},
beforeDestroy() {
const canvas = this.$refs.canvas;
if (canvas) {
canvas.removeEventListener('mousedown', this.startSelection);
canvas.removeEventListener('mousemove', this.updateSelection);
canvas.removeEventListener('mouseup', this.endSelection);
}
},
methods: {
startCrop() {
const img = this.$refs.image;
const canvas = this.$refs.canvas;
if (!img || !img.complete || img.naturalWidth === 0) {
this.cropInfo = '图片尚未加载完成';
return;
}
canvas.width = img.clientWidth;
canvas.height = img.clientHeight;
this.canvasVisible = true;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.cropping = true;
this.cropInfo = '拖动鼠标选择裁剪区域';
},
startSelection(e) {
const canvas = this.$refs.canvas;
const rect = canvas.getBoundingClientRect();
this.isSelecting = true;
this.startX = e.clientX - rect.left;
this.startY = e.clientY - rect.top;
},
updateSelection(e) {
if (!this.isSelecting) return;
const canvas = this.$refs.canvas;
const ctx = canvas.getContext('2d');
const rect = canvas.getBoundingClientRect();
this.endX = e.clientX - rect.left;
this.endY = e.clientY - rect.top;
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = '#1890ff';
ctx.lineWidth = 2;
ctx.setLineDash([5, 5]);
const x = Math.min(this.startX, this.endX);
const y = Math.min(this.startY, this.endY);
const width = Math.abs(this.endX - this.startX);
const height = Math.abs(this.endY - this.startY);
ctx.strokeRect(x, y, width, height);
ctx.fillStyle = 'rgba(24, 144, 255, 0.1)';
ctx.fillRect(x, y, width, height);
this.cropInfo = `选择区域: ${Math.round(x)}, ${Math.round(y)}, ${Math.round(width)}x${Math.round(height)}`;
},
endSelection() {
if (!this.isSelecting) return;
this.isSelecting = false;
const x = Math.min(this.startX, this.endX);
const y = Math.min(this.startY, this.endY);
const width = Math.abs(this.endX - this.startX);
const height = Math.abs(this.endY - this.startY);
if (width > 5 && height > 5) {
this.performCrop(x, y, width, height);
} else {
this.cropInfo = '选择区域太小,请重新选择';
}
},
cancelSelection() {
if (this.isSelecting) {
this.isSelecting = false;
}
},
performCrop(x, y, width, height) {
const img = this.$refs.image;
// 计算显示尺寸与实际尺寸的比例
// 由于使用 object-fit: contain,需要计算实际图片在容器中的显示区域
const imgRect = this.getImageDisplayRect(img);
// 显示区域内的坐标转换为实际图片的坐标
const scaleX = img.naturalWidth / imgRect.width;
const scaleY = img.naturalHeight / imgRect.height;
// 转换为相对于实际图片的坐标
const relativeX = x - imgRect.left;
const relativeY = y - imgRect.top;
const actualX = relativeX * scaleX;
const actualY = relativeY * scaleY;
const actualWidth = width * scaleX;
const actualHeight = height * scaleY;
const canvas = document.createElement('canvas');
canvas.width = actualWidth;
canvas.height = actualHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, actualX, actualY, actualWidth, actualHeight, 0, 0, actualWidth, actualHeight);
canvas.toBlob((blob) => {
if (!blob) return;
this.croppedImage = URL.createObjectURL(blob);
this.canvasVisible = false;
this.cropInfo = `裁剪完成: ${Math.round(actualWidth)}x${Math.round(actualHeight)} 像素`;
this.$emit('crop-complete', this.keyPrefix, blob, { x: actualX, y: actualY, width: actualWidth, height: actualHeight });
}, 'image/png');
},
// 获取图片在容器中的实际显示区域
getImageDisplayRect(img) {
const container = img.parentElement;
const containerRatio = container.clientWidth / container.clientHeight;
const imgRatio = img.naturalWidth / img.naturalHeight;
let width, height, left, top;
if (imgRatio > containerRatio) {
// 图片较宽,按宽度适配
width = container.clientWidth;
height = width / imgRatio;
left = 0;
top = (container.clientHeight - height) / 2;
} else {
// 图片较高,按高度适配
height = container.clientHeight;
width = height * imgRatio;
top = 0;
left = (container.clientWidth - width) / 2;
}
return { left, top, width, height };
},
resetCrop() {
const canvas = this.$refs.canvas;
if (canvas) {
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
this.canvasVisible = false;
}
this.croppedImage = null;
this.cropInfo = '鼠标悬停开始裁剪';
this.$emit('crop-complete', this.keyPrefix, null);
},
matchTemplate() {
if (!this.croppedImage) return;
// 使用当前裁剪的图片作为模板,进行匹配
this.$emit('set-template', {
code: this.code,
street: this.street,
image: this.croppedImage,
keyPrefix: this.keyPrefix
});
// 触发匹配,传递 keyPrefix 以排除当前图片
this.$emit('match-template', {
code: this.code,
street: this.street,
keyPrefix: this.keyPrefix
});
},
handleImageError() {
this.cropInfo = '图片加载失败';
this.canvasVisible = false;
}
},
computed: {
hasTemplate() {
// 当前裁剪的图片就是模板
return !!this.croppedImage;
}
}
};
</script>
<style scoped>
.image-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.image-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.card-title {
font-weight: 600;
font-size: 14px;
}
.card-image {
position: relative;
background: #f5f5f5;
height: 700px;
overflow: hidden;
}
.card-image img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.card-image canvas {
position: absolute;
top: 0;
left: 0;
cursor: crosshair;
}
.card-info {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.info-text {
font-size: 12px;
color: #8c8c8c;
}
.card-actions {
padding: 12px 16px;
display: flex;
justify-content: center;
gap: 8px;
}
.card-preview {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
}
.preview-label {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 8px;
}
.card-preview img {
width: 100%;
height: 100px;
object-fit: contain;
border-radius: 4px;
border: 1px solid #d9d9d9;
}
.match-status {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
text-align: center;
}
</style>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,669 @@
<template>
<div class="page-container">
<div class="page-header">
<div class="header-content">
<h1 class="page-title">
<a-icon type="scissor" /> 图片裁剪工具
</h1>
<p class="page-desc">选择图片区域进行裁剪,使用模板匹配自动裁剪相似区域</p>
</div>
<div class="header-actions">
<a-button size="large" @click="loadErrorImages">
<a-icon type="reload" /> 刷新图片
</a-button>
<a-button type="primary" size="large" @click="exportAllCroppedImages" :disabled="!hasCroppedImages">
<a-icon type="download" /> 导出全部裁剪图片
</a-button>
</div>
</div>
<div class="instruction-card">
<a-alert
message="使用说明"
description="1. 鼠标悬停在图片上开始裁剪 | 2. 拖动选择区域 | 3. 使用设为模板后点击匹配并裁剪可自动识别相似区域"
type="info"
show-icon
/>
</div>
<a-spin :spinning="loading" tip="加载图片中...">
<div class="tabs-container">
<div v-if="imagesData.length === 0" class="empty-container">
<a-empty description="暂无报错图片数据">
<a-button type="primary" @click="loadErrorImages">刷新数据</a-button>
</a-empty>
</div>
<a-tabs v-else v-model="activeStreet" @change="handleStreetChange">
<a-tab-pane v-for="(data, street) in groupedByStreet" :key="street" :tab="`巷道 ${street}`">
<div class="street-toolbar">
<span class="street-info">共 {{ getTotalImages(street) }} 张图片</span>
<a-tag color="blue">已裁剪: {{ getCroppedCount(street) }}</a-tag>
<a-button size="small" @click="exportStreetImages(street)">
<a-icon type="export" /> 导出本巷道
</a-button>
</div>
<div v-for="(items, code) in data" :key="code" class="code-section">
<div class="code-header">
<a-divider>
<span class="code-title">Code: {{ code }}</span>
<a-tag color="cyan">{{ items.length }} 张</a-tag>
</a-divider>
</div>
<a-row :gutter="[20, 20]">
<a-col :span="12" v-for="(item, idx) in items" :key="`${code}-${idx}`">
<image-crop-card
:image="item"
:key-prefix="`${code}-1${idx + 1}`"
:code="code"
:street="street"
:opencv="cv"
:match-result="matchResults[`${code}-1${idx + 1}`]"
@crop-complete="handleCropComplete"
@set-template="handleSetTemplate"
@match-template="handleMatchTemplate"
/>
</a-col>
</a-row>
</div>
</a-tab-pane>
</a-tabs>
</div>
</a-spin>
<a-modal
v-model="templateModalVisible"
title="当前模板"
:footer="null"
width="400px"
>
<div class="template-preview">
<img v-if="templateImage" :src="templateImage" alt="模板预览" />
<a-empty v-else description="尚未设置模板" />
</div>
<div class="template-info">
<a-tag v-if="templateData">Code: {{ templateData.code }}</a-tag>
<a-tag v-if="templateData" color="green">巷道: {{ templateData.street }}</a-tag>
</div>
</a-modal>
</div>
</template>
<script>
import JSZip from 'jszip';
import { Tabs, Button, Alert, Tag, Divider, Row, Col, Modal, Empty, Spin, Icon, message } from 'ant-design-vue';
import ImageCropCard from './ImageCropCard.vue';
import cv from '@techstark/opencv-js';
export default {
name: 'ImageCropper',
components: {
'a-tabs': Tabs,
'a-tab-pane': Tabs.TabPane,
'a-button': Button,
'a-alert': Alert,
'a-tag': Tag,
'a-divider': Divider,
'a-row': Row,
'a-col': Col,
'a-modal': Modal,
'a-empty': Empty,
'a-spin': Spin,
'a-icon': Icon,
'image-crop-card': ImageCropCard
},
provide() {
return {
templateData: this.computedTemplateData,
setTemplate: this.handleSetTemplate
};
},
data() {
return {
imagesData: [],
croppedImages: {},
cropCounters: {},
cv: cv,
opencvReady: true,
templateData: null,
templateImage: null,
templateModalVisible: false,
activeStreet: '',
templateMat: null,
loading: true,
matchResults: {} // 存储每个图片的匹配结果 { keyPrefix: { matched: boolean, score: number } }
};
},
computed: {
groupedByStreet() {
const grouped = {};
this.imagesData.forEach((item, index) => {
const street = item.street || 'unknown';
const code = item.code || `img_${index}`;
if (!grouped[street]) grouped[street] = {};
if (!grouped[street][code]) grouped[street][code] = [];
grouped[street][code].push({ ...item, index });
});
return grouped;
},
hasCroppedImages() {
return Object.keys(this.croppedImages).length > 0;
},
computedTemplateData() {
return this.templateData;
}
},
async mounted() {
console.log('OpenCV 已加载:', this.cv);
await this.loadErrorImages();
},
methods: {
async loadErrorImages() {
try {
this.loading = true;
const res = await this.$api.httpApi.getStockErrorUrls({});
console.log('API 响应:', res);
if (res && res.code === 200 && res.data) {
this.imagesData = res.data.map((item, index) => ({
code: item.code || `img_${index}`,
street: item.street ? item.street.toString() : 'unknown',
url: item.url,
id: item.id || index,
index: index
}));
console.log('处理后的图片数据:', this.imagesData);
console.log('分组数据:', this.groupedByStreet);
message.success(`成功加载 ${this.imagesData.length} 张图片`);
// 设置默认激活第一个巷道
if (Object.keys(this.groupedByStreet).length > 0) {
this.activeStreet = Object.keys(this.groupedByStreet)[0];
}
} else {
console.error('API 返回格式不正确:', res);
message.error('获取图片列表失败');
}
} catch (error) {
console.error('获取图片列表错误:', error);
message.error('获取图片列表失败');
} finally {
this.loading = false;
}
},
handleStreetChange(street) {
console.log('切换到巷道:', street);
},
handleCropComplete(key, blob, cropRect) {
if (blob) {
const code = key.split('-')[0];
this.cropCounters[code] = (this.cropCounters[code] || 0) + 1;
const cropKey = `${code}-1${this.cropCounters[code]}`;
this.$set(this.croppedImages, cropKey, blob);
// 保存裁剪区域信息用于模板匹配
this.$set(this.matchResults, cropKey, { matched: false, score: 0, cropRect });
} else {
Object.keys(this.croppedImages).forEach(k => {
if (k.startsWith(key.split('-')[0])) {
this.$delete(this.croppedImages, k);
this.$delete(this.matchResults, k);
}
});
}
},
async handleSetTemplate(data) {
this.templateData = data;
this.templateImage = data.image;
// 不弹出模态框,直接加载模板
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
if (this.cv) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
this.templateMat = this.cv.matFromImageData(imageData);
}
};
img.src = data.image;
},
async handleMatchTemplate(target) {
message.loading({ content: '正在匹配中...', key: 'matching' });
// 计算模板图片的 keyPrefix
const templateKeyPrefix = target.keyPrefix || `${target.code}-1${target.idx + 1}`;
// 获取相同 code 和 street 的其他图片,排除模板图片
const targetImages = this.imagesData.filter(
item => item.code === target.code && item.street === target.street && `${item.code}-1${item.index + 1}` !== templateKeyPrefix
);
const matchedKeys = [];
for (const item of targetImages) {
const key = `${item.code}-1${item.index + 1}`;
const result = await this.findBestMatch(item, this.templateMat);
if (result) {
matchedKeys.push(key);
// 更新匹配结果
this.$set(this.matchResults, key, {
matched: true,
score: result.score,
x: result.x,
y: result.y,
width: result.width,
height: result.height
});
}
}
message.success({
content: `${templateKeyPrefix} 匹配完成,找到 ${matchedKeys.length} 个匹配区域。识别到: ${matchedKeys.join(', ')}`,
key: 'matching',
duration: 10
});
},
async findBestMatch(item, templateMat) {
return new Promise((resolve) => {
const key = `${item.code}-1${item.index + 1}`;
console.log(`开始匹配图片: ${key}, URL: ${item.url}`);
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => {
try {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
console.log(`图片尺寸: ${canvas.width}x${canvas.height}, 模板尺寸: ${templateMat.cols}x${templateMat.rows}`);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const srcMat = this.cv.matFromImageData(imageData);
if (templateMat.cols > srcMat.cols || templateMat.rows > srcMat.rows) {
console.log(`跳过: 模板比图片大`);
srcMat.delete();
resolve(null);
return;
}
const maskMat = new this.cv.Mat();
const resultMat = new this.cv.Mat();
this.cv.matchTemplate(srcMat, templateMat, resultMat, this.cv.TM_CCOEFF_NORMED, maskMat);
const minMax = this.cv.minMaxLoc(resultMat);
const threshold = 0.7;
console.log(`匹配结果: ${key}, 相似度: ${minMax.maxVal.toFixed(4)}, 阈值: ${threshold}`);
if (minMax.maxVal >= threshold) {
const cropCanvas = document.createElement('canvas');
cropCanvas.width = templateMat.cols;
cropCanvas.height = templateMat.rows;
const cropCtx = cropCanvas.getContext('2d');
cropCtx.drawImage(
img,
minMax.maxLoc.x, minMax.maxLoc.y,
templateMat.cols, templateMat.rows,
0, 0,
templateMat.cols, templateMat.rows
);
console.log(`匹配成功: ${key}, 位置: (${minMax.maxLoc.x}, ${minMax.maxLoc.y})`);
cropCanvas.toBlob((blob) => {
if (blob) {
this.$set(this.croppedImages, key, blob);
resolve({
score: minMax.maxVal,
x: minMax.maxLoc.x,
y: minMax.maxLoc.y,
width: templateMat.cols,
height: templateMat.rows
});
} else {
console.log(`blob 生成失败: ${key}`);
resolve(null);
}
}, 'image/png');
} else {
console.log(`匹配失败: ${key}, 相似度 ${minMax.maxVal.toFixed(4)} 低于阈值 ${threshold}`);
resolve(null);
}
srcMat.delete();
maskMat.delete();
resultMat.delete();
} catch (error) {
console.error(`匹配错误 ${key}:`, error);
resolve(null);
}
};
img.onerror = () => {
console.log(`图片加载失败: ${key}, URL: ${item.url}`);
resolve(null);
};
img.src = item.url;
});
},
getTotalImages(street) {
const data = this.groupedByStreet[street];
if (!data) return 0;
return Object.values(data).reduce((sum, items) => sum + items.length, 0);
},
getCroppedCount(street) {
return Object.keys(this.croppedImages).filter(key => {
const item = this.imagesData.find(img => img.street === street && key.startsWith(img.code));
return !!item;
}).length;
},
exportStreetImages(street) {
const keys = Object.keys(this.croppedImages).filter(key => {
const code = key.split('-')[0];
const item = this.imagesData.find(img => img.street === street && img.code === code);
return !!item;
});
if (keys.length === 0) {
message.warning(`巷道 ${street} 没有裁剪的图片可以导出`);
return;
}
this.createZipFile(keys, `${street}_cropped.zip`);
message.success(`已导出巷道 ${street} 的 ${keys.length} 个裁剪图片`);
},
async exportAllCroppedImages() {
const keys = Object.keys(this.croppedImages);
if (keys.length === 0) {
message.warning('没有裁剪的图片可以导出');
return;
}
this.createZipFile(keys, `cropped_images_${Date.now()}.zip`);
message.success(`已导出 ${keys.length} 个裁剪图片`);
},
async createZipFile(keys, fileName) {
const zip = new JSZip();
for (const key of keys) {
const blob = this.croppedImages[key];
const arrayBuffer = await this.blobToArrayBuffer(blob);
zip.file(`${key}.png`, arrayBuffer);
}
const zipBlob = await zip.generateAsync({ type: 'blob' });
this.downloadFile(zipBlob, fileName);
},
blobToArrayBuffer(blob) {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.readAsArrayBuffer(blob);
});
},
downloadFile(blob, fileName) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fileName;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
},
beforeDestroy() {
if (this.templateMat) {
this.templateMat.delete();
}
}
};
</script>
<style scoped>
.page-container {
padding: 24px;
background: #f0f2f5;
min-height: 100vh;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 32px;
border-radius: 16px;
color: white;
box-shadow: 0 8px 24px rgba(102, 126, 234, 0.25);
}
.header-actions {
display: flex;
gap: 12px;
}
.header-content .page-title {
margin: 0 0 12px 0;
font-size: 32px;
font-weight: 700;
display: flex;
align-items: center;
gap: 12px;
}
.page-desc {
margin: 0;
font-size: 16px;
opacity: 0.9;
}
.instruction-card {
margin-bottom: 24px;
}
.empty-container {
padding: 80px 0;
text-align: center;
}
.instruction-card {
margin-bottom: 24px;
}
.tabs-container {
background: white;
border-radius: 12px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.street-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 20px;
padding: 16px;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
border-radius: 8px;
}
.street-info {
font-weight: 500;
color: #333;
}
.code-section {
margin-bottom: 32px;
}
.code-header {
margin-bottom: 16px;
}
.code-title {
font-size: 16px;
font-weight: 600;
color: #1890ff;
}
.image-card {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
transition: all 0.3s ease;
}
.image-card:hover {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
transform: translateY(-2px);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.card-title {
font-weight: 600;
font-size: 14px;
}
.card-image {
position: relative;
background: #f5f5f5;
height: 400px;
overflow: hidden;
}
.card-image img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.card-image canvas {
position: absolute;
top: 0;
left: 0;
cursor: crosshair;
}
.card-info {
padding: 12px 16px;
border-bottom: 1px solid #f0f0f0;
background: #fafafa;
}
.info-text {
font-size: 12px;
color: #8c8c8c;
}
.card-actions {
padding: 12px 16px;
display: flex;
justify-content: center;
}
.card-preview {
padding: 12px 16px;
border-top: 1px solid #f0f0f0;
background: #fafafa;
}
.preview-label {
font-size: 12px;
color: #8c8c8c;
margin-bottom: 8px;
}
.card-preview img {
width: 100%;
height: 100px;
object-fit: contain;
border-radius: 4px;
border: 1px solid #d9d9d9;
}
.template-preview {
text-align: center;
}
.template-preview img {
max-width: 100%;
border-radius: 8px;
}
.template-info {
margin-top: 16px;
text-align: center;
}
::v-deep .ant-tabs-nav {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 12px 16px;
border-radius: 8px 8px 0 0;
}
::v-deep .ant-tabs-bar {
border-bottom: none;
margin-bottom: 24px;
}
::v-deep .ant-tabs-tab {
font-size: 16px;
font-weight: 500;
padding: 8px 24px;
border-radius: 6px;
transition: all 0.3s ease;
}
::v-deep .ant-tabs-tab:hover {
color: #1890ff;
background: rgba(24, 144, 255, 0.1);
}
::v-deep .ant-tabs-tab-active {
background: #1890ff;
color: white;
}
::v-deep .ant-tabs-ink-bar {
display: none;
}
::v-deep .ant-divider-horizontal.ant-divider-with-text-left {
margin: 24px 0 16px;
}
</style>
Loading…
Cancel
Save