Files
opencatd-open/frontend/src/components/LineSegmentFlow.vue
T
Sakurasan 15f17f4e8d frontend
2025-04-16 18:14:53 +08:00

277 lines
13 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] backdrop-blur-0 px-4 py-0 my-0 rounded-lg overflow-hidden ">
<!-- bg-gradient-to-br from-slate-50 to-orange-50 -->
<!-- 中心图标容器 -->
<div ref="centerElement" class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-20">
<!-- 中心图标本身 -->
<div class="w-10 h-10 md:w-16 md:h-16 rounded-full flex items-center justify-center backdrop-blur-md animate-bounce hover:cursor-alias" @click="$router.push('/dashboard')">
<img src="../assets/logo.svg" alt="Center Logo" class="rounded-full object-cover">
</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 lg:w-12 lg:h-12 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 lg:w-12 lg:h-12 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="'group-left-' + icon.id">
<!-- 1. 绘制静态背景连接线 (图标到中心) -->
<path :id="'path-visual-left-' + icon.id"
:d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'left')" stroke="#E5E7EB"
stroke-width="1" fill="none" />
<!-- 2. 绘制用于动画的覆盖在背景线上的短线段 -->
<path :id="'path-anim-left-' + icon.id"
:d="calculatePathForVisual(iconCoords[icon.id], centerCoords, 'left')"
:stroke="icon.color || '#DB2777'" stroke-width="2.5" fill="none" stroke-linecap="round"
:stroke-dasharray="`${dashLen} ${largeGap}`" :stroke-dashoffset="largeGap + dashLen">
<!-- 定义动画改变 stroke-dashoffset 使短线段移动 -->
<animate attributeName="stroke-dashoffset" :from="largeGap + dashLen" :to="0"
:dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
repeatCount="indefinite" fill="freeze" />
<!-- keyTimes values 可以更精细控制但这里 from/to 足够 -->
</path>
</template>
<!-- 绘制右侧图标的线条和动画 -->
<template v-for="icon in rightIcons" :key="'group-right-' + icon.id">
<!-- 1. 绘制静态背景连接线 (图标到中心) -->
<path :id="'path-visual-right-' + icon.id"
:d="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'right')" stroke="#E5E7EB"
stroke-width="1" fill="none" />
<!-- 2. 绘制用于动画的覆盖在背景线上的短线段 -->
<path :id="'path-anim-right-' + icon.id"
:d="calculatePathForAnimation(iconCoords[icon.id], centerCoords, 'right', 'fromCenter')"
:stroke="icon.color || '#1D4ED8'" stroke-width="2.5" fill="none" stroke-linecap="round"
:stroke-dasharray="`${dashLen} ${largeGap}`" :stroke-dashoffset="0">
<!-- 定义动画改变 stroke-dashoffset 使短线段移动 -->
<!-- 注意路径本身是从 Icon Center 绘制的为了让动画看起来是从 Center Icon -->
<!-- 我们需要让 dashoffset 0 (在Icon处开始) 变为 负的pattern长度 (移动到Center处结束) -->
<animate attributeName="stroke-dashoffset" :from="0" :to="-(largeGap + dashLen)"
:dur="`${4 + Math.random() * 4}s`" :begin="`${Math.random() * -5}s`"
repeatCount="indefinite" fill="freeze" />
</path>
</template>
</g>
</svg>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick, reactive, computed } from 'vue'; // 引入 computed
// --- 图标数据 (保持不变) ---
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: '#eac50c' },
{ 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' },
{ id: 'bedrock', name: 'BedRock', img: 'https://img.icons8.com/?size=100&id=saSupsgVcmJe&format=png&color=000000', color: '#FF9900' },
{ 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: '#4CAF50' },
{ id: 'github', name: 'GitHub', img: 'https://img.icons8.com/ios-filled/50/000000/github.png', color: '#333' },
]);
// --- 结束图标数据 ---
// --- Dash 动画参数 ---
const dashLen = ref(15); // 移动线段的长度
const largeGap = ref(1000); // 一个足够大的间隔,确保只有一个线段可见
// --- 结束 Dash 动画参数 ---
const svgCanvas = ref(null);
const centerElement = ref(null);
const iconRefs = reactive({});
const centerCoords = ref(null);
const iconCoords = reactive({});
// (getElementCenterCoords 和 updateCoordinates 函数保持不变)
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("部分图标坐标未能成功计算。");
// }
};
// (calculatePathForVisual 函数保持不变,我们不再需要 calculatePathForAnimation)
/**
* 计算静态视觉连接线的 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;
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;
/* Prevent text/image selection */
user-select: none;
/* Standard */
-webkit-user-select: none;
/* Safari, Chrome, Opera */
-moz-user-select: none;
/* Firefox */
-ms-user-select: none;
/* IE/Edge */
/* Prevent dragging ghost image (optional but helpful) */
-webkit-user-drag: none;
user-drag: none;
/* Maybe needed for some browsers */
pointer-events: none;
/* Also prevents clicks/hovers directly on the img if needed */
}
.flex-col.justify-around {
justify-content: space-around;
}
/* 可选:给动画路径添加一点模糊效果? */
#path-anim-left,
#path-anim-right {
filter: blur(2px);
background-color: #eac50c;
}
</style>