引言
最近遇到一个前端问题,期望根据表格数据生成(阻抗电路图)图片,生成条件是判断数据类型,进线与非进线,生成后以图片的形式传递给后端,后端拼接到excel中,于是想到了svg拆分拼接元素,动态生成图片,如图:
需要拼接的要素:
指令拼接后:
核心功能实现
1. SVG文件加载机制
该工具采用XMLHttpRequest异步加载SVG文件,并将其缓存在内存中以供后续使用。主要实现如下:
const svgCache = {
'1': '',
'2': '',
'3': ''
};
function loadSVGsWithXHR() {
for (let i = 1; i <= 3; i++) {
const xhr = new XMLHttpRequest();
xhr.open('GET', `SVG/${i}.svg`, true);
xhr.responseType = 'text';
xhr.onload = function() {
if (xhr.status === 200) {
svgCache[i] = xhr.responseText;
// ... 处理加载状态
}
};
xhr.send();
}
}
2. SVG拼接算法
拼接算法的核心思路是:
- 根据用户输入的模式(如"2,3")确定上下SVG的数量
- 动态计算每个SVG元素的位置
- 创建中间连接线
- 组合所有元素成为最终SVG
关键实现包括:
2.1 位置计算逻辑
// 上方元素位置计算
const leftCount = Math.floor(topCount / 2);
const rightCount = topCount - leftCount;
const leftWidth = isLargeNumber ? (580 - 163) : 400;
const leftSpacing = leftWidth / (leftCount + 1);
2.2 元素克隆与变换
const topGroup = svg1Group.cloneNode(true);
topGroup.setAttribute("transform", `translate(${xPos}, 8) scale(0.65)`);
mainGroup.appendChild(topGroup);
3. 中间连接线的实现
中间连接线由多个SVG基本元素组成:
- 左右两条主线
- 中间断开的连接器
- 装饰性圆形和矩形
// 左侧长线
const leftLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
leftLine.setAttribute("x1", "50");
leftLine.setAttribute("y1", "180");
leftLine.setAttribute("x2", "590");
leftLine.setAttribute("y2", "180");
4. 导出功能
工具提供两种导出方式:
- 下载SVG文件
- 上传到后端服务器
4.1 下载实现
function downloadSVG() {
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svgEl);
svgString = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + svgString;
const blob = new Blob([svgString], {type: 'image/svg+xml'});
// ... 创建下载链接
}
4.2 上传实现
function uploadSVGToBackend() {
const formData = new FormData();
formData.append('svg', new Blob([svgString], {type: 'image/svg+xml'}));
formData.append('pattern', patternInput);
fetch('/api/upload-svg', {
method: 'POST',
body: formData
});
}
用户界面设计
1. 响应式布局
工具采用Flexbox布局,确保在不同屏幕尺寸下都能良好显示:
.container {
display: flex;
flex-direction: column;
align-items: center;
max-width: 1200px;
margin: 0 auto;
}
2. 状态反馈
通过状态提示区域实时反馈操作结果:
.status {
color: #0d6efd;
margin: 15px 0;
text-align: center;
padding: 10px;
background-color: #f8f9fa;
}
技术要点总结
- SVG操作:使用DOM API动态创建和操作SVG元素
- 异步加载:采用XMLHttpRequest异步加载SVG资源
- 动态布局:根据输入参数动态计算元素位置
- 文件处理:实现SVG的序列化、下载和上传功能
- 错误处理:完善的错误处理和用户反馈机制
优化(我懒没做系列)
- 考虑添加SVG预览功能
- 实现更多样化的拼接模式
- 添加SVG编辑功能
- 优化大量元素时的性能
- 增加更多自定义选项(如颜色、大小等)
这个只是html的示例,后续我封装的vue3的模块组件,
使用方式
组件代码如下:
<template>
<div class="svg-combiner">
<div class="loading-container">
<n-card :bordered="false" size="small" style="width: 400px">
<template #header>
<div class="progress-header p-2">
{{ statusMessage }}
</div>
</template>
<div class="pr-4">
<n-progress
type="line"
:percentage="progress"
:processing="isProcessing"
:indicator-placement="'inside'"
:height="24"
/>
</div>
<div class="progress-detail">{{ progressDetail }}</div>
</n-card>
</div>
<div class="svg-container" style="display: none">
<div id="result-svg" ref="resultSvg"></div>
</div>
</div>
</template>
<script>
import { http } from "@/utils/http";
import { storageLocal, downloadByData } from "@pureadmin/utils";
import { uploadFile } from "@/utils/file";
export default {
name: "SvgCombiner",
props: {
topCount: {
type: Number,
required: true,
validator: (value) => value >= 0 && value <= 99,
},
bottomCount: {
type: Number,
required: true,
validator: (value) => value >= 0 && value <= 99,
},
transformerEntityList: {
type: Array,
required: true,
},
},
data() {
return {
svgCache: {
1: "",
2: "",
3: "",
},
SvgUrl: "",
statusMessage: "正在处理",
progressDetail: "",
progress: 0,
isProcessing: true,
loadedCount: 0,
};
},
emits: ["close"],
mounted() {
this.loadSVGsWithXHR();
},
watch: {
topCount: {
handler() {
this.generateAndUpload();
},
},
bottomCount: {
handler() {
this.generateAndUpload();
},
},
},
methods: {
async updateProgress(step, detail = "") {
const steps = {
init: 0,
load: 20,
generate: 40,
convert: 60,
upload: 80,
complete: 100,
};
const currentProgress = this.progress;
const targetProgress = steps[step] || 0;
// 平滑过渡到目标进度
const step_size = 2;
const delay = 20;
for (let i = currentProgress; i <= targetProgress; i += step_size) {
this.progress = i;
await new Promise((resolve) => setTimeout(resolve, delay));
}
if (detail) {
this.progressDetail = detail;
}
},
async loadSVGsWithXHR() {
this.loadedCount = 0;
this.statusMessage = "加载SVG文件";
this.isProcessing = true;
await this.updateProgress("init", "准备加载文件...");
for (let i = 1; i <= 3; i++) {
try {
const response = await fetch(`/src/assets/PinSvg/${i}.svg`);
if (response.ok) {
this.svgCache[i] = await response.text();
this.loadedCount++;
await this.updateProgress("load", `已加载 ${this.loadedCount}/拼接文件`);
await new Promise((resolve) => setTimeout(resolve, 200)); // 每个文件加载后稍作停顿
if (this.loadedCount === 3) {
this.generateAndUpload();
}
} else {
throw new Error(`状态码: ${response.status}`);
}
} catch (error) {
console.error(`加载SVG ${i} 时出错:`, error);
this.statusMessage = "加载失败";
this.progressDetail = `加载SVG/${i}.svg 时出错,请检查文件是否存在`;
this.isProcessing = false;
setTimeout(() => {
this.$emit("close");
}, 2000);
}
}
},
async generateAndUpload() {
if (!this.svgCache["1"] || !this.svgCache["2"] || !this.svgCache["3"]) {
this.statusMessage = "文件未完全加载";
this.progressDetail = "请等待或刷新页面重试";
return;
}
try {
this.statusMessage = "生成图形";
this.isProcessing = true;
await new Promise((resolve) => setTimeout(resolve, 500)); // 开始生成前稍作停顿
await this.generateSVG();
await this.updateProgress("generate", "图形生成完成");
await new Promise((resolve) => setTimeout(resolve, 300)); // 生成完成后稍作停顿
await this.uploadSVGToBackend();
} catch (error) {
console.error("处理出错:", error);
this.statusMessage = "处理失败";
this.progressDetail = error.message;
this.isProcessing = false;
setTimeout(() => {
this.$emit("close");
}, 2000);
}
},
async generateSVG() {
if (!this.svgCache["1"] || !this.svgCache["2"] || !this.svgCache["3"]) {
this.statusMessage = "SVG文件未完全加载,请等待或刷新页面";
return;
}
try {
const resultContainer = this.$refs.resultSvg;
resultContainer.innerHTML = "";
// 创建SVG元素
const combinedSvg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
combinedSvg.setAttribute("xmlns", "http://www.w3.org/2000/svg");
combinedSvg.setAttribute("width", "100%");
combinedSvg.setAttribute("height", "100%");
combinedSvg.setAttribute("viewBox", "0 0 1200 500");
combinedSvg.setAttribute("id", "combined-svg");
// 添加样式
const styleElement = document.createElementNS("http://www.w3.org/2000/svg", "style");
styleElement.textContent = `
.cls-1 {
fill: none;
stroke: #000;
stroke-linecap: round;
stroke-miterlimit: 10;
stroke-width: 2.83px;
}
.cls-2 {
fill: #000;
}
`;
combinedSvg.appendChild(styleElement);
// 创建主组
const mainGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
mainGroup.setAttribute("id", "combined-group");
// 解析SVG字符串为文档
const parser = new DOMParser();
const svg1Doc = parser.parseFromString(this.svgCache["1"], "image/svg+xml");
const svg2Doc = parser.parseFromString(this.svgCache["2"], "image/svg+xml");
const svg3Doc = parser.parseFromString(this.svgCache["3"], "image/svg+xml");
// 获取各个SVG的主要内容组
const svg1Group = svg1Doc.querySelector("g > g").cloneNode(true);
const svg2Group = svg2Doc.querySelector("g > g").cloneNode(true);
const svg3Group = svg3Doc.querySelector("g > g").cloneNode(true);
// 放置上方元素
this.placeTopElements(mainGroup, svg1Group);
// 添加中间分隔线
this.addMiddleDivider(mainGroup);
// 放置下方元素
this.placeBottomElements(mainGroup, svg2Group);
// 添加主组到SVG
combinedSvg.appendChild(mainGroup);
// 添加到页面
resultContainer.appendChild(combinedSvg);
await this.updateProgress("generate", "正在生成图形");
await new Promise((resolve) => setTimeout(resolve, 500)); // 生成过程中的延时
} catch (error) {
console.error("生成SVG时出错:", error);
this.statusMessage = "生成SVG失败";
this.progressDetail = error.message;
this.isProcessing = false;
setTimeout(() => {
this.$emit("close");
}, 2000);
}
},
placeTopElements(mainGroup, svg1Group) {
if (this.topCount === 0) {
return;
}
if (this.topCount === 1) {
const topGroup = svg1Group.cloneNode(true);
let xPos = 400;
// if (this.bottomCount === 1) {
// xPos = 400;
// }
topGroup.setAttribute("transform", `translate(${xPos}, 8) scale(0.65)`);
mainGroup.appendChild(topGroup);
return;
}
const leftCount = Math.floor(this.topCount / 2);
const rightCount = this.topCount - leftCount;
const isLargeNumber = this.topCount > 20;
// 左侧元素
const leftWidth = isLargeNumber ? 580 - 163 : 400;
const leftSpacing = leftWidth / (leftCount + 1);
for (let i = 0; i < leftCount; i++) {
const topGroup = svg1Group.cloneNode(true);
const xPos = 150 + (i + 1) * leftSpacing;
topGroup.setAttribute("transform", `translate(${xPos}, 8) scale(0.65)`);
mainGroup.appendChild(topGroup);
}
// 右侧元素
const rightWidth = isLargeNumber ? 1050 - 630 : 400;
const rightSpacing = rightWidth / (rightCount + 1);
for (let i = 0; i < rightCount; i++) {
const topGroup = svg1Group.cloneNode(true);
const xPos = 630 + (i + 1) * rightSpacing;
topGroup.setAttribute("transform", `translate(${xPos}, 8) scale(0.65)`);
mainGroup.appendChild(topGroup);
}
},
placeBottomElements(mainGroup, svg2Group) {
if (this.bottomCount === 0) {
return;
}
if (this.bottomCount === 1) {
const bottomGroup = svg2Group.cloneNode(true);
let xPos = 800;
// if (this.topCount === 1) {
// xPos = 800;
// }
bottomGroup.setAttribute("transform", `translate(${xPos}, 182) scale(0.65)`);
mainGroup.appendChild(bottomGroup);
return;
}
const leftCount = Math.floor(this.bottomCount / 2);
const rightCount = this.bottomCount - leftCount;
const isLargeNumber = this.bottomCount > 20;
// 左侧元素
const leftWidth = isLargeNumber ? 580 - 172 : 400;
const leftSpacing = leftWidth / (leftCount + 1);
for (let i = 0; i < leftCount; i++) {
const bottomGroup = svg2Group.cloneNode(true);
const xPos = 150 + (i + 1) * leftSpacing;
bottomGroup.setAttribute("transform", `translate(${xPos}, 182) scale(0.65)`);
mainGroup.appendChild(bottomGroup);
}
// 右侧元素
const rightWidth = isLargeNumber ? 1050 - 630 : 400;
const rightSpacing = rightWidth / (rightCount + 1);
for (let i = 0; i < rightCount; i++) {
const bottomGroup = svg2Group.cloneNode(true);
const xPos = 630 + (i + 1) * rightSpacing;
bottomGroup.setAttribute("transform", `translate(${xPos}, 182) scale(0.65)`);
mainGroup.appendChild(bottomGroup);
}
},
addMiddleDivider(mainGroup) {
const middleGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
middleGroup.setAttribute("id", "middle-divider");
// 左侧长线
const leftLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
leftLine.setAttribute("x1", "50");
leftLine.setAttribute("y1", "180");
leftLine.setAttribute("x2", "590");
leftLine.setAttribute("y2", "180");
leftLine.setAttribute("stroke", "#000");
leftLine.setAttribute("stroke-width", "2.83");
leftLine.setAttribute("stroke-linecap", "round");
leftLine.setAttribute("class", "cls-2");
middleGroup.appendChild(leftLine);
// 右侧长线
const rightLine = document.createElementNS("http://www.w3.org/2000/svg", "line");
rightLine.setAttribute("x1", "620");
rightLine.setAttribute("y1", "180");
rightLine.setAttribute("x2", "1150");
rightLine.setAttribute("y2", "180");
rightLine.setAttribute("stroke", "#000");
rightLine.setAttribute("stroke-width", "2.83");
rightLine.setAttribute("stroke-linecap", "round");
rightLine.setAttribute("class", "cls-2");
middleGroup.appendChild(rightLine);
// 中间连接器
const middleConnector = document.createElementNS("http://www.w3.org/2000/svg", "g");
const pathsData = [
{ d: "M583,171l-8.34,8.5c-.51.52-.48,1.36.06,1.85l8.28,7.47", class: "cls-1" },
{ d: "M589,171l-8.34,8.5c-.51.52-.48,1.36.06,1.85l8.28,7.47", class: "cls-1" },
{ d: "M629,171l8.34,8.5c.51.52.48,1.36-.06,1.85l-8.28,7.47", class: "cls-1" },
{ d: "M623,171l8.34,8.5c.51.52.48,1.36-.06,1.85l-8.28,7.47", class: "cls-1" },
];
pathsData.forEach((data) => {
const path = document.createElementNS("http://www.w3.org/2000/svg", "path");
path.setAttribute("d", data.d);
path.setAttribute("class", data.class);
middleConnector.appendChild(path);
});
const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
rect.setAttribute("x", "590");
rect.setAttribute("y", "174");
rect.setAttribute("width", "32");
rect.setAttribute("height", "11");
rect.setAttribute("rx", "2.18");
rect.setAttribute("ry", "2.18");
rect.setAttribute("class", "cls-1");
middleConnector.appendChild(rect);
const circles = [
{ cx: "601", cy: "180" },
{ cx: "611", cy: "180" },
];
circles.forEach((data) => {
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
circle.setAttribute("cx", data.cx);
circle.setAttribute("cy", data.cy);
circle.setAttribute("r", "3");
circle.setAttribute("class", "cls-1");
middleConnector.appendChild(circle);
});
middleGroup.appendChild(middleConnector);
mainGroup.appendChild(middleGroup);
},
async uploadSVGToBackend() {
try {
const svgEl = document.getElementById("combined-svg");
if (!svgEl) {
throw new Error("找不到SVG元素");
}
this.statusMessage = "转换图像";
await this.updateProgress("convert", "正在转换为PNG格式");
await new Promise((resolve) => setTimeout(resolve, 300)); // 转换过程的延时
// 创建Canvas并转换图像
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
const svgRect = svgEl.getBoundingClientRect();
canvas.width = svgRect.width || 1100;
canvas.height = svgRect.height || 500;
const serializer = new XMLSerializer();
const svgString = serializer.serializeToString(svgEl);
const svgBlob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
const svgUrl = URL.createObjectURL(svgBlob);
const img = new Image();
img.src = svgUrl;
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
});
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0);
const pngBlob = await new Promise((resolve) => {
canvas.toBlob(resolve, "image/png");
});
URL.revokeObjectURL(svgUrl);
this.statusMessage = "上传文件";
await this.updateProgress("upload", "正在上传到服务器");
await new Promise((resolve) => setTimeout(resolve, 500)); // 上传过程的延时
const result = await uploadFile(pngBlob, {
fileName: "combined_image.png",
extraData: {
pattern: `${this.topCount},${this.bottomCount}`,
},
showSuccessMessage: false,
});
this.SvgUrl = result.data;
let infoKey = storageLocal().getItem("infoKey");
this.statusMessage = "生成Excel";
await this.updateProgress("upload", "正在生成Excel文件");
await new Promise((resolve) => setTimeout(resolve, 500)); // Excel生成过程的延时
const biz_content = {
depId: infoKey,
imagePath: result.data,
transformerEntityList: this.transformerEntityList
};
const res = await http.request("post", "/shbg/transformer/export/excel", {
data: JSON.stringify(biz_content),
responseType: "blob",
});
if (res.status === 200) {
const regex = /filename=([^;]+)/;
const match = res.headers["content-disposition"].match(regex);
if (match) {
const filename = decodeURIComponent(match[1]) || "数据列表.xlsx";
downloadByData(res.data, filename);
}
this.statusMessage = "处理完成";
await this.updateProgress("complete", "文件已下载");
this.isProcessing = false;
// 完成后等待较长时间再关闭
await new Promise((resolve) => setTimeout(resolve, 1300));
this.$emit("close");
} else {
throw new Error("导出Excel失败");
}
} catch (error) {
console.error("处理出错:", error);
this.statusMessage = "处理失败";
this.progressDetail = error.message;
this.isProcessing = false;
setTimeout(() => {
this.$emit("close");
}, 2000);
}
},
},
};
</script>
<style scoped>
.svg-combiner {
font-family: Arial, sans-serif;
display: flex;
justify-content: center;
align-items: center;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
}
.loading-container {
padding: 20px;
}
.progress-header {
font-size: 16px;
font-weight: 500;
margin-bottom: 8px;
}
.progress-detail {
margin-top: 8px;
font-size: 14px;
color: #999;
}
.svg-container {
display: none;
}
</style>
结语
这个SVG拼接工具展示了如何通过Web技术实现SVG的动态操作和组合。通过合理的架构设计和用户界面,为用户提供了简单易用的SVG处理工具。