这篇内容是在有道云旧笔记基础上重新整理的 Vue/Vite 工程实践 笔记。原始记录偏碎片化,这里补充了使用场景、阅读顺序和落地时需要注意的边界,方便以后在项目里快速复用。

适用场景 ​

  • Vue/Vite 项目中遇到配置、组件封装、构建或运行时问题时,作为排查入口。
  • 需要把一次性调试经验沉淀成团队内可复用的前端工程实践。
  • 迁移旧项目或升级依赖时,用来对照检查环境变量、插件和组件行为差异。

核心要点 ​

  • 正文以代码或命令片段为主,阅读时建议先确认目标问题,再挑选对应片段验证。
  • 保留了 7 段可直接参考的代码或命令,主要涉及 html、javascript、css,复制前需要按当前项目路径、版本和变量名做调整。
  • 文中保留了 1 个导出图片资源,适合和文字步骤一起对照查看。
  • 涉及命令行操作时,建议先在测试目录或测试环境执行,确认输出符合预期后再应用到生产环境。

Demo 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SVG 身体部位选择器</title>
<script src="https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
color: #2c3e50;
}
.container {
max-width: 1200px;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
}
header {
text-align: center;
margin-bottom: 30px;
width: 100%;
}
h1 {
font-size: 2.5rem;
color: #3498db;
margin-bottom: 10px;
text-shadow: 1px 1px 3px rgba(0,0,0,0.1);
}
.description {
font-size: 1.1rem;
color: #7f8c8d;
max-width: 800px;
margin: 0 auto;
}
.content {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 30px;
width: 100%;
}
.svg-container {
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
padding: 20px;
flex: 1;
min-width: 500px;
max-width: 700px;
overflow: auto;
}
.controls {
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
padding: 20px;
flex: 1;
min-width: 300px;
max-width: 400px;
}
.controls h2 {
color: #3498db;
margin-bottom: 20px;
text-align: center;
}
.body-part-list {
list-style: none;
}
.body-part-item {
display: flex;
align-items: center;
padding: 12px 15px;
margin-bottom: 10px;
background: #f8f9fa;
border-radius: 8px;
cursor: pointer;
transition: all 0.3s ease;
}
.body-part-item:hover {
background: #e8f4fc;
transform: translateY(-2px);
}
.body-part-item.selected {
background: #d1edff;
border-left: 4px solid #3498db;
}
.color-indicator {
width: 20px;
height: 20px;
border-radius: 50%;
margin-right: 15px;
background: rgba(53, 254, 255, 0.4);
}
.instructions {
margin-top: 30px;
background: white;
border-radius: 15px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1);
padding: 20px;
width: 100%;
max-width: 800px;
}
.instructions h2 {
color: #3498db;
margin-bottom: 15px;
text-align: center;
}
.instructions ul {
padding-left: 20px;
}
.instructions li {
margin-bottom: 10px;
line-height: 1.5;
}
.highlight {
background: #fff8e1;
padding: 2px 5px;
border-radius: 4px;
font-family: monospace;
}
@media (max-width: 900px) {
.content {
flex-direction: column;
align-items: center;
}
.svg-container, .controls {
min-width: 100%;
}
}
</style>
</head>
<body>
<div id="app" class="container">
<header>
<h1>身体部位选择器</h1>
<p class="description">点击下方身体部位或右侧列表项可查看SVG层级关系。文本始终显示在路径上方。</p>
</header>

<div class="content">
<div class="svg-container">
<svg width="100%" height="500" viewBox="0 0 1000 600">
<!-- 使用g元素循环渲染身体部位 -->
<g v-for="item in bodyParts" :key="item.uuid">
<!-- 先绘制路径 -->
<path
:d="item.position"
:fill="item.checked ? 'rgba(53, 254, 255, 0.4)' : 'transparent'"
stroke="#3498db"
stroke-width="2"
@click="toggleSelection(item)"
class="body-path"
/>
<!-- 后绘制文本(文本将显示在路径上方) -->
<text
:x="item.x"
:y="item.y"
text-anchor="middle"
dominant-baseline="middle"
font-size="20"
:fill="item.checked ? '#2c3e50' : '#7f8c8d'"
font-weight="bold"
@click="toggleSelection(item)"
class="body-text"
>
{{ item.name }}
</text>
</g>
</svg>
</div>

