hookehuyr

feat(动画): 添加贝塞尔曲线动画路径组件

新增了一个基于SVG的贝塞尔曲线动画路径组件,支持逐步展示连接点之间的路径,并带有动画效果和箭头指示方向。同时,更新了路由配置以支持新页面,并添加了less依赖以支持样式预处理。
......@@ -28,6 +28,7 @@
"@vueuse/core": "^13.0.0",
"autoprefixer": "^10.4.19",
"axios": "^1.8.4",
"less": "^4.2.2",
"postcss": "^8.4.35",
"qs": "^6.14.0",
"tailwindcss": "^3.4.1",
......@@ -1858,6 +1859,18 @@
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
"dev": true
},
"node_modules/copy-anything": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-2.0.6.tgz",
"integrity": "sha512-1j20GZTsvKNkc4BY3NpMOM8tt///wY3FpIzozTOFO2ffuZcV61nojHXVKIy3WM+7ADCy5FVhdZYHYDdgTU0yJw==",
"dev": true,
"dependencies": {
"is-what": "^3.14.1"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/copy-text-to-clipboard": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/copy-text-to-clipboard/-/copy-text-to-clipboard-3.2.0.tgz",
......@@ -2001,6 +2014,19 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/errno": {
"version": "0.1.8",
"resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz",
"integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==",
"dev": true,
"optional": true,
"dependencies": {
"prr": "~1.0.1"
},
"bin": {
"errno": "cli.js"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
......@@ -2362,6 +2388,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"dev": true,
"optional": true
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
......@@ -2401,6 +2434,32 @@
"node": ">= 0.4"
}
},
"node_modules/iconv-lite": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"optional": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/image-size": {
"version": "0.5.5",
"resolved": "https://registry.npmjs.org/image-size/-/image-size-0.5.5.tgz",
"integrity": "sha512-6TDAlDPZxUFCv+fuOkIoXT/V/f3Qbq8e37p+YOiYrUv3v9cc3/6x78VdfPgFVaB9dZYeLUfKgHRebpkm/oP2VQ==",
"dev": true,
"optional": true,
"bin": {
"image-size": "bin/image-size.js"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/individual": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/individual/-/individual-2.0.0.tgz",
......@@ -2477,6 +2536,12 @@
"node": ">=0.12.0"
}
},
"node_modules/is-what": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-3.14.1.tgz",
"integrity": "sha512-sNxgpk9793nzSs7bA6JQJGeIuRBQhAaNGG77kzYQgMkrID+lS6SlK07K5LaptscDlSaIgH+GPFzf+d75FVxozA==",
"dev": true
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
......@@ -2542,6 +2607,32 @@
"resolved": "https://registry.npmjs.org/keycode/-/keycode-2.2.1.tgz",
"integrity": "sha512-Rdgz9Hl9Iv4QKi8b0OlCRQEzp4AgVxyCtz5S/+VIHezDmrDhkp2N2TqBWOLz0/gbeREXOOiI9/4b8BY9uw2vFg=="
},
"node_modules/less": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/less/-/less-4.2.2.tgz",
"integrity": "sha512-tkuLHQlvWUTeQ3doAqnHbNn8T6WX1KA8yvbKG9x4VtKtIjHsVKQZCH11zRgAfbDAXC2UNIg/K9BYAAcEzUIrNg==",
"dev": true,
"dependencies": {
"copy-anything": "^2.0.1",
"parse-node-version": "^1.0.1",
"tslib": "^2.3.0"
},
"bin": {
"lessc": "bin/lessc"
},
"engines": {
"node": ">=6"
},
"optionalDependencies": {
"errno": "^0.1.1",
"graceful-fs": "^4.1.2",
"image-size": "~0.5.0",
"make-dir": "^2.1.0",
"mime": "^1.4.1",
"needle": "^3.1.0",
"source-map": "~0.6.0"
}
},
"node_modules/lilconfig": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
......@@ -2604,6 +2695,40 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/make-dir": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz",
"integrity": "sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==",
"dev": true,
"optional": true,
"dependencies": {
"pify": "^4.0.1",
"semver": "^5.6.0"
},
"engines": {
"node": ">=6"
}
},
"node_modules/make-dir/node_modules/pify": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz",
"integrity": "sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==",
"dev": true,
"optional": true,
"engines": {
"node": ">=6"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "5.7.2",
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
"dev": true,
"optional": true,
"bin": {
"semver": "bin/semver"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
......@@ -2635,6 +2760,19 @@
"node": ">=8.6"
}
},
"node_modules/mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
"dev": true,
"optional": true,
"bin": {
"mime": "cli.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
......@@ -2786,6 +2924,23 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/needle": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz",
"integrity": "sha512-6k0YULvhpw+RoLNiQCRKOl09Rv1dPLr8hHnVjHqdolKwDrdNyk+Hmrthi4lIGPPz3r39dLx0hsF5s40sZ3Us4Q==",
"dev": true,
"optional": true,
"dependencies": {
"iconv-lite": "^0.6.3",
"sax": "^1.2.4"
},
"bin": {
"needle": "bin/needle"
},
"engines": {
"node": ">= 4.4.x"
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
......@@ -2846,6 +3001,15 @@
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"dev": true
},
"node_modules/parse-node-version": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz",
"integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==",
"dev": true,
"engines": {
"node": ">= 0.10"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
......@@ -3102,6 +3266,13 @@
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"node_modules/prr": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz",
"integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==",
"dev": true,
"optional": true
},
"node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
......@@ -3286,6 +3457,20 @@
"rust-result": "^1.0.0"
}
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"optional": true
},
"node_modules/sax": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.1.tgz",
"integrity": "sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==",
"dev": true,
"optional": true
},
"node_modules/scule": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/scule/-/scule-1.3.0.tgz",
......@@ -3406,6 +3591,16 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/source-map": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"dev": true,
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
......@@ -3703,6 +3898,12 @@
"integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==",
"dev": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"dev": true
},
"node_modules/ufo": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.5.4.tgz",
......
......@@ -35,6 +35,7 @@
"@vueuse/core": "^13.0.0",
"autoprefixer": "^10.4.19",
"axios": "^1.8.4",
"less": "^4.2.2",
"postcss": "^8.4.35",
"qs": "^6.14.0",
"tailwindcss": "^3.4.1",
......
/*
* @Date: 2025-03-20 20:36:36
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-25 15:17:18
* @LastEditTime: 2025-03-28 09:39:27
* @FilePath: /mlaj/src/router/routes.js
* @Description: 路由地址映射配置
*/
......@@ -175,6 +175,12 @@ export const routes = [
meta: { title: 'test' },
},
{
path: '/animation',
name: 'animation',
component: () => import('../views/animation.vue'),
meta: { title: 'animation' },
},
{
path: '/upload_video',
name: 'upload_video',
component: () => import('../views/upload_video.vue'),
......
<!--
* @Date: 2025-03-28 09:23:04
* @LastEditors: hookehuyr hookehuyr@gmail.com
* @LastEditTime: 2025-03-28 13:26:37
* @FilePath: /mlaj/src/views/animation.vue
* @Description: 贝塞尔曲线动画路径组件
*
* 该组件实现了一个基于SVG的贝塞尔曲线动画效果:
* 1. 在画布上展示多个连接点
* 2. 点击"下一步"按钮可以逐步显示连接点之间的贝塞尔曲线路径
* 3. 路径带有动画效果和箭头指示方向
* 4. 已激活的路径段会以绿色高亮显示
* 5. 支持通过点击节点来激活路径
-->
<template>
<div class="animation-container">
<button class="next-button" @click="nextStep" :disabled="activeNodeIndex >= points.length - 1">下一步</button>
<svg width="1000" height="1000" viewBox="0 0 1000 1000">
<!-- 定义箭头标记 -->
<defs>
<marker
id="arrow-inactive"
viewBox="0 0 10 10"
refX="5"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#ccc" />
</marker>
<marker
id="arrow-active"
viewBox="0 0 10 10"
refX="5"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#4CAF50" />
</marker>
</defs>
<!-- 贝塞尔曲线路径 -->
<template v-for="(_, index) in points.slice(0, -1)" :key="index">
<path
v-show="activeNodeIndex === -1 || index > activeNodeIndex"
:d="calculatePathSegment(points[index], points[index + 1])"
fill="none"
:stroke="pathColor"
stroke-width="2"
stroke-dasharray="5,5"
class="path-animation"
marker-end="url(#arrow-inactive)"
/>
</template>
<!-- 高亮的路径段 -->
<path
v-for="(segment, index) in activePathSegments"
:key="index"
:d="segment"
fill="none"
stroke="#4CAF50"
stroke-width="2"
stroke-dasharray="10"
marker-end="url(#arrow-active)"
class="active-path-animation"
/>
<!-- 节点圆点 -->
<template v-for="(point, index) in points" :key="index">
<circle
:cx="point.x"
:cy="point.y"
r="6"
:class="['node', { 'node-active': activeNodeIndex >= index - 1 }]"
@click="activateNode(index)"
/>
</template>
</svg>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
// 定义节点坐标数组,每个节点包含x和y坐标
// 这些点将用于生成贝塞尔曲线的路径
const points = ref([
{ x: 250, y: 50 },
{ x: 10, y: 250 },
{ x: 400, y: 500 },
{ x: 50, y: 700 },
{ x: 400, y: 950 }
]);
// 当前激活的节点索引,初始值为-1表示没有节点被激活
const activeNodeIndex = ref(-1);
// 存储已激活的路径段数组,每个元素是一个SVG路径字符串
// 用于显示高亮的贝塞尔曲线路径
const activePathSegments = ref([]);
// 计算完整的贝塞尔曲线路径
// 使用三次贝塞尔曲线(Cubic Bezier)创建平滑的曲线效果
// 控制点的位置通过当前点和下一个点的中点来计算
const pathData = computed(() => {
const path = [];
points.value.forEach((point, index) => {
if (index === 0) {
path.push(`M ${point.x} ${point.y}`);
} else {
const prevPoint = points.value[index - 1];
const segment = calculatePathSegment(prevPoint, point);
path.push(segment.substring(segment.indexOf('C')));
}
});
return path.join(' ');
});
// 计算两点之间的贝塞尔曲线路径段
// @param startPoint - 起始点坐标 {x, y}
// @param endPoint - 结束点坐标 {x, y}
// @returns 返回SVG路径字符串
const calculatePathSegment = (startPoint, endPoint) => {
// 计算路径的方向向量
const dx = endPoint.x - startPoint.x;
const dy = endPoint.y - startPoint.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// 设置节点间的偏移距离(可以根据需要调整)
const offset = 0;
const verticalOffset = -20; // 添加垂直偏移量参数
// 计算单位向量
const unitX = dx / distance;
const unitY = dy / distance;
// 计算偏移后的起点和终点,根据路径方向调整垂直偏移
const adjustedStart = {
x: startPoint.x + unitX * offset,
y: startPoint.y + (dy > 0 ? -verticalOffset : verticalOffset)
};
const adjustedEnd = {
x: endPoint.x - unitX * offset,
y: endPoint.y + (dy > 0 ? verticalOffset : -verticalOffset)
};
// 计算控制点
const cpx1 = adjustedStart.x;
const cpy1 = adjustedStart.y + (adjustedEnd.y - adjustedStart.y) * 0.5;
const cpx2 = adjustedEnd.x;
const cpy2 = adjustedStart.y + (adjustedEnd.y - adjustedStart.y) * 0.5;
return `M ${adjustedStart.x} ${adjustedStart.y} C ${cpx1} ${cpy1}, ${cpx2} ${cpy2}, ${adjustedEnd.x} ${adjustedEnd.y}`;
};
// 未激活路径的颜色
// 使用浅灰色(#ccc)表示未激活状态
const pathColor = computed(() => {
return '#ccc';
});
// 处理节点点击事件,激活指定节点并更新路径
// @param index - 被点击节点的索引
// 点击节点时会激活该节点及其之前的所有节点和路径
const activateNode = (index) => {
activeNodeIndex.value = index;
activePathSegments.value = [];
// 重新计算所有激活的路径段
for (let i = 0; i < index; i++) {
const startPoint = points.value[i];
const endPoint = points.value[i + 1];
if (endPoint) {
const segment = calculatePathSegment(startPoint, endPoint);
activePathSegments.value.push(segment);
}
}
};
// 处理下一步按钮点击事件
// 激活下一个节点并创建新的高亮路径段
// 当到达最后一个节点时,按钮将被禁用
const nextStep = () => {
if (activeNodeIndex.value < points.value.length - 1) {
activeNodeIndex.value++;
const startPoint = points.value[activeNodeIndex.value];
const endPoint = points.value[activeNodeIndex.value + 1];
// 只有当存在下一个节点时才添加新的路径段
if (endPoint) {
const newSegment = calculatePathSegment(startPoint, endPoint);
// 保留之前的路径段,添加新的路径段
activePathSegments.value.push(newSegment);
}
// 如果是最后一个节点,直接将其设置为激活状态
if (activeNodeIndex.value === points.value.length - 2) {
activeNodeIndex.value = points.value.length - 1;
}
}
};
</script>
<style scoped>
.animation-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #f5f5f5;
}
.node {
fill: white;
stroke: #ccc;
stroke-width: 2;
cursor: pointer;
transition: all 0.3s ease;
}
.node-active {
fill: white;
stroke: #4CAF50;
}
.path-animation {
transition: all 0.5s ease;
}
.active-path-animation {
animation: dash 1.5s linear infinite;
}
@keyframes dash {
to {
stroke-dashoffset: -20;
}
}
.next-button {
position: absolute;
top: 20px;
right: 20px;
padding: 8px 16px;
background-color: #4CAF50;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.3s ease;
}
.next-button:hover {
background-color: #45a049;
}
.next-button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
</style>