|
|
|
|
@ -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>
|