<div class="controls">
<h2>身体部位列表</h2>
<ul class="body-part-list">
<li
v-for="item in bodyParts"
:key="item.uuid"
@click="toggleSelection(item)"
:class="['body-part-item', {selected: item.checked}]"
>
<div class="color-indicator" :style="{opacity: item.checked ? 1 : 0.2}"></div>
<span>{{ item.name }}</span>
</li>
</ul>
</div>
</div>

<div class="instructions">
<h2>实现原理说明</h2>
<ul>
<li>在SVG中,元素的绘制顺序决定了它们的层级关系 - 后绘制的元素会显示在先绘制的元素之上。</li>
<li>要让文本显示在路径上层,只需确保<strong class="highlight">文本元素在路径元素之后定义</strong>。</li>
<li>在此示例中,我们使用Vue的v-for指令循环渲染身体部位,每个部位组内先绘制路径再绘制文本。</li>
<li>点击身体部位或列表项可以切换选中状态,选中的部位会以半透明青色显示。</li>
</ul>
</div>
</div>

<script>
new Vue({
el: '#app',
data: {
bodyParts: [
{ name: '头部', uuid: '1', x: 500, y: 100, position: 'M450,100 C450,50 550,50 550,100 C550,150 450,150 450,100 Z', checked: false },
{ name: '躯干', uuid: '2', x: 500, y: 280, position: 'M450,180 L450,380 550,380 550,180 Z', checked: false },
{ name: '左臂', uuid: '3', x: 350, y: 280, position: 'M450,200 L350,200 300,380 400,380 Z', checked: false },
{ name: '右臂', uuid: '4', x: 650, y: 280, position: 'M550,200 L650,200 700,380 600,380 Z', checked: false },
{ name: '左腿', uuid: '5', x: 430, y: 480, position: 'M450,380 L430,380 420,580 450,580 Z', checked: false },
{ name: '右腿', uuid: '6', x: 570, y: 480, position: 'M550,380 L570,380 580,580 550,580 Z', checked: false }
]
},
methods: {
toggleSelection(item) {
item.checked = !item.checked;
}
}
});
</script>
</body>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<div class="part-pain-body">
<div class="image-body">
<img v-if="data.url" class="img" :src="data.url">
<svg class="image-svg" x="0" y="0" viewBox="0 0 1340 600" xmlns="http://www.w3.org/2000/svg" @click="handlePainBodyClick">
<g v-if="bodyPart">
<g v-for="item in bodyPart" :key="item.name" :uuid="item.uuid">
<path :fill="item.checked ? 'rgba(53, 254, 255, 0.4)' : 'transparent'" :d="item.position" />
<text
:x="item.x"
:y="item.y"
text-anchor="middle"
dominant-baseline="middle"
font-size="50"
:fill="`rgba(0,0,0, .${item.checked ? '8' : '6'})`"
>
{{ item.name }}
</text>
</g>
<!-- 区域内的文字 -->
</g>
</svg>
</div>
</div>

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
const handlePainBodyClick = (event) => {
const selectedId = getAttributesFromEvent(event, 'uuid');

if (selectedId && isNonEmptyArray(bodyPart.value)) {
const selectedArea = bodyPart.value.find(item => item.uuid === selectedId);
if (selectedArea.name) {
if (props.data.choiceType === 'single') {
// 单选模式,取消其他选中状态
bodyPart.value.forEach((item) => {
if (item.uuid !== selectedId) {
item.checked = false;
}
});
}
// 更改状态
selectedArea.checked = !selectedArea.checked;
}
console.log(`%c 选择部位 ${selectedArea.checked ? '已选中' : '取消选择'} => [ ${selectedArea.name}, ${selectedId} ]-66`, `font-size:14px; background:${selectedArea.checked ? '#07c160' : '#cf222e'}; color:#fff;`);
}
};

