Files
opencatd-open/frontend/src/components/LineSegmentFlow_dot.vue
Sakurasan fca67cae40 fix UI
2025-04-17 23:09:57 +08:00

243 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>