243 lines
11 KiB
Vue
243 lines
11 KiB
Vue
<template>
|
||
<!-- 组件根元素:相对定位,设置最大宽度、外边距、宽高比、背景渐变、内边距、圆角、阴影和溢出隐藏 -->
|
||
<div
|
||
class="relative w-full max-w-4xl mx-auto my-10 aspect-[4/3] bg-gradient-to-br from-slate-50 to-orange-50 p-4 rounded-lg shadow-md overflow-hidden">
|
||
|
||
<!-- 中心图标容器 -->
|
||
<div ref="centerElement" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
|
||
<!-- 中心图标本身:设置宽高、圆角、Flex 布局居中、背景模糊效果 -->
|
||
<div class="w-10 h-10 md:w-12 md:h-12 rounded-full flex items-center justify-center backdrop-blur-md">
|
||
<!-- 中心图标图片 -->
|
||
<img src="../assets/logo.svg" alt="Center Logo" class="w-full h-full object-contain">
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 左侧图标列 -->
|
||
<div
|
||
class="absolute top-0 left-0 h-full flex flex-col justify-around items-center py-4 md:py-8 px-2 md:px-4 z-10">
|
||
<!-- 遍历左侧图标数据 -->
|
||
<div v-for="icon in leftIcons" :key="icon.id" :ref="el => { if (el) iconRefs[icon.id] = el }"
|
||
class="w-8 h-8 md:w-10 md:h-10 flex items-center justify-center">
|
||
<!-- 图标图片或占位符 -->
|
||
<img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
|
||
<div v-else
|
||
class="w-full h-full rounded bg-gray-300 flex items-center justify-center text-xs text-gray-600">?
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧图标列 -->
|
||
<div
|
||
class="absolute top-0 right-0 h-full flex flex-col justify-around items-center py-4 md:py-8 px-2 md:px-4 z-10">
|
||
<!-- 遍历右侧图标数据 -->
|
||
<div v-for="icon in rightIcons" :key="icon.id" :ref="el => { if (el) iconRefs[icon.id] = el }"
|
||
class="w-8 h-8 md:w-10 md:h-10 flex items-center justify-center">
|
||
<!-- 图标图片或占位符 -->
|
||
<img v-if="icon.img" :src="icon.img" :alt="icon.name" class="w-full h-full object-contain">
|
||
<div v-else
|
||
class="w-full h-full rounded bg-gray-300 flex items-center justify-center text-xs text-gray-600">?
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SVG 画布,用于绘制线条和动画 -->
|
||
<svg class="absolute inset-0 w-full h-full z-0" ref="svgCanvas">
|
||
<defs>
|
||
<!-- 这里可以定义 SVG 渐变或标记 (marker) -->
|
||
</defs>
|
||
|
||
<!-- 只有当中心点和图标坐标都计算好后才开始绘制 -->
|
||
<g v-if="centerCoords && Object.keys(iconCoords).length >= (leftIcons.length + rightIcons.length)">
|
||
<!-- 绘制左侧图标的线条和动画 -->
|
||
<template v-for="icon in leftIcons" :key="'line-' + icon.id">
|
||
<!-- 绘制静态连接线 (图标到中心) -->
|
||
<path :id="'path-visual-' + icon.id" :d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'left')"
|
||
stroke="#E5E7EB" stroke-width="1" fill="none" />
|
||
<!-- 创建一个用于动画的小圆点 (从图标到中心) -->
|
||
<circle cx="0" cy="0" r="2.5" :fill="icon.color || '#DB2777'">
|
||
<!-- 定义动画:让圆点沿着指定路径移动 (图标到中心) -->
|
||
<animateMotion :dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
|
||
repeatCount="indefinite" fill="freeze"
|
||
:path="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'left', 'toCenter')"
|
||
rotate="auto" />
|
||
</circle>
|
||
</template>
|
||
|
||
<!-- 绘制右侧图标的线条和动画 -->
|
||
<template v-for="icon in rightIcons" :key="'line-' + icon.id">
|
||
<!-- 绘制静态连接线 (图标到中心) -->
|
||
<path :id="'path-visual-' + icon.id" :d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'right')"
|
||
stroke="#E5E7EB" stroke-width="1" fill="none" />
|
||
<!-- 创建一个用于动画的小圆点 (从中心到图标) -->
|
||
<circle cx="0" cy="0" r="2.5" :fill="icon.color || '#1D4ED8'">
|
||
<!-- 定义动画:让圆点沿着指定路径移动 (中心到图标) -->
|
||
<animateMotion :dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
|
||
repeatCount="indefinite" fill="freeze"
|
||
:path="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'right', 'fromCenter')"
|
||
rotate="auto" />
|
||
</circle>
|
||
</template>
|
||
</g>
|
||
</svg>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, onMounted, onUnmounted, nextTick, reactive } from 'vue';
|
||
|
||
// --- 图标数据 ---
|
||
// (保持你的图标数据不变)
|
||
const leftIcons = ref([
|
||
{ id: 'web', name: 'Web', img: 'https://img.icons8.com/?size=100&id=38536&format=png&color=000000', color: '#DB4437' },
|
||
{ id: 'iphone', name: 'iPhone', img: 'https://img.icons8.com/?size=100&id=ZwGNoFXGbt9n&format=png&color=000000', color: '#00AB6C' },
|
||
{ id: 'mac', name: 'Mac', img: 'https://img.icons8.com/?size=100&id=RHxDgbKmJhUD&format=png&color=000000', color: '#1DB954' },
|
||
]);
|
||
const rightIcons = ref([
|
||
{ id: 'openai', name: 'OpenAI', img: 'https://img.icons8.com/?size=100&id=FBO05Dys9QCg&format=png&color=000000', color: '#E4405F' },
|
||
{ id: 'claude', name: 'Claude', img: 'https://img.icons8.com/?size=100&id=H5H0mqCCr5AV&format=png&color=000000', color: '#229ED9' },
|
||
{ id: 'gemini', name: 'Gemini', img: 'https://img.icons8.com/?size=100&id=eoxMN35Z6JKg&format=png&color=000000', color: '#FF6600' },
|
||
{ id: 'azure', name: 'Azure', img: 'https://img.icons8.com/?size=100&id=VLKafOkk3sBX&format=png&color=000000', color: '#007FFF' }, // Changed color slightly
|
||
{ id: 'bedrock', name: 'BedRock', img: 'https://img.icons8.com/?size=100&id=saSupsgVcmJe&format=png&color=000000', color: '#FF9900' }, // Changed color slightly
|
||
{ id: 'google', name: 'Google', img: 'https://img.icons8.com/color/48/google-logo.png', color: '#DB4437' },
|
||
{ id: 'deepseek', name: 'DeepSeek', img: 'https://img.icons8.com/?size=100&id=YWOidjGxCpFW&format=png&color=000000', color: '#3a7dd5' }, // Changed color slightly
|
||
{ id: 'github', name: 'GitHub', img: 'https://img.icons8.com/ios-filled/50/000000/github.png', color: '#111111' },
|
||
]);
|
||
// --- 结束图标数据 ---
|
||
|
||
const svgCanvas = ref(null);
|
||
const centerElement = ref(null);
|
||
const iconRefs = reactive({});
|
||
const centerCoords = ref(null);
|
||
const iconCoords = reactive({});
|
||
|
||
const getElementCenterCoords = (element) => {
|
||
if (!element || !svgCanvas.value) return null;
|
||
const svgRect = svgCanvas.value.getBoundingClientRect();
|
||
const elemRect = element.getBoundingClientRect();
|
||
return {
|
||
x: elemRect.left + elemRect.width / 2 - svgRect.left,
|
||
y: elemRect.top + elemRect.height / 2 - svgRect.top,
|
||
};
|
||
};
|
||
|
||
const updateCoordinates = () => {
|
||
if (!centerElement.value || !svgCanvas.value) return;
|
||
centerCoords.value = getElementCenterCoords(centerElement.value);
|
||
const allIcons = [...leftIcons.value, ...rightIcons.value];
|
||
let coordsFound = 0;
|
||
allIcons.forEach(icon => {
|
||
const element = iconRefs[icon.id];
|
||
if (element) {
|
||
iconCoords[icon.id] = getElementCenterCoords(element);
|
||
if (iconCoords[icon.id]) {
|
||
coordsFound++;
|
||
}
|
||
} else {
|
||
console.warn(`找不到图标 ${icon.id} 的 DOM 元素引用。`);
|
||
}
|
||
});
|
||
if (coordsFound < allIcons.length) {
|
||
// console.warn("部分图标坐标未能成功计算。"); // 可以取消注释以进行调试
|
||
}
|
||
};
|
||
|
||
/**
|
||
* 计算静态视觉连接线的 SVG 路径 (总是从图标到中心)
|
||
* @param {object} iconCoord 图标坐标 {x, y}
|
||
* @param {object} centerCoord 中心坐标 {x, y}
|
||
* @param {'left' | 'right'} side 图标在哪一侧
|
||
* @returns {string} SVG path 'd' 属性字符串
|
||
*/
|
||
const calculatePathForVisual = (iconCoord, centerCoord, side) => {
|
||
if (!iconCoord || !centerCoord) return '';
|
||
const { x: startX, y: startY } = iconCoord; // 起点是图标
|
||
const { x: endX, y: endY } = centerCoord; // 终点是中心
|
||
|
||
// 控制点计算逻辑 (与原版一致,确保曲线形状不变)
|
||
const controlX = (side === 'left')
|
||
? startX + (endX - startX) * 0.6
|
||
: startX - (startX - endX) * 0.6;
|
||
const controlY = startY; // 控制点 Y 与起点(图标)对齐
|
||
|
||
return `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;
|
||
};
|
||
|
||
/**
|
||
* 计算动画运动的 SVG 路径
|
||
* @param {object} iconCoord 图标坐标 {x, y}
|
||
* @param {object} centerCoord 中心坐标 {x, y}
|
||
* @param {'left' | 'right'} side 图标在哪一侧
|
||
* @param {'toCenter' | 'fromCenter'} direction 动画方向
|
||
* @returns {string} SVG path 'd' 属性字符串
|
||
*/
|
||
const calculatePathForAnimation = (iconCoord, centerCoord, side, direction) => {
|
||
if (!iconCoord || !centerCoord) return '';
|
||
|
||
let startX, startY, endX, endY;
|
||
let controlX, controlY;
|
||
|
||
if (direction === 'fromCenter') {
|
||
// --- 动画从中心开始 ---
|
||
startX = centerCoord.x;
|
||
startY = centerCoord.y;
|
||
endX = iconCoord.x;
|
||
endY = iconCoord.y;
|
||
|
||
// 控制点计算:
|
||
// 为了使曲线形状看起来与 'toCenter' 类似但方向相反
|
||
// 我们将控制点放在靠近中心(起点)的位置,并使其 Y 坐标与终点(图标)对齐
|
||
controlX = startX + (endX - startX) * 0.4; // X 轴方向上,控制点靠近起点 (中心)
|
||
controlY = endY; // Y 轴方向上,与终点 (图标) 对齐
|
||
|
||
} else { // direction === 'toCenter' (默认)
|
||
// --- 动画从图标开始 ---
|
||
startX = iconCoord.x;
|
||
startY = iconCoord.y;
|
||
endX = centerCoord.x;
|
||
endY = centerCoord.y;
|
||
|
||
// 控制点计算 (与视觉线一致)
|
||
controlX = (side === 'left')
|
||
? startX + (endX - startX) * 0.6
|
||
: startX - (startX - endX) * 0.6;
|
||
controlY = startY; // Y 轴方向上,与起点 (图标) 对齐
|
||
}
|
||
|
||
return `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;
|
||
};
|
||
|
||
|
||
// --- 生命周期钩子 ---
|
||
let resizeObserver;
|
||
|
||
onMounted(async () => {
|
||
await nextTick();
|
||
updateCoordinates();
|
||
resizeObserver = new ResizeObserver(updateCoordinates); // 简化:直接传递函数
|
||
if (svgCanvas.value?.parentElement) {
|
||
resizeObserver.observe(svgCanvas.value.parentElement);
|
||
} else {
|
||
console.warn("无法找到用于 ResizeObserver 的父元素。");
|
||
}
|
||
});
|
||
|
||
onUnmounted(() => {
|
||
if (resizeObserver) {
|
||
resizeObserver.disconnect();
|
||
}
|
||
});
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
img {
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
background: #3a7dd5;
|
||
}
|
||
.flex-col.justify-around {
|
||
justify-content: space-around;
|
||
}
|
||
|
||
</style> |