bodyPart = [
{
"index": null,
"position": "M556 1 L762 1 L762 191 L556 191 Z",
"name": "上腹"
},
{
"index": null,
"position": "M762,381.775554 L762,593.775554 L556,593.775554 L556,381.775554 L762,381.775554 Z",
"name": "下腹"
},
{
"index": null,
"position": "M977,0.975445416 C953.823172,104.0299582 943.289838,198.9966072 945.4,285.875392 C913.788534,285.862892 852.455202,285.767254 761.4,285.588478 L761.105582,0.975445416 L977,0.975445416 Z",
"name": "右上腹"
},
{
"index": null,
"position": "M945.4,285.588478 C940.254174,309.862826 961.918126,359.333334 1010.391858,434 C1058.86559,508.666666 1090.401638,562.258518 1105,594.775554 L761.4,594.775554 L761.4,285.875392 L945.4,285.588478 Z",
"name": "右下腹"
},
{
"index": null,
"position": "M365.505582,0.975445416 C392.78637,106.867884 403.980652,201.738894 399.088432,285.588478 C417.879604,285.42308 470.286768,285.42308 556.309928,285.588478 L556.014732,0.975445416 L365.505582,0.975445416 Z",
"name": "左上腹"
},
{
"index": null,
"position": "M398.654212,285.588478 C395.447922,321.720552 373.801178,371.19106 333.713982,434 C293.626784,496.80894 262.4921,550.40079 240.309928,594.775554 L556.309928,594.775554 L556.309928,285.875392 L398.654212,285.588478 Z",
"name": "左下腹"
},
{
"index": null,
"position": "M762,191 L762,380.720164 L556,380.720164 L556,191 L762,191 Z",
"name": "脐周"
}
]

// 计算中心点

item.bodyPart.forEach((part) => {
part.uuid = uuid();
part.checked = false;
const pts = parsePathToPoints(part.position);
const [cx, cy] = getCentroid(pts);
part.x = cx;
part.y = cy;
});


/**
* 解析 SVG path(仅支持 M/L/Z)为点坐标数组
* @param {string} d - path 的 d 属性
* @returns {Array<[number, number]>}
*/
export const parsePathToPoints = (d) => {
if (!d || typeof d !== 'string') return []; // d为空或非字符串直接返回空数组

const tokens = d.match(/[MLZ]|-?\d*\.?\d+/gi);
if (!tokens || tokens.length === 0) return []; // 没有匹配到有效命令或数字,返回空数组

const points = [];
let i = 0;

while (i < tokens.length) {
const token = tokens[i++];
if (/^[ML]$/i.test(token)) continue; // M 或 L 忽略命令本身
if (/^Z$/i.test(token)) break; // 遇到 Z 结束

const x = Number.parseFloat(token);
const y = Number.parseFloat(tokens[i++]);
if (Number.isNaN(x) || Number.isNaN(y)) continue; // 忽略无效坐标
points.push([x, y]);
}

return points;
};

/**
* 计算多边形的质心 (centroid)
* @param {Array<[number, number]>} points
* @returns {[number, number]} [cx, cy]
*/
export const getCentroid = (points) => {
let area = 0;
let cx = 0;
let cy = 0;
const n = points.length;

for (let i = 0; i < n; i++) {
const [x0, y0] = points[i];
const [x1, y1] = points[(i + 1) % n];
const cross = x0 * y1 - x1 * y0;
area += cross;
cx += (x0 + x1) * cross;
cy += (y0 + y1) * cross;
}

area *= 0.5;
cx /= (6 * area);
cy /= (6 * area);

return [cx, cy];
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
.part-pain-body {
padding: 80px 50px 100px;
@if $PLATFORM == 'SNAI-PRO' {
padding: 50px 120px 60px;
}
.image-body {
position: relative;
&,
.img {
width: 100%;
}
.img {
height: auto;
}
}
.image-svg {
position: absolute;
inset: 0;
&,
.image-map,
.image-g {
width: 100%;
height: 100%;
}
}
}

实践检查清单 &#8203;

  • 先确认 Vue、Vite、Node、包管理器版本,再判断示例是否需要按当前工程结构调整。
  • 涉及构建或插件配置时,建议同时验证开发环境和生产构建结果。
  • 涉及 UI 或浏览器行为时,至少在桌面端和移动端各验证一次关键路径。

复盘小结 &#8203;

这篇笔记更适合作为问题排查或方案落地前的速查材料。后续如果在真实项目中再次遇到同类问题,可以继续把具体版本、报错信息、最终取舍和验证结果补到对应小节里,让它从代码片段逐步沉淀成完整方